| | 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) |