import { onRequest } from "firebase-functions/v2/https"; import { getLogger, getAdmin } from "../shared/config"; import * as admin from "firebase-admin"; const app = getAdmin(); const logger = getLogger(); const CACHE_FOLDER = "gym_member_cache"; interface MembershipData { gymId?: string; userId?: string; status?: string; subscription?: { hasPersonalTraining?: boolean; frequency?: string; [key: string]: any; }; isPartialPayment?: boolean; remainingAmount?: number; createdAt?: admin.firestore.Timestamp; updatedAt?: admin.firestore.Timestamp; [key: string]: any; } interface ClientFields { fields?: { [key: string]: any }; [key: string]: any; } interface PaymentData { dateTimestamp?: admin.firestore.Timestamp; [key: string]: any; } interface TrainerAssignment { id?: string; membershipId?: string; timeSlot?: any[]; createdAt?: admin.firestore.Timestamp; [key: string]: any; } interface CacheEntry { userId: string; membershipId: string; memberData: { daysUntilExpiry?: number | null; hasPartialPayment: boolean; createdAt?: string | null; updatedAt?: string | null; [key: string]: any; }; fields: { [key: string]: any }; renewalDate?: string | null; trainerAssignments: any[]; timeSlots: any[]; lastUpdated: string; } interface JsonCacheData { gymId: string; members: CacheEntry[]; totalMembers: number; lastUpdated: string; cacheVersion: string; } export const getMemberCache = onRequest( { region: "#{SERVICES_RGN}#", cors: true, }, async (req, res) => { try { if (req.method === "OPTIONS") { res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Methods", "GET, POST"); res.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.status(204).send(""); return; } res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); let requestData: any = {}; if (req.method === "GET") { requestData = req.query; } else if (req.method === "POST") { requestData = req.body; } const { gymId } = requestData; if (!gymId) { res.status(400).json({ error: "gymId is required" }); return; } const fileName = `${CACHE_FOLDER}/${gymId}.json`; const file = app.storage().bucket().file(fileName); try { const [fileBuffer] = await file.download(); const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString()); logger.info( `Retrieved ${jsonData.totalMembers} members from cache for gym ${gymId}` ); res.status(200).json(jsonData); } catch (error) { logger.error(`Error reading cache file for gym ${gymId}:`, error); res.status(404).json({ error: "Cache not found for this gym. Please update cache first.", gymId, members: [], totalMembers: 0, lastUpdated: new Date().toISOString(), cacheVersion: "2.0", }); } } catch (error) { logger.error("Error getting member cache:", error); res.status(500).json({ error: "Error retrieving cached data" }); } } ); export const updateMemberCache = onRequest( { region: "#{SERVICES_RGN}#", cors: true, timeoutSeconds: 540, }, async (req, res) => { try { if (req.method === "OPTIONS") { res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Methods", "POST"); res.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.status(204).send(""); return; } res.set("Access-Control-Allow-Origin", "*"); res.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method !== "POST") { res.status(405).json({ error: "Method not allowed. Use POST." }); return; } const { gymId, membershipIds } = req.body; let incrementalUpdate = req.body.incrementalUpdate || false; if (!gymId) { res.status(400).json({ error: "gymId is required" }); return; } logger.info( `Starting cache ${ incrementalUpdate ? "incremental update" : "full refresh" } for gym: ${gymId}` ); let members: CacheEntry[] = []; let existingData: JsonCacheData | null = null; if (incrementalUpdate) { try { const fileName = `${CACHE_FOLDER}/${gymId}.json`; const file = app.storage().bucket().file(fileName); const [fileBuffer] = await file.download(); existingData = JSON.parse(fileBuffer.toString()); if (existingData) { members = [...existingData.members]; logger.info( `Loaded existing cache with ${members.length} members for incremental update` ); } else { logger.warn( "Existing cache data is invalid, performing full refresh" ); incrementalUpdate = false; } } catch (error) { logger.warn( `Could not load existing cache for incremental update, performing full refresh: ${error}` ); incrementalUpdate = false; } } if (incrementalUpdate && membershipIds && Array.isArray(membershipIds)) { await updateSpecificMembers(gymId, membershipIds, members); } else { members = await fetchAllMembers(gymId); } const jsonData: JsonCacheData = { gymId, members, totalMembers: members.length, lastUpdated: new Date().toISOString(), cacheVersion: "2.0", }; await saveCacheToStorage(gymId, jsonData); const updateType = incrementalUpdate ? "incremental update" : "full refresh"; logger.info( `Cache ${updateType} completed successfully for gym ${gymId} with ${members.length} members` ); res.status(200).json({ success: true, message: `Cache ${updateType} completed successfully for gym ${gymId}`, totalMembers: members.length, lastUpdated: jsonData.lastUpdated, updateType: updateType, }); } catch (error) { logger.error("Error updating member cache:", error); res.status(500).json({ error: "Error updating cache", details: error instanceof Error ? error.message : String(error), }); } } ); async function fetchAllMembers(gymId: string): Promise { const members: CacheEntry[] = []; const membershipsSnapshot = await app .firestore() .collection("memberships") .where("gymId", "==", gymId) .orderBy("createdAt", "desc") .get(); const batchSize = 10; for (let i = 0; i < membershipsSnapshot.docs.length; i += batchSize) { const batch = membershipsSnapshot.docs.slice(i, i + batchSize); const batchPromises = batch.map(async (doc) => { try { const membershipData = doc.data() as MembershipData; const userId = membershipData.userId; if (!userId) { logger.warn( `Skipping member with ID ${doc.id} due to missing userId` ); return null; } return await generateCacheEntry(userId, doc.id, membershipData); } catch (error) { logger.error(`Error processing member ${doc.id}:`, error); return null; } }); const batchResults = await Promise.all(batchPromises); const validResults = batchResults.filter( (member): member is CacheEntry => member !== null ); members.push(...validResults); } return members; } async function updateSpecificMembers( gymId: string, membershipIds: string[], existingMembers: CacheEntry[] ): Promise { logger.info(`Updating ${membershipIds.length} specific members`); const existingMembersMap = new Map(); existingMembers.forEach((member, index) => { existingMembersMap.set(member.membershipId, index); }); const batchSize = 10; for (let i = 0; i < membershipIds.length; i += batchSize) { const batch = membershipIds.slice(i, i + batchSize); const membershipDocs = await Promise.all( batch.map(async (membershipId) => { try { const doc = await app .firestore() .collection("memberships") .doc(membershipId) .get(); return doc.exists ? { id: doc.id, data: doc.data() } : null; } catch (error) { logger.error(`Error fetching membership ${membershipId}:`, error); return null; } }) ); const batchPromises = membershipDocs .filter((doc): doc is { id: string; data: any } => doc !== null) .map(async (doc) => { try { const membershipData = doc.data as MembershipData; const userId = membershipData.userId; if (!userId) { logger.warn( `Skipping member with ID ${doc.id} due to missing userId` ); return null; } return await generateCacheEntry(userId, doc.id, membershipData); } catch (error) { logger.error(`Error processing member ${doc.id}:`, error); return null; } }); const batchResults = await Promise.all(batchPromises); batchResults.forEach((updatedMember) => { if (updatedMember) { const existingIndex = existingMembersMap.get( updatedMember.membershipId ); if (existingIndex !== undefined) { existingMembers[existingIndex] = updatedMember; } else { existingMembers.push(updatedMember); } } }); } } async function saveCacheToStorage( gymId: string, jsonData: JsonCacheData ): Promise { const fileName = `${CACHE_FOLDER}/${gymId}.json`; const file = app.storage().bucket().file(fileName); await file.save(JSON.stringify(jsonData, null, 2), { metadata: { contentType: "application/json", metadata: { gymId: gymId, totalMembers: jsonData.totalMembers.toString(), generatedAt: jsonData.lastUpdated, cacheVersion: jsonData.cacheVersion, }, }, }); } async function generateCacheEntry( userId: string, membershipId: string, membershipData: MembershipData ): Promise { try { let fields: { [key: string]: string } = {}; try { const clientFieldsSnapshot = await app .firestore() .collection("client_profiles") .where("uid", "==", userId) .get(); if (!clientFieldsSnapshot.empty) { const fieldDoc = clientFieldsSnapshot.docs[0]; const fieldData = fieldDoc.data() as ClientFields; fields = { ...fieldData.fields }; Object.keys(fieldData).forEach((key) => { if (key !== "fields" && !fields[key]) { fields[key] = fieldData[key]; } }); } } catch (error) { logger.error(`Error getting fields for user ${userId}:`, error); } let renewalDate: Date | null = null; let daysUntilExpiry: number | null = null; if (membershipData.subscription) { try { const paymentsSnapshot = await app .firestore() .collection("membership_payments") .where("membershipId", "==", membershipId) .orderBy("dateTimestamp", "desc") .limit(1) .get(); if (!paymentsSnapshot.empty) { const latestPayment = paymentsSnapshot.docs[0].data() as PaymentData; if (latestPayment.dateTimestamp) { const paymentDate = latestPayment.dateTimestamp.toDate(); renewalDate = calculateRenewalDateTimeFromPayment( membershipData.subscription, paymentDate ); if (renewalDate && membershipData.status === "ACTIVE") { const now = new Date(); const difference = Math.floor( (renewalDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) ); daysUntilExpiry = difference > 0 ? difference : null; } } } } catch (error) { logger.error( `Error getting renewal date for membership ${membershipId}:`, error ); } } const isPartial = membershipData.isPartialPayment === true; const remaining = membershipData.remainingAmount || 0; const hasPartialPayment = isPartial && remaining > 0; let trainerAssignments: any[] = []; let timeSlots: any[] = []; if (membershipData.subscription?.hasPersonalTraining) { try { const assignmentsSnapshot = await app .firestore() .collection("personal_trainer_assignments") .where("membershipId", "==", membershipId) .get(); assignmentsSnapshot.docs.forEach((doc) => { const data = doc.data() as TrainerAssignment; if (data.timeSlot && Array.isArray(data.timeSlot)) { timeSlots.push(...data.timeSlot); } trainerAssignments.push({ id: doc.id, ...data }); }); } catch (error) { logger.error( `Error getting trainer data for membership ${membershipId}:`, error ); } } const cacheEntry: CacheEntry = { userId, membershipId, memberData: { ...membershipData, hasPartialPayment, daysUntilExpiry, createdAt: membershipData.createdAt ? membershipData.createdAt.toDate().toISOString() : null, updatedAt: membershipData.updatedAt ? membershipData.updatedAt.toDate().toISOString() : null, }, fields, renewalDate: renewalDate ? renewalDate.toISOString() : null, trainerAssignments, timeSlots, lastUpdated: new Date().toISOString(), }; return cacheEntry; } catch (error) { logger.error("Error generating cache entry:", error); throw error; } } function calculateRenewalDateTimeFromPayment( subscription: { frequency?: string }, paymentDate: Date ): Date { const frequency = subscription.frequency || "Monthly"; switch (frequency) { case "Monthly": return new Date( paymentDate.getFullYear(), paymentDate.getMonth() + 1, paymentDate.getDate() ); case "Quarterly": return new Date( paymentDate.getFullYear(), paymentDate.getMonth() + 3, paymentDate.getDate() ); case "Half-yearly": return new Date( paymentDate.getFullYear(), paymentDate.getMonth() + 6, paymentDate.getDate() ); case "Yearly": return new Date( paymentDate.getFullYear() + 1, paymentDate.getMonth(), paymentDate.getDate() ); default: return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); } }