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 # --- Basic Configuration --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # --- Initialize Flask App and CORS --- app = Flask(__name__) # In production, you should restrict the origins to your frontend URL CORS(app, resources={r"/api/*": {"origins": "*"}}) # --- Firebase Initialization (Firestore) --- 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) # ----------------------------------------------------------------------------- # 2. AUTHORIZATION MIDDLEWARE (HELPER FUNCTIONS) # ----------------------------------------------------------------------------- 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 # Helper function for migration # Place this helper function somewhere accessible, e.g., before the admin endpoints section. 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)) # In dashboard_server.py 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 # Create a mapping of common variations to the standard code # The keys should be lowercase for case-insensitive matching currency_map = { # US Dollar '$': 'USD', 'dollar': 'USD', 'dollars': 'USD', 'usd': 'USD', # South African Rand 'r': 'ZAR', 'rand': 'ZAR', 'rands': 'ZAR', 'zar': 'ZAR', } # Clean the input and check against the map clean_code = raw_code.lower().strip() return currency_map.get(clean_code, default_code) # ----------------------------------------------------------------------------- # 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS # ----------------------------------------------------------------------------- @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') # Optional phone number if not email or not password or not display_name: return jsonify({'error': 'Email, password, and display name are required'}), 400 # Validate phone number if provided if phone: phone = phone.strip() if not phone: phone = None # Treat empty string as None else: # Check if phone number is already registered 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 # Step 1: Create the user in Firebase Authentication user = auth.create_user( email=email, password=password, display_name=display_name ) # Step 2: Create the data dictionary FOR THE DATABASE, using the special marker user_data_for_db = { 'uid': user.uid, 'email': email, 'displayName': display_name, 'isAdmin': False, 'phone': phone, 'phoneStatus': 'pending' if phone else 'unsubmitted', # Auto-submit for approval if phone provided 'organizationId': None, 'createdAt': firestore.SERVER_TIMESTAMP # This is for Firestore } db.collection('users').document(user.uid).set(user_data_for_db) # Log signup with phone status 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)") # --- THE FIX IS HERE --- # Step 3: Create a SEPARATE dictionary for the JSON response # Replace the special marker with a JSON-friendly ISO date string. response_data = user_data_for_db.copy() response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" # This is for the client # Add appropriate success message based on phone submission 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) # exc_info=True gives more detail if 'EMAIL_EXISTS' in str(e): return jsonify({'error': 'An account with this email already exists.'}), 409 # Check for the specific error text in the exception itself if 'Object of type Sentinel is not JSON serializable' in str(e): # This means the DB write likely succeeded but the return failed. # In this specific case, we can arguably return success. 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: # User already exists, this is safe to return. return jsonify({'uid': uid, **user_doc.to_dict()}), 200 else: # This is a new user (first social login), create their full profile in Firestore. logging.info(f"New social user detected: {uid}. Creating database profile.") try: firebase_user = auth.get_user(uid) # Data for the database, with the special marker 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 # For Firestore } user_ref.set(new_user_data_for_db) logging.info(f"Successfully created profile for new social user: {uid}") # --- THE FIX IS HERE --- # Create a clean copy for the JSON response response_data = new_user_data_for_db.copy() response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" # For the client 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 # ----------------------------------------------------------------------------- # 4. LOGGED-IN USER ENDPOINTS # ----------------------------------------------------------------------------- @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 # Process Sales 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 # Process Expenses 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 # Calculate final totals using the clean, normalized currency codes 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 # Replace your existing PUT /api/user/profile with this one. @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." # --- Handle Display Name Update --- 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() # --- Handle All Phone-Related Scenarios Intelligently --- new_phone = data.get('phone') if new_phone: new_phone_stripped = new_phone.strip() current_phone = current_user_data.get('phone') # Scenario 1: New user submitting a phone for the first time if not current_phone: # Validate uniqueness 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 # Stage the update for simple approval 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.") # Scenario 2: Existing user changing their phone number 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 # Validate uniqueness of the new number 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 # Stage the change based on the user's chosen action 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: # Commit all staged changes to Firestore user_ref.update(update_data) # Also update Firebase Auth display name if it was changed if 'displayName' in update_data: auth.update_user(uid, display_name=update_data['displayName']) # --- Create a Clean, JSON-Safe Response --- updated_user_doc = user_ref.get() final_user_data = updated_user_doc.to_dict() # Convert datetime objects to strings if 'createdAt' in final_user_data and isinstance(final_user_data['createdAt'], datetime): final_user_data['createdAt'] = final_user_data['createdAt'].isoformat() + "Z" # Do not send internal migration data to the frontend if 'migration_data' in final_user_data: del final_user_data['migration_data'] return jsonify({ 'success': True, 'message': response_message, **final_user_data # Return the clean, flattened user object }), 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 # ----------------------------------------------------------------------------- # 5. ADMIN USER MANAGEMENT (FULL CRUD) # ----------------------------------------------------------------------------- @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/', 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() # Validate required fields 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'] # Optional fields with defaults display_name = data.get('displayName', '') phone = data.get('phone', '') is_admin = data.get('isAdmin', False) # Create user in Firebase Auth user_record = auth.create_user( email=email, password=password, display_name=display_name if display_name else None ) # Determine phone status based on whether phone is provided phone_status = 'approved' if phone else 'unsubmitted' # Create user document in Firestore 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 } # Use batch to create user and approve phone if provided batch = db.batch() batch.set(db.collection('users').document(user_record.uid), user_data_for_db) # If phone number is provided, also create phone approval document 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 "")) # Create separate dictionary for JSON response with JSON-friendly timestamps 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) # Handle JSON serialization error from SERVER_TIMESTAMP 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/', 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/', 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('+') # --- EXECUTE ACTION --- 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: # If old document doesn't exist, just create a new empty one 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}") # Simply create a new, approved document for the bot data. db.collection('users').document(to_phone_id).set({ 'status': 'approved', 'ownerUid': target_uid }) # --- FINALIZE --- # Update the user's profile to complete the change. 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 # ----------------------------------------------------------------------------- # 6. ORGANIZATION MANAGEMENT (FULL CRUD) # ----------------------------------------------------------------------------- @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() # Data for the database, with the special marker org_data_for_db = { 'id': org_ref.id, 'name': org_name, 'ownerUid': uid, 'members': [uid], 'createdAt': firestore.SERVER_TIMESTAMP # For Firestore } batch = db.batch() batch.set(org_ref, org_data_for_db) batch.update(db.collection('users').document(uid), {'organizationId': org_ref.id}) batch.commit() # --- THE FIX IS HERE --- # Create a clean copy for the JSON response response_data = org_data_for_db.copy() response_data['createdAt'] = datetime.utcnow().isoformat() + "Z" # For the client 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 # --- Admin Organization Endpoints --- @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/', 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/', 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//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//members/', 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 # ----------------------------------------------------------------------------- # 7. ADMIN DASHBOARD ENDPOINT (INCLUDED) # ----------------------------------------------------------------------------- @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')) # --- Initialization --- 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 = {}, {}, {} # --- First Pass: Process all documents to gather stats and approved phones --- pending_approvals, approved_users, admin_count = 0, 0, 0 approved_phone_numbers = [] for doc in all_users_docs: user_data = doc.to_dict() # This check ensures we only process documents that are actual user profiles 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)} # --- Second Pass: Aggregate financial data from bot documents --- 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 # --- Post-Processing: Generate Leaderboards For Each Currency --- 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]] # --- Final Assembly --- 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 # ----------------------------------------------------------------------------- # 8. SERVER EXECUTION # ----------------------------------------------------------------------------- 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)