|
|
import os |
|
|
import json |
|
|
import logging |
|
|
from datetime import datetime, timedelta |
|
|
from flask import Flask, request, jsonify |
|
|
from flask_cors import CORS |
|
|
import firebase_admin |
|
|
from firebase_admin import credentials, auth, firestore |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
|
|
|
|
|
|
app = Flask(__name__) |
|
|
|
|
|
CORS(app, resources={r"/api/*": {"origins": "*"}}) |
|
|
|
|
|
|
|
|
try: |
|
|
credentials_json_string = os.environ.get("FIREBASE") |
|
|
if not credentials_json_string: |
|
|
raise ValueError("The FIREBASE environment variable is not set.") |
|
|
|
|
|
credentials_json = json.loads(credentials_json_string) |
|
|
cred = credentials.Certificate(credentials_json) |
|
|
|
|
|
if not firebase_admin._apps: |
|
|
firebase_admin.initialize_app(cred) |
|
|
logging.info("Firebase Admin SDK initialized successfully.") |
|
|
|
|
|
db = firestore.client() |
|
|
|
|
|
except Exception as e: |
|
|
logging.critical(f"FATAL: Error initializing Firebase: {e}") |
|
|
exit(1) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_token(auth_header): |
|
|
"""Verifies the Firebase ID token and returns the user's UID.""" |
|
|
if not auth_header or not auth_header.startswith('Bearer '): |
|
|
return None |
|
|
token = auth_header.split('Bearer ')[1] |
|
|
try: |
|
|
return auth.verify_id_token(token)['uid'] |
|
|
except Exception as e: |
|
|
logging.warning(f"Token verification failed: {e}") |
|
|
return None |
|
|
|
|
|
def verify_admin_and_get_uid(auth_header): |
|
|
"""Verifies if the user is an admin and returns their UID.""" |
|
|
uid = verify_token(auth_header) |
|
|
if not uid: |
|
|
raise PermissionError('Invalid or missing user token') |
|
|
user_doc = db.collection('users').document(uid).get() |
|
|
if not user_doc.exists or not user_doc.to_dict().get('isAdmin', False): |
|
|
raise PermissionError('Admin access required') |
|
|
return uid |
|
|
|
|
|
|
|
|
|
|
|
def copy_collection(source_ref, dest_ref): |
|
|
"""Recursively copies documents and sub-collections.""" |
|
|
docs = source_ref.stream() |
|
|
for doc in docs: |
|
|
dest_ref.document(doc.id).set(doc.to_dict()) |
|
|
for sub_coll_ref in doc.reference.collections(): |
|
|
copy_collection(sub_coll_ref, dest_ref.document(doc.id).collection(sub_coll_ref.id)) |
|
|
|
|
|
|
|
|
|
|
|
def normalize_currency_code(raw_code, default_code='USD'): |
|
|
""" |
|
|
Takes a messy currency string (e.g., '$', 'rand', 'R') and returns a |
|
|
standard 3-letter ISO code (e.g., 'USD', 'ZAR'). |
|
|
""" |
|
|
if not raw_code or not isinstance(raw_code, str): |
|
|
return default_code |
|
|
|
|
|
|
|
|
|
|
|
currency_map = { |
|
|
|
|
|
'$': 'USD', 'dollar': 'USD', 'dollars': 'USD', 'usd': 'USD', |
|
|
|
|
|
'r': 'ZAR', 'rand': 'ZAR', 'rands': 'ZAR', 'zar': 'ZAR', |
|
|
} |
|
|
|
|
|
|
|
|
clean_code = raw_code.lower().strip() |
|
|
return currency_map.get(clean_code, default_code) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/auth/signup', methods=['POST']) |
|
|
def signup(): |
|
|
"""Handles new user sign-up with email/password and creates their Firestore profile.""" |
|
|
try: |
|
|
data = request.get_json() |
|
|
email, password, display_name = data.get('email'), data.get('password'), data.get('displayName') |
|
|
phone = data.get('phone') |
|
|
|
|
|
if not email or not password or not display_name: |
|
|
return jsonify({'error': 'Email, password, and display name are required'}), 400 |
|
|
|
|
|
|
|
|
if phone: |
|
|
phone = phone.strip() |
|
|
if not phone: |
|
|
phone = None |
|
|
else: |
|
|
|
|
|
existing_user_query = db.collection('users').where('phone', '==', phone).limit(1).stream() |
|
|
if len(list(existing_user_query)) > 0: |
|
|
return jsonify({'error': 'This phone number is already registered to another account.'}), 409 |
|
|
|
|
|
|
|
|
user = auth.create_user( |
|
|
email=email, |
|
|
password=password, |
|
|
display_name=display_name |
|
|
) |
|
|
|
|
|
|
|
|
user_data_for_db = { |
|
|
'uid': user.uid, |
|
|
'email': email, |
|
|
'displayName': display_name, |
|
|
'isAdmin': False, |
|
|
'phone': phone, |
|
|
'phoneStatus': 'pending' if phone else 'unsubmitted', |
|
|
'organizationId': None, |
|
|
'createdAt': firestore.SERVER_TIMESTAMP |
|
|
} |
|
|
db.collection('users').document(user.uid).set(user_data_for_db) |
|
|
|
|
|
|
|
|
if phone: |
|
|
logging.info(f"New user signed up: {user.uid}, Name: {display_name}, Phone: {phone} (submitted for approval)") |
|
|
else: |
|
|
logging.info(f"New user signed up: {user.uid}, Name: {display_name} (no phone number)") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
response_data = user_data_for_db.copy() |
|
|
response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" |
|
|
|
|
|
|
|
|
success_message = 'Account created successfully.' |
|
|
if phone: |
|
|
success_message += ' Your phone number has been submitted for admin approval.' |
|
|
|
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': success_message, |
|
|
**response_data |
|
|
}), 201 |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Signup failed: {e}", exc_info=True) |
|
|
if 'EMAIL_EXISTS' in str(e): |
|
|
return jsonify({'error': 'An account with this email already exists.'}), 409 |
|
|
|
|
|
if 'Object of type Sentinel is not JSON serializable' in str(e): |
|
|
|
|
|
|
|
|
return jsonify({'success': True, 'uid': data.get('uid'), 'message': 'Account created, but response generation had a minor issue.'}), 201 |
|
|
return jsonify({'error': 'An internal server error occurred.'}), 500 |
|
|
|
|
|
@app.route('/api/auth/social-signin', methods=['POST']) |
|
|
def social_signin(): |
|
|
"""Ensures a user record exists in Firestore after a social login.""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
user_ref = db.collection('users').document(uid) |
|
|
user_doc = user_ref.get() |
|
|
|
|
|
if user_doc.exists: |
|
|
|
|
|
return jsonify({'uid': uid, **user_doc.to_dict()}), 200 |
|
|
else: |
|
|
|
|
|
logging.info(f"New social user detected: {uid}. Creating database profile.") |
|
|
try: |
|
|
firebase_user = auth.get_user(uid) |
|
|
|
|
|
|
|
|
new_user_data_for_db = { |
|
|
'uid': uid, 'email': firebase_user.email, 'displayName': firebase_user.display_name, |
|
|
'isAdmin': False, 'phone': None, 'phoneStatus': 'unsubmitted', 'organizationId': None, |
|
|
'createdAt': firestore.SERVER_TIMESTAMP |
|
|
} |
|
|
user_ref.set(new_user_data_for_db) |
|
|
|
|
|
logging.info(f"Successfully created profile for new social user: {uid}") |
|
|
|
|
|
|
|
|
|
|
|
response_data = new_user_data_for_db.copy() |
|
|
response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" |
|
|
|
|
|
return jsonify({'success': True, **response_data}), 201 |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error creating profile for new social user {uid}: {e}") |
|
|
return jsonify({'error': f'Failed to create user profile: {str(e)}'}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/user/profile', methods=['GET']) |
|
|
def get_user_profile(): |
|
|
"""Retrieves the logged-in user's profile from Firestore.""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
user_doc = db.collection('users').document(uid).get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User profile not found in database'}), 404 |
|
|
|
|
|
return jsonify({'uid': uid, **user_doc.to_dict()}) |
|
|
|
|
|
@app.route('/api/user/dashboard', methods=['GET']) |
|
|
def get_user_dashboard(): |
|
|
""" |
|
|
Retrieves and aggregates data for the user's dashboard. |
|
|
**MODIFIED**: Now filters by a date range if provided. |
|
|
""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
user_doc = db.collection('users').document(uid).get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User not found'}), 404 |
|
|
|
|
|
user_data = user_doc.to_dict() |
|
|
if user_data.get('phoneStatus') != 'approved': |
|
|
return jsonify({'error': 'Your phone number is not yet approved.'}), 403 |
|
|
|
|
|
phone_number = user_data.get('phone') |
|
|
if not phone_number: return jsonify({'error': 'No phone number is associated with your account.'}), 404 |
|
|
|
|
|
try: |
|
|
start_date_str = request.args.get('start_date') |
|
|
end_date_str = request.args.get('end_date') |
|
|
|
|
|
start_date, end_date = None, None |
|
|
if start_date_str: |
|
|
start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00')) |
|
|
if end_date_str: |
|
|
end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00')) |
|
|
|
|
|
bot_data_id = phone_number.lstrip('+') |
|
|
bot_user_ref = db.collection('users').document(bot_data_id) |
|
|
|
|
|
sales_revenue_by_currency, cogs_by_currency, expenses_by_currency = {}, {}, {} |
|
|
sales_count = 0 |
|
|
|
|
|
default_currency_code = normalize_currency_code(user_data.get('defaultCurrency'), 'USD') |
|
|
last_seen_currency_code = default_currency_code |
|
|
|
|
|
|
|
|
sales_query = bot_user_ref.collection('sales').stream() |
|
|
for doc in sales_query: |
|
|
doc_data = doc.to_dict() |
|
|
created_at = doc_data.get('createdAt') |
|
|
|
|
|
if start_date and (not created_at or created_at < start_date): |
|
|
continue |
|
|
if end_date and (not created_at or created_at > end_date): |
|
|
continue |
|
|
|
|
|
details = doc_data.get('details', {}) |
|
|
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code) |
|
|
last_seen_currency_code = currency_code |
|
|
quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0)) |
|
|
sales_revenue_by_currency[currency_code] = sales_revenue_by_currency.get(currency_code, 0) + (price * quantity) |
|
|
cogs_by_currency[currency_code] = cogs_by_currency.get(currency_code, 0) + (cost * quantity) |
|
|
sales_count += 1 |
|
|
|
|
|
|
|
|
expenses_query = bot_user_ref.collection('expenses').stream() |
|
|
for doc in expenses_query: |
|
|
doc_data = doc.to_dict() |
|
|
created_at = doc_data.get('createdAt') |
|
|
|
|
|
if start_date and (not created_at or created_at < start_date): |
|
|
continue |
|
|
if end_date and (not created_at or created_at > end_date): |
|
|
continue |
|
|
|
|
|
details = doc.to_dict().get('details', {}) |
|
|
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code) |
|
|
last_seen_currency_code = currency_code |
|
|
amount = float(details.get('amount', 0)) |
|
|
expenses_by_currency[currency_code] = expenses_by_currency.get(currency_code, 0) + amount |
|
|
|
|
|
|
|
|
all_currencies = set(sales_revenue_by_currency.keys()) | set(cogs_by_currency.keys()) | set(expenses_by_currency.keys()) |
|
|
gross_profit_by_currency, net_profit_by_currency = {}, {} |
|
|
|
|
|
for curr in all_currencies: |
|
|
revenue, cogs, expenses = sales_revenue_by_currency.get(curr, 0), cogs_by_currency.get(curr, 0), expenses_by_currency.get(curr, 0) |
|
|
gross_profit_by_currency[curr] = round(revenue - cogs, 2) |
|
|
net_profit_by_currency[curr] = round(revenue - cogs - expenses, 2) |
|
|
|
|
|
dashboard_data = { |
|
|
'totalSalesRevenueByCurrency': sales_revenue_by_currency, |
|
|
'totalCostOfGoodsSoldByCurrency': cogs_by_currency, |
|
|
'grossProfitByCurrency': gross_profit_by_currency, |
|
|
'totalExpensesByCurrency': expenses_by_currency, |
|
|
'netProfitByCurrency': net_profit_by_currency, |
|
|
'salesCount': sales_count, |
|
|
} |
|
|
return jsonify(dashboard_data), 200 |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error fetching dashboard data for user {uid} (phone: {phone_number}): {e}") |
|
|
return jsonify({'error': 'An error occurred while fetching your dashboard data.'}), 500 |
|
|
|
|
|
|
|
|
@app.route('/api/user/profile', methods=['PUT']) |
|
|
def update_user_profile(): |
|
|
""" |
|
|
A single, intelligent endpoint to handle all user profile updates, |
|
|
including initial phone submission and subsequent changes/migrations. |
|
|
""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
user_ref = db.collection('users').document(uid) |
|
|
user_doc = user_ref.get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User profile not found'}), 404 |
|
|
|
|
|
current_user_data = user_doc.to_dict() |
|
|
data = request.get_json() |
|
|
if not data: return jsonify({'error': 'No data provided'}), 400 |
|
|
|
|
|
update_data = {} |
|
|
response_message = "Profile updated successfully." |
|
|
|
|
|
|
|
|
new_display_name = data.get('displayName') |
|
|
if new_display_name and new_display_name.strip() != current_user_data.get('displayName'): |
|
|
update_data['displayName'] = new_display_name.strip() |
|
|
|
|
|
|
|
|
new_phone = data.get('phone') |
|
|
if new_phone: |
|
|
new_phone_stripped = new_phone.strip() |
|
|
current_phone = current_user_data.get('phone') |
|
|
|
|
|
|
|
|
if not current_phone: |
|
|
|
|
|
existing_user_query = db.collection('users').where('phone', '==', new_phone_stripped).limit(1).stream() |
|
|
if len(list(existing_user_query)) > 0: |
|
|
return jsonify({'error': 'This phone number is already registered to another account.'}), 409 |
|
|
|
|
|
|
|
|
update_data['phone'] = new_phone_stripped |
|
|
update_data['phoneStatus'] = 'pending' |
|
|
response_message += ' Your phone number has been submitted for approval.' |
|
|
logging.info(f"New user {uid} submitted phone {new_phone_stripped} for initial approval.") |
|
|
|
|
|
|
|
|
elif new_phone_stripped != current_phone: |
|
|
action = data.get('phoneChangeAction') |
|
|
if action not in ['migrate', 'start_fresh']: |
|
|
return jsonify({'error': "A choice ('migrate' or 'start_fresh') is required when changing an existing phone number."}), 400 |
|
|
|
|
|
|
|
|
existing_user_query = db.collection('users').where('phone', '==', new_phone_stripped).limit(1).stream() |
|
|
if len(list(existing_user_query)) > 0: |
|
|
return jsonify({'error': 'This phone number is already registered to another account.'}), 409 |
|
|
|
|
|
|
|
|
update_data['migration_data'] = {'from_phone': current_phone, 'to_phone': new_phone_stripped, 'action': action} |
|
|
update_data['phoneStatus'] = 'pending_migration' if action == 'migrate' else 'pending_fresh_start' |
|
|
response_message += ' Your request to change your phone number has been submitted for approval.' |
|
|
logging.info(f"User {uid} initiated phone change to {new_phone_stripped} with action '{action}'.") |
|
|
|
|
|
if not update_data: |
|
|
return jsonify({'message': 'No changes detected.'}), 200 |
|
|
|
|
|
try: |
|
|
|
|
|
user_ref.update(update_data) |
|
|
|
|
|
|
|
|
if 'displayName' in update_data: |
|
|
auth.update_user(uid, display_name=update_data['displayName']) |
|
|
|
|
|
|
|
|
updated_user_doc = user_ref.get() |
|
|
final_user_data = updated_user_doc.to_dict() |
|
|
|
|
|
|
|
|
if 'createdAt' in final_user_data and isinstance(final_user_data['createdAt'], datetime): |
|
|
final_user_data['createdAt'] = final_user_data['createdAt'].isoformat() + "Z" |
|
|
|
|
|
|
|
|
if 'migration_data' in final_user_data: |
|
|
del final_user_data['migration_data'] |
|
|
|
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': response_message, |
|
|
**final_user_data |
|
|
}), 200 |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error updating profile for user {uid}: {e}", exc_info=True) |
|
|
return jsonify({'error': 'Failed to update profile'}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/admin/users', methods=['GET']) |
|
|
def get_all_users(): |
|
|
"""Admin: Retrieve a list of all users.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
all_users = [doc.to_dict() for doc in db.collection('users').stream()] |
|
|
return jsonify(all_users), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to fetch all users: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/users/<string:target_uid>', methods=['GET']) |
|
|
def get_single_user(target_uid): |
|
|
"""Admin: Get the detailed profile of a single user.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
user_doc = db.collection('users').document(target_uid).get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User not found'}), 404 |
|
|
return jsonify(user_doc.to_dict()), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to fetch user {target_uid}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/users', methods=['POST']) |
|
|
def admin_create_user(): |
|
|
"""Admin: Create a new user with email, password, and profile information.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
|
|
|
|
|
|
if not data or 'email' not in data or 'password' not in data: |
|
|
return jsonify({'error': 'Email and password are required'}), 400 |
|
|
|
|
|
email = data['email'] |
|
|
password = data['password'] |
|
|
|
|
|
|
|
|
display_name = data.get('displayName', '') |
|
|
phone = data.get('phone', '') |
|
|
is_admin = data.get('isAdmin', False) |
|
|
|
|
|
|
|
|
user_record = auth.create_user( |
|
|
email=email, |
|
|
password=password, |
|
|
display_name=display_name if display_name else None |
|
|
) |
|
|
|
|
|
|
|
|
phone_status = 'approved' if phone else 'unsubmitted' |
|
|
|
|
|
|
|
|
user_data_for_db = { |
|
|
'uid': user_record.uid, |
|
|
'email': email, |
|
|
'displayName': display_name, |
|
|
'phone': phone, |
|
|
'isAdmin': is_admin, |
|
|
'phoneStatus': phone_status, |
|
|
'organizationId': None, |
|
|
'createdAt': firestore.SERVER_TIMESTAMP, |
|
|
'updatedAt': firestore.SERVER_TIMESTAMP |
|
|
} |
|
|
|
|
|
|
|
|
batch = db.batch() |
|
|
batch.set(db.collection('users').document(user_record.uid), user_data_for_db) |
|
|
|
|
|
|
|
|
if phone: |
|
|
batch.set(db.collection('users').document(phone), {'status': 'approved'}, merge=True) |
|
|
|
|
|
batch.commit() |
|
|
|
|
|
logging.info(f"Admin created new user {user_record.uid} with email {email}" + |
|
|
(f" and auto-approved phone {phone}" if phone else "")) |
|
|
|
|
|
|
|
|
response_data = user_data_for_db.copy() |
|
|
current_time = datetime.utcnow().isoformat() + "Z" |
|
|
response_data['createdAt'] = current_time |
|
|
response_data['updatedAt'] = current_time |
|
|
response_data['phoneStatus'] = phone_status |
|
|
|
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': 'User created successfully', |
|
|
'user': response_data |
|
|
}), 201 |
|
|
|
|
|
except auth.EmailAlreadyExistsError: |
|
|
return jsonify({'error': 'A user with this email already exists'}), 409 |
|
|
except auth.WeakPasswordError: |
|
|
return jsonify({'error': 'Password is too weak. Must be at least 6 characters'}), 400 |
|
|
except auth.InvalidEmailError: |
|
|
return jsonify({'error': 'Invalid email format'}), 400 |
|
|
except PermissionError as e: |
|
|
return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to create user: {e}", exc_info=True) |
|
|
|
|
|
if 'Object of type Sentinel is not JSON serializable' in str(e): |
|
|
return jsonify({ |
|
|
'success': True, |
|
|
'message': 'User created successfully, but response generation had a minor issue.', |
|
|
'uid': user_record.uid if 'user_record' in locals() else None |
|
|
}), 201 |
|
|
return jsonify({'error': 'An internal error occurred during user creation'}), 500 |
|
|
|
|
|
@app.route('/api/admin/users/<string:target_uid>', methods=['PUT']) |
|
|
def admin_update_user(target_uid): |
|
|
"""Admin: Update a user's profile information.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
update_data = {} |
|
|
if 'displayName' in data: update_data['displayName'] = data['displayName'] |
|
|
if 'phone' in data: update_data['phone'] = data['phone'] |
|
|
if 'isAdmin' in data: update_data['isAdmin'] = data['isAdmin'] |
|
|
if not update_data: return jsonify({'error': 'No update data provided'}), 400 |
|
|
db.collection('users').document(target_uid).update(update_data) |
|
|
logging.info(f"Admin updated profile for user {target_uid}") |
|
|
return jsonify({'success': True, 'message': 'User profile updated'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to update user {target_uid}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/users/<string:target_uid>', methods=['DELETE']) |
|
|
def admin_delete_user(target_uid): |
|
|
"""Admin: Delete a user from Auth and Firestore.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
auth.delete_user(target_uid) |
|
|
db.collection('users').document(target_uid).delete() |
|
|
logging.info(f"Admin deleted user {target_uid} from Auth and Firestore.") |
|
|
return jsonify({'success': True, 'message': 'User deleted successfully'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to delete user {target_uid}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred during deletion'}), 500 |
|
|
|
|
|
@app.route('/api/admin/users/approve', methods=['POST']) |
|
|
def approve_user_phone(): |
|
|
"""Admin: Approve a user's phone number, enabling bot access.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
target_uid = data.get('uid') |
|
|
if not target_uid: return jsonify({'error': 'User UID is required'}), 400 |
|
|
user_ref = db.collection('users').document(target_uid) |
|
|
user_doc = user_ref.get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User not found'}), 404 |
|
|
phone_number = user_doc.to_dict().get('phone') |
|
|
if not phone_number: return jsonify({'error': 'User has no phone number submitted'}), 400 |
|
|
batch = db.batch() |
|
|
batch.update(user_ref, {'phoneStatus': 'approved'}) |
|
|
batch.set(db.collection('users').document(phone_number), {'status': 'approved'}, merge=True) |
|
|
batch.commit() |
|
|
return jsonify({'success': True, 'message': f'User {target_uid} approved.'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin approval failed for user {data.get('uid')}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
|
|
|
@app.route('/api/admin/users/approve-phone-change', methods=['POST']) |
|
|
def approve_phone_change(): |
|
|
""" |
|
|
Admin: Approves a phone number change. Handles both 'migrate' and 'start_fresh' |
|
|
scenarios based on the user's stored choice. |
|
|
""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
target_uid = data.get('uid') |
|
|
if not target_uid: |
|
|
return jsonify({'error': 'User UID is required for approval'}), 400 |
|
|
|
|
|
user_ref = db.collection('users').document(target_uid) |
|
|
user_doc = user_ref.get() |
|
|
if not user_doc.exists: return jsonify({'error': 'User not found'}), 404 |
|
|
|
|
|
user_data = user_doc.to_dict() |
|
|
status = user_data.get('phoneStatus') |
|
|
if status not in ['pending_migration', 'pending_fresh_start']: |
|
|
return jsonify({'error': f'User is not awaiting a phone change (status is {status})'}), 400 |
|
|
|
|
|
migration_data = user_data.get('migration_data') |
|
|
if not migration_data or 'to_phone' not in migration_data or 'action' not in migration_data: |
|
|
return jsonify({'error': 'Invalid migration data in user profile'}), 500 |
|
|
|
|
|
action = migration_data['action'] |
|
|
to_phone = migration_data['to_phone'] |
|
|
to_phone_id = to_phone.lstrip('+') |
|
|
|
|
|
|
|
|
if action == 'migrate': |
|
|
from_phone = migration_data.get('from_phone') |
|
|
if not from_phone: |
|
|
return jsonify({'error': 'Cannot migrate: Original phone number is missing.'}), 400 |
|
|
|
|
|
from_phone_id = from_phone.lstrip('+') |
|
|
source_ref = db.collection('users').document(from_phone_id) |
|
|
dest_ref = db.collection('users').document(to_phone_id) |
|
|
|
|
|
logging.info(f"Admin approved MIGRATION for user {target_uid}. Copying from {from_phone_id} to {to_phone_id}") |
|
|
source_doc = source_ref.get() |
|
|
if source_doc.exists: |
|
|
dest_ref.set(source_doc.to_dict()) |
|
|
for coll_ref in source_ref.collections(): |
|
|
copy_collection(coll_ref, dest_ref.collection(coll_ref.id)) |
|
|
else: |
|
|
|
|
|
dest_ref.set({'status': 'approved', 'ownerUid': target_uid}) |
|
|
|
|
|
elif action == 'start_fresh': |
|
|
logging.info(f"Admin approved FRESH START for user {target_uid} on new number {to_phone_id}") |
|
|
|
|
|
db.collection('users').document(to_phone_id).set({ |
|
|
'status': 'approved', |
|
|
'ownerUid': target_uid |
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
user_ref.update({ |
|
|
'phone': to_phone, |
|
|
'phoneStatus': 'approved', |
|
|
'migration_data': firestore.DELETE_FIELD |
|
|
}) |
|
|
|
|
|
return jsonify({'success': True, 'message': f"Phone change action '{action}' completed successfully."}), 200 |
|
|
|
|
|
except PermissionError as e: |
|
|
return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin phone change approval failed for user {data.get('uid')}: {e}", exc_info=True) |
|
|
return jsonify({'error': 'An internal error occurred during the process'}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/organizations', methods=['POST']) |
|
|
def create_organization(): |
|
|
"""A logged-in user creates a new organization.""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
data = request.get_json() |
|
|
org_name = data.get('name') |
|
|
if not org_name: return jsonify({'error': 'Organization name is required'}), 400 |
|
|
|
|
|
try: |
|
|
org_ref = db.collection('organizations').document() |
|
|
|
|
|
|
|
|
org_data_for_db = { |
|
|
'id': org_ref.id, 'name': org_name, 'ownerUid': uid, |
|
|
'members': [uid], 'createdAt': firestore.SERVER_TIMESTAMP |
|
|
} |
|
|
|
|
|
batch = db.batch() |
|
|
batch.set(org_ref, org_data_for_db) |
|
|
batch.update(db.collection('users').document(uid), {'organizationId': org_ref.id}) |
|
|
batch.commit() |
|
|
|
|
|
|
|
|
|
|
|
response_data = org_data_for_db.copy() |
|
|
response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" |
|
|
|
|
|
return jsonify(response_data), 201 |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"User {uid} failed to create organization: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/my-organization', methods=['GET']) |
|
|
def get_my_organization(): |
|
|
"""A logged-in user retrieves details of their organization.""" |
|
|
uid = verify_token(request.headers.get('Authorization')) |
|
|
if not uid: return jsonify({'error': 'Invalid or expired token'}), 401 |
|
|
|
|
|
user_doc = db.collection('users').document(uid).get() |
|
|
org_id = user_doc.to_dict().get('organizationId') |
|
|
if not org_id: return jsonify({'error': 'User does not belong to an organization'}), 404 |
|
|
|
|
|
org_doc = db.collection('organizations').document(org_id).get() |
|
|
if not org_doc.exists: return jsonify({'error': 'Organization not found'}), 404 |
|
|
|
|
|
return jsonify(org_doc.to_dict()), 200 |
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/admin/organizations', methods=['GET']) |
|
|
def get_all_organizations(): |
|
|
"""Admin: Get a list of all organizations.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
orgs = [doc.to_dict() for doc in db.collection('organizations').stream()] |
|
|
return jsonify(orgs), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to fetch organizations: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/organizations/<string:org_id>', methods=['PUT']) |
|
|
def admin_update_organization(org_id): |
|
|
"""Admin: Update an organization's name.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
new_name = data.get('name') |
|
|
if not new_name: return jsonify({'error': 'New name is required'}), 400 |
|
|
db.collection('organizations').document(org_id).update({'name': new_name}) |
|
|
return jsonify({'success': True, 'message': 'Organization updated'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to update organization {org_id}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/organizations/<string:org_id>', methods=['DELETE']) |
|
|
def admin_delete_organization(org_id): |
|
|
"""Admin: Delete an organization and clean up member profiles.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
org_ref = db.collection('organizations').document(org_id) |
|
|
org_doc = org_ref.get() |
|
|
if not org_doc.exists: return jsonify({'error': 'Organization not found'}), 404 |
|
|
|
|
|
members = org_doc.to_dict().get('members', []) |
|
|
for member_uid in members: |
|
|
db.collection('users').document(member_uid).update({'organizationId': None}) |
|
|
|
|
|
org_ref.delete() |
|
|
return jsonify({'success': True, 'message': 'Organization deleted'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to delete organization {org_id}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/organizations/<string:org_id>/members', methods=['POST']) |
|
|
def admin_add_member_to_org(org_id): |
|
|
"""Admin: Add a user to an organization.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
data = request.get_json() |
|
|
member_uid = data.get('uid') |
|
|
if not member_uid: return jsonify({'error': 'User UID is required'}), 400 |
|
|
|
|
|
batch = db.batch() |
|
|
batch.update(db.collection('organizations').document(org_id), {'members': firestore.ArrayUnion([member_uid])}) |
|
|
batch.update(db.collection('users').document(member_uid), {'organizationId': org_id}) |
|
|
batch.commit() |
|
|
return jsonify({'success': True, 'message': 'Member added'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to add member to org {org_id}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
@app.route('/api/admin/organizations/<string:org_id>/members/<string:member_uid>', methods=['DELETE']) |
|
|
def admin_remove_member_from_org(org_id, member_uid): |
|
|
"""Admin: Remove a user from an organization.""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
batch = db.batch() |
|
|
batch.update(db.collection('organizations').document(org_id), {'members': firestore.ArrayRemove([member_uid])}) |
|
|
batch.update(db.collection('users').document(member_uid), {'organizationId': None}) |
|
|
batch.commit() |
|
|
return jsonify({'success': True, 'message': 'Member removed'}), 200 |
|
|
except PermissionError as e: return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to remove member from org {org_id}: {e}") |
|
|
return jsonify({'error': 'An internal error occurred'}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/api/admin/dashboard/stats', methods=['GET']) |
|
|
def get_admin_dashboard_stats(): |
|
|
""" |
|
|
Retrieves complete global statistics, including an accurate total user count |
|
|
and separate Top 5 leaderboards for each currency. |
|
|
**MODIFIED**: Now filters by a date range if provided. |
|
|
""" |
|
|
try: |
|
|
verify_admin_and_get_uid(request.headers.get('Authorization')) |
|
|
|
|
|
start_date_str = request.args.get('start_date') |
|
|
end_date_str = request.args.get('end_date') |
|
|
|
|
|
start_date, end_date = None, None |
|
|
if start_date_str: |
|
|
start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00')) |
|
|
if end_date_str: |
|
|
end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00')) |
|
|
|
|
|
|
|
|
all_users_docs = list(db.collection('users').stream()) |
|
|
all_orgs_docs = list(db.collection('organizations').stream()) |
|
|
|
|
|
user_sales_data, global_item_revenue, global_expense_totals, phone_to_user_map = {}, {}, {}, {} |
|
|
global_sales_rev_by_curr, global_cogs_by_curr, global_expenses_by_curr = {}, {}, {} |
|
|
|
|
|
|
|
|
pending_approvals, approved_users, admin_count = 0, 0, 0 |
|
|
approved_phone_numbers = [] |
|
|
|
|
|
for doc in all_users_docs: |
|
|
user_data = doc.to_dict() |
|
|
|
|
|
if user_data.get('email'): |
|
|
phone = user_data.get('phone') |
|
|
if user_data.get('phoneStatus') == 'pending': |
|
|
pending_approvals += 1 |
|
|
elif user_data.get('phoneStatus') == 'approved' and phone: |
|
|
approved_users += 1 |
|
|
approved_phone_numbers.append(phone) |
|
|
phone_to_user_map[phone] = { |
|
|
'displayName': user_data.get('displayName', 'N/A'), 'uid': user_data.get('uid'), |
|
|
'defaultCurrency': user_data.get('defaultCurrency', 'USD') |
|
|
} |
|
|
if user_data.get('isAdmin', False): |
|
|
admin_count += 1 |
|
|
|
|
|
user_profile_docs = [doc for doc in all_users_docs if doc.to_dict().get('email')] |
|
|
|
|
|
user_stats = { |
|
|
'total': len(user_profile_docs), |
|
|
'admins': admin_count, |
|
|
'approvedForBot': approved_users, |
|
|
'pendingApproval': pending_approvals |
|
|
} |
|
|
|
|
|
org_stats = {'total': len(all_orgs_docs)} |
|
|
|
|
|
|
|
|
sales_count = 0 |
|
|
for phone in approved_phone_numbers: |
|
|
try: |
|
|
bot_data_id = phone.lstrip('+') |
|
|
bot_user_ref = db.collection('users').document(bot_data_id) |
|
|
|
|
|
user_sales_data[phone] = {'total_revenue_by_currency': {}, 'item_sales': {}} |
|
|
last_seen_currency_code = normalize_currency_code(phone_to_user_map.get(phone, {}).get('defaultCurrency'), 'USD') |
|
|
|
|
|
sales_query = bot_user_ref.collection('sales').stream() |
|
|
for sale_doc in sales_query: |
|
|
sale_data = sale_doc.to_dict() |
|
|
created_at = sale_data.get('createdAt') |
|
|
|
|
|
if start_date and (not created_at or created_at < start_date): |
|
|
continue |
|
|
if end_date and (not created_at or created_at > end_date): |
|
|
continue |
|
|
|
|
|
details = sale_data.get('details', {}) |
|
|
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code) |
|
|
last_seen_currency_code = currency_code |
|
|
quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0)) |
|
|
sale_revenue = price * quantity |
|
|
item_name = details.get('item', 'Unknown Item') |
|
|
|
|
|
global_sales_rev_by_curr[currency_code] = global_sales_rev_by_curr.get(currency_code, 0) + sale_revenue |
|
|
global_cogs_by_curr[currency_code] = global_cogs_by_curr.get(currency_code, 0) + (cost * quantity) |
|
|
sales_count += 1 |
|
|
|
|
|
user_sales_data[phone]['total_revenue_by_currency'][currency_code] = user_sales_data[phone]['total_revenue_by_currency'].get(currency_code, 0) + sale_revenue |
|
|
|
|
|
if item_name not in global_item_revenue: global_item_revenue[item_name] = {} |
|
|
global_item_revenue[item_name][currency_code] = global_item_revenue[item_name].get(currency_code, 0) + sale_revenue |
|
|
|
|
|
expenses_query = bot_user_ref.collection('expenses').stream() |
|
|
for expense_doc in expenses_query: |
|
|
expense_data = expense_doc.to_dict() |
|
|
created_at = expense_data.get('createdAt') |
|
|
|
|
|
if start_date and (not created_at or created_at < start_date): |
|
|
continue |
|
|
if end_date and (not created_at or created_at > end_date): |
|
|
continue |
|
|
|
|
|
details = expense_data.get('details', {}) |
|
|
currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code) |
|
|
last_seen_currency_code = currency_code |
|
|
amount = float(details.get('amount', 0)) |
|
|
category = details.get('description', 'Uncategorized') |
|
|
|
|
|
global_expenses_by_curr[currency_code] = global_expenses_by_curr.get(currency_code, 0) + amount |
|
|
if category not in global_expense_totals: global_expense_totals[category] = {} |
|
|
global_expense_totals[category][currency_code] = global_expense_totals[category].get(currency_code, 0) + amount |
|
|
except Exception as e: |
|
|
logging.error(f"Admin stats: Could not process data for phone {phone}. Error: {e}") |
|
|
continue |
|
|
|
|
|
|
|
|
all_currencies = set(global_sales_rev_by_curr.keys()) | set(global_expenses_by_curr.keys()) |
|
|
|
|
|
leaderboards = {'topUsersByRevenue': {}, 'topSellingItems': {}, 'topExpenses': {}} |
|
|
|
|
|
for currency in all_currencies: |
|
|
users_in_curr = [(phone, data['total_revenue_by_currency'].get(currency, 0)) for phone, data in user_sales_data.items() if data['total_revenue_by_currency'].get(currency, 0) > 0] |
|
|
sorted_users = sorted(users_in_curr, key=lambda item: item[1], reverse=True) |
|
|
leaderboards['topUsersByRevenue'][currency] = [{'displayName': phone_to_user_map.get(phone, {}).get('displayName'), 'uid': phone_to_user_map.get(phone, {}).get('uid'), 'totalRevenue': round(revenue, 2)} for phone, revenue in sorted_users[:5]] |
|
|
|
|
|
items_in_curr = [(name, totals.get(currency, 0)) for name, totals in global_item_revenue.items() if totals.get(currency, 0) > 0] |
|
|
sorted_items = sorted(items_in_curr, key=lambda item: item[1], reverse=True) |
|
|
leaderboards['topSellingItems'][currency] = [{'item': name, 'totalRevenue': round(revenue, 2)} for name, revenue in sorted_items[:5]] |
|
|
|
|
|
expenses_in_curr = [(name, totals.get(currency, 0)) for name, totals in global_expense_totals.items() if totals.get(currency, 0) > 0] |
|
|
sorted_expenses = sorted(expenses_in_curr, key=lambda item: item[1], reverse=True) |
|
|
leaderboards['topExpenses'][currency] = [{'category': name, 'totalAmount': round(amount, 2)} for name, amount in sorted_expenses[:5]] |
|
|
|
|
|
|
|
|
global_net_profit_by_curr = {} |
|
|
for curr in all_currencies: |
|
|
revenue = global_sales_rev_by_curr.get(curr, 0) |
|
|
cogs = global_cogs_by_curr.get(curr, 0) |
|
|
expenses = global_expenses_by_curr.get(curr, 0) |
|
|
global_net_profit_by_curr[curr] = round(revenue - cogs - expenses, 2) |
|
|
|
|
|
system_stats = { |
|
|
'totalSalesRevenueByCurrency': {k: round(v, 2) for k, v in global_sales_rev_by_curr.items()}, |
|
|
'totalCostOfGoodsSoldByCurrency': {k: round(v, 2) for k, v in global_cogs_by_curr.items()}, |
|
|
'totalExpensesByCurrency': {k: round(v, 2) for k, v in global_expenses_by_curr.items()}, |
|
|
'totalNetProfitByCurrency': global_net_profit_by_curr, |
|
|
'totalSalesCount': sales_count, |
|
|
} |
|
|
|
|
|
return jsonify({ |
|
|
'userStats': user_stats, 'organizationStats': org_stats, |
|
|
'systemStats': system_stats, 'leaderboards': leaderboards |
|
|
}), 200 |
|
|
|
|
|
except PermissionError as e: |
|
|
return jsonify({'error': str(e)}), 403 |
|
|
except Exception as e: |
|
|
logging.error(f"Admin failed to fetch dashboard stats: {e}", exc_info=True) |
|
|
return jsonify({'error': 'An internal error occurred while fetching stats'}), 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
port = int(os.environ.get("PORT", 7860)) |
|
|
debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true" |
|
|
|
|
|
logging.info(f"Starting Dashboard Server. Debug mode: {debug_mode}, Port: {port}") |
|
|
if not debug_mode: |
|
|
from waitress import serve |
|
|
serve(app, host="0.0.0.0", port=port) |
|
|
else: |
|
|
app.run(debug=True, host="0.0.0.0", port=port) |