sqx-api / main.py
rairo's picture
Update main.py
1f5b5a3 verified
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/<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()
# 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/<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('+')
# --- 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/<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
# -----------------------------------------------------------------------------
# 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)