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; subscriptionId?: string; subscription?: { hasPersonalTraining?: boolean; frequency?: string; price?: number; [key: string]: any; }; isPartialPayment?: boolean; remainingAmount?: number; daysUntilExpiry?: number; expirationDate?: admin.firestore.Timestamp; createdAt?: admin.firestore.Timestamp; updatedAt?: admin.firestore.Timestamp; [key: string]: any; } interface ClientFields { fields?: { [key: string]: any }; [key: string]: any; } interface MinimalCacheEntry { membershipId: string; userId: string; status: string; displayName: string; email?: string | null; phoneNumber?: string | null; alternateContact?: string | null; renewalDate?: string | null; expirationDate?: string | null; createdAt?: string | null; daysUntilExpiry?: number | null; hasPersonalTraining: boolean; hasPartialPayment: boolean; remainingAmount: number; subscriptionId?: string | null; lastUpdated: string; } interface MinimalJsonCacheData { gymId: string; members: MinimalCacheEntry[]; 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: MinimalJsonCacheData = 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: "3.1", }); } } 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: MinimalCacheEntry[] = []; let existingData: MinimalJsonCacheData | 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 fetchAllMinimalMembers(gymId); } const jsonData: MinimalJsonCacheData = { gymId, members, totalMembers: members.length, lastUpdated: new Date().toISOString(), cacheVersion: "3.1", }; 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 fetchAllMinimalMembers( gymId: string ): Promise { const members: MinimalCacheEntry[] = []; 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 generateMinimalCacheEntry(userId, doc.id, membershipData, gymId); } 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 MinimalCacheEntry => member !== null ); members.push(...validResults); } return members; } async function updateSpecificMembers( gymId: string, membershipIds: string[], existingMembers: MinimalCacheEntry[] ): 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 generateMinimalCacheEntry( userId, doc.id, membershipData, gymId ); } 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: MinimalJsonCacheData ): 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 generateMinimalCacheEntry( userId: string, membershipId: string, membershipData: MembershipData, gymId: string ): Promise { try { let firstName = ""; let lastName = ""; let email = ""; let phoneNumber = ""; let alternateContact = ""; 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; const fields = fieldData.fields || {}; firstName = fields["first-name"] || fieldData["first-name"] || ""; lastName = fields["last-name"] || fieldData["last-name"] || ""; email = fields["email"] || fieldData["email"] || ""; phoneNumber = fields["phone-number"] || fieldData["phone-number"] || ""; alternateContact = fields["alternate-contact"] || fieldData["alternate-contact"] || ""; } } catch (error) { logger.error(`Error getting fields for user ${userId}:`, error); } const daysUntilExpiry = membershipData.daysUntilExpiry || null; const displayName = firstName.length === 0 ? "Unknown" : lastName.length === 0 ? firstName : `${firstName} ${lastName}`; const isPartial = membershipData.isPartialPayment === true; const remaining = membershipData.remainingAmount || 0; const hasPartialPayment = isPartial && remaining > 0; const minimalEntry: MinimalCacheEntry = { membershipId, userId, status: membershipData.status || "N/A", displayName, email: email || null, phoneNumber: phoneNumber || null, alternateContact: alternateContact || null, renewalDate: null, expirationDate: membershipData.expirationDate ? membershipData.expirationDate.toDate().toISOString() : null, createdAt: membershipData.createdAt ? membershipData.createdAt.toDate().toISOString() : null, daysUntilExpiry, hasPersonalTraining: membershipData.subscription?.hasPersonalTraining === true, hasPartialPayment, remainingAmount: remaining, subscriptionId: membershipData.subscriptionId || null, lastUpdated: new Date().toISOString(), }; return minimalEntry; } catch (error) { logger.error("Error generating minimal cache entry:", error); throw error; } }