diff --git a/functions/src/index.ts b/functions/src/index.ts index 6163325..a5d136c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,26 +6,25 @@ setGlobalOptions({ timeoutSeconds: 540, minInstances: 0, maxInstances: 10, - concurrency: 80 -}); + concurrency: 80, +}); -export * from './shared/config'; -export { sendEmailSES } from './email'; -export { sendSMSMessage } from './sms'; -export { accessFile } from './storage'; -export { processNotificationOnCreate, checkExpiredMemberships } from './notifications'; -export * from './payments'; -export { getPlaceDetails, getPlacesAutocomplete } from './places'; -export { registerClient } from './users'; +export * from "./shared/config"; +export { sendEmailSES } from "./email"; +export { sendSMSMessage } from "./sms"; +export { accessFile } from "./storage"; export { - esslGetUserDetails, esslUpdateUser, - esslDeleteUser, esslGetEmployeePunchLogs -} from './dooraccess'; + processNotificationOnCreate, + checkExpiredMemberships, +} from "./notifications"; +export * from "./payments"; +export { getPlaceDetails, getPlacesAutocomplete } from "./places"; +export { registerClient } from "./users"; +export { + esslGetUserDetails, + esslUpdateUser, + esslDeleteUser, + esslGetEmployeePunchLogs, +} from "./dooraccess"; -export { - generateMemberCache, - updateTrainerAssignmentCache, - getCachedMembers, - rebuildGymCache, - batchRebuildCaches -} from './memberCache'; \ No newline at end of file +export { getMemberCache, updateMemberCache } from "./memberCache"; diff --git a/functions/src/memberCache/index.ts b/functions/src/memberCache/index.ts index fb4b6bb..c2e1d70 100644 --- a/functions/src/memberCache/index.ts +++ b/functions/src/memberCache/index.ts @@ -1,7 +1 @@ -export { - generateMemberCache, - updateTrainerAssignmentCache, - getCachedMembers, - rebuildGymCache, - batchRebuildCaches -} from './memberCache'; \ No newline at end of file +export { getMemberCache, updateMemberCache } from "./memberCache"; diff --git a/functions/src/memberCache/memberCache.ts b/functions/src/memberCache/memberCache.ts index fa3a54c..b2ee547 100644 --- a/functions/src/memberCache/memberCache.ts +++ b/functions/src/memberCache/memberCache.ts @@ -1,16 +1,15 @@ -import { onDocumentWritten } from "firebase-functions/v2/firestore"; -import { onCall, HttpsError } from "firebase-functions/v2/https"; +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'; +const CACHE_FOLDER = "gym_member_cache"; interface MembershipData { gymId?: string; - userId?: string; + userId?: string; status?: string; subscription?: { hasPersonalTraining?: boolean; @@ -37,14 +36,14 @@ interface PaymentData { interface TrainerAssignment { id?: string; membershipId?: string; - timeSlot?: any[]; + timeSlot?: any[]; createdAt?: admin.firestore.Timestamp; [key: string]: any; } interface CacheEntry { - userId: string; - membershipId: string; + userId: string; + membershipId: string; memberData: { daysUntilExpiry?: number | null; hasPartialPayment: boolean; @@ -67,200 +66,257 @@ interface JsonCacheData { cacheVersion: string; } -export const generateMemberCache = onDocumentWritten( +export const getMemberCache = onRequest( { region: "#{SERVICES_RGN}#", - document: "memberships/{membershipId}", + cors: true, }, - async (event) => { + async (req, res) => { try { - const membershipId = event.params.membershipId; - const membershipData = event.data?.after?.exists - ? event.data.after.data() as MembershipData - : null; - - if (!membershipData) { - await rebuildGymCacheFromDeletion(membershipId); + 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; } - const gymId = membershipData.gymId; + 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) { - logger.warn(`No gymId found for membership ${membershipId}`); + res.status(400).json({ error: "gymId is required" }); return; } - await rebuildGymCachee(gymId); - - logger.info(`JSON cache updated for gym ${gymId} after member ${membershipId} change`); - } catch (error) { - logger.error('Error updating member cache:', error); - } - } -); - -export const updateTrainerAssignmentCache = onDocumentWritten( - { - region: "#{SERVICES_RGN}#", - document: "personal_trainer_assignments/{assignmentId}", - }, - async (event) => { - try { - const assignmentData = event.data?.after?.exists - ? event.data.after.data() as TrainerAssignment - : null; - const oldAssignmentData = event.data?.before?.exists - ? event.data.before.data() as TrainerAssignment - : null; - - const gymIds = new Set(); - - if (assignmentData?.membershipId) { - const gymId = await getGymIdFromMembershipId(assignmentData.membershipId); - if (gymId) gymIds.add(gymId); - } - - if (oldAssignmentData?.membershipId) { - const gymId = await getGymIdFromMembershipId(oldAssignmentData.membershipId); - if (gymId) gymIds.add(gymId); - } - - for (const gymId of gymIds) { - await rebuildGymCachee(gymId); - } - - if (gymIds.size > 0) { - logger.info(`Updated trainer assignment cache for ${gymIds.size} gym(s)`); - } - } catch (error) { - logger.error('Error updating trainer assignment cache:', error); - } - } -); - -export const getCachedMembers = onCall( - { - region: "#{SERVICES_RGN}#", - }, - async (request) => { - try { - const { gymId, forceRefresh } = request.data; - - if (!gymId) { - throw new HttpsError('invalid-argument', 'gymId is required'); - } - const fileName = `${CACHE_FOLDER}/${gymId}.json`; const file = app.storage().bucket().file(fileName); - let fileExists = false; - let fileAge = 0; - - try { - const [metadata] = await file.getMetadata(); - fileExists = true; - fileAge = Date.now() - new Date(metadata.timeCreated!).getTime(); - } catch (error) { - fileExists = false; - } - - const fiveMinutes = 5 * 60 * 1000; - if (forceRefresh || !fileExists || fileAge > fiveMinutes) { - logger.info(`Rebuilding cache for gym ${gymId} - forceRefresh: ${forceRefresh}, fileExists: ${fileExists}, fileAge: ${fileAge}ms`); - await rebuildGymCachee(gymId); - } - try { const [fileBuffer] = await file.download(); const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString()); - logger.info(`Retrieved ${jsonData.members?.length || 0} members from cache for gym ${gymId}`); - return jsonData.members || []; + + 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); - - await rebuildGymCachee(gymId); - const [fileBuffer] = await file.download(); - const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString()); - return jsonData.members || []; + 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 cached members:', error); - throw new HttpsError('internal', 'Error retrieving cached data'); + logger.error("Error getting member cache:", error); + res.status(500).json({ error: "Error retrieving cached data" }); } } ); -export const rebuildGymCache = onCall( +export const updateMemberCache = onRequest( { region: "#{SERVICES_RGN}#", + cors: true, + timeoutSeconds: 540, }, - async (request) => { - const { gymId } = request.data; - - if (!gymId) { - throw new HttpsError('invalid-argument', 'gymId is required'); - } - - await rebuildGymCachee(gymId); - return { success: true, message: `JSON cache rebuilt for gym ${gymId}` }; - } -); - -export const batchRebuildCaches = onCall( - { - region: "#{SERVICES_RGN}#", - }, - async (request) => { + async (req, res) => { try { - const { gymIds } = request.data; - - if (!gymIds || !Array.isArray(gymIds)) { - throw new HttpsError('invalid-argument', 'gymIds array is required'); + 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; } - const results: Array<{ gymId: string; status: string; error?: string }> = []; - - for (const gymId of gymIds) { + 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 { - await rebuildGymCachee(gymId); - results.push({ gymId, status: 'success' }); + 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) { - const errorMessage = error instanceof Error ? error.message : String(error); - results.push({ gymId, status: 'error', error: errorMessage }); + logger.warn( + `Could not load existing cache for incremental update, performing full refresh: ${error}` + ); + incrementalUpdate = false; } } - logger.info(`Batch rebuild completed for ${gymIds.length} gyms`); - return { results }; + 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) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new HttpsError('internal', errorMessage); + 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 rebuildGymCachee(gymId: string): Promise { - try { - logger.info(`Starting JSON cache rebuild for gym: ${gymId}`); - - const membershipsSnapshot = await app - .firestore() - .collection('memberships') - .where('gymId', '==', gymId) - .orderBy('createdAt', 'desc') - .get(); +async function fetchAllMembers(gymId: string): Promise { + const members: CacheEntry[] = []; - const members: CacheEntry[] = []; - const batchSize = 10; + const membershipsSnapshot = await app + .firestore() + .collection("memberships") + .where("gymId", "==", gymId) + .orderBy("createdAt", "desc") + .get(); - for (let i = 0; i < membershipsSnapshot.docs.length; i += batchSize) { - const batch = membershipsSnapshot.docs.slice(i, i + batchSize); - const batchPromises = batch.map(async (doc) => { + 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 membershipData = doc.data() as MembershipData; + 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`); + logger.warn( + `Skipping member with ID ${doc.id} due to missing userId` + ); return null; } return await generateCacheEntry(userId, doc.id, membershipData); @@ -269,59 +325,67 @@ async function rebuildGymCachee(gymId: string): Promise { return null; } }); - - const batchResults = await Promise.all(batchPromises); - const validResults = batchResults.filter((member): member is CacheEntry => member !== null); - members.push(...validResults); - } - const jsonData: JsonCacheData = { - gymId, - members, - totalMembers: members.length, - lastUpdated: new Date().toISOString(), - cacheVersion: '1.0' - }; + const batchResults = await Promise.all(batchPromises); - 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: members.length.toString(), - generatedAt: new Date().toISOString() + batchResults.forEach((updatedMember) => { + if (updatedMember) { + const existingIndex = existingMembersMap.get( + updatedMember.membershipId + ); + if (existingIndex !== undefined) { + existingMembers[existingIndex] = updatedMember; + } else { + existingMembers.push(updatedMember); } } }); - - logger.info(`JSON cache saved for gym ${gymId} with ${members.length} members`); - } catch (error) { - logger.error(`Error rebuilding JSON cache for gym ${gymId}:`, error); - throw error; } } -async function generateCacheEntry(userId: string, membershipId: string, membershipData: MembershipData): Promise { +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) + .collection("client_profiles") + .where("uid", "==", userId) .get(); - + if (!clientFieldsSnapshot.empty) { const fieldDoc = clientFieldsSnapshot.docs[0]; const fieldData = fieldDoc.data() as ClientFields; fields = { - 'first-name': fieldData.fields?.['first-name'] || '', - 'email': fieldData.fields?.['email'] || '', - 'phone-number': fieldData.fields?.['phone-number'] || '', - 'alternate-contact': fieldData.fields?.['alternate-contact'] || '', + "first-name": fieldData.fields?.["first-name"] || "", + "last-name": fieldData.fields?.["last-name"] || "", + "email": fieldData.fields?.["email"] || "", + "phone-number": fieldData.fields?.["phone-number"] || "", + "alternate-contact": fieldData.fields?.["alternate-contact"] || "", }; } } catch (error) { @@ -330,32 +394,40 @@ async function generateCacheEntry(userId: string, membershipId: string, membersh 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') + .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') { + 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)); + 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); + logger.error( + `Error getting renewal date for membership ${membershipId}:`, + error + ); } } @@ -365,24 +437,27 @@ async function generateCacheEntry(userId: string, membershipId: string, membersh let trainerAssignments: any[] = []; let timeSlots: any[] = []; - + if (membershipData.subscription?.hasPersonalTraining) { try { const assignmentsSnapshot = await app .firestore() - .collection('personal_trainer_assignments') - .where('membershipId', '==', membershipId) + .collection("personal_trainer_assignments") + .where("membershipId", "==", membershipId) .get(); - - assignmentsSnapshot.docs.forEach(doc => { + + 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 }); + trainerAssignments.push({ id: doc.id, ...data }); }); } catch (error) { - logger.error(`Error getting trainer data for membership ${membershipId}:`, error); + logger.error( + `Error getting trainer data for membership ${membershipId}:`, + error + ); } } @@ -390,17 +465,15 @@ async function generateCacheEntry(userId: string, membershipId: string, membersh userId, membershipId, memberData: { - status: membershipData.status, - subscription: { - hasPersonalTraining: membershipData.subscription?.hasPersonalTraining, - frequency: membershipData.subscription?.frequency, - }, - remainingAmount: membershipData.remainingAmount, - isPartialPayment: membershipData.isPartialPayment, + ...membershipData, hasPartialPayment, daysUntilExpiry, - createdAt: membershipData.createdAt ? membershipData.createdAt.toDate().toISOString() : null, - updatedAt: membershipData.updatedAt ? membershipData.updatedAt.toDate().toISOString() : null + createdAt: membershipData.createdAt + ? membershipData.createdAt.toDate().toISOString() + : null, + updatedAt: membershipData.updatedAt + ? membershipData.updatedAt.toDate().toISOString() + : null, }, fields, renewalDate: renewalDate ? renewalDate.toISOString() : null, @@ -411,71 +484,43 @@ async function generateCacheEntry(userId: string, membershipId: string, membersh return cacheEntry; } catch (error) { - logger.error('Error generating cache entry:', error); + logger.error("Error generating cache entry:", error); throw error; } } -async function getGymIdFromMembershipId(membershipId: string): Promise { - try { - const membershipDoc = await app.firestore().collection('memberships').doc(membershipId).get(); - if (membershipDoc.exists) { - const data = membershipDoc.data() as MembershipData; - return data.gymId || null; - } - return null; - } catch (error) { - logger.error(`Error getting gym ID for membership ${membershipId}:`, error); - return null; - } -} +function calculateRenewalDateTimeFromPayment( + subscription: { frequency?: string }, + paymentDate: Date +): Date { + const frequency = subscription.frequency || "Monthly"; -async function rebuildGymCacheFromDeletion(membershipId: string): Promise { - try { - const [files] = await app.storage().bucket().getFiles({ - prefix: `${CACHE_FOLDER}/`, - delimiter: '/' - }); - - for (const file of files) { - if (file.name.endsWith('.json')) { - try { - const [fileBuffer] = await file.download(); - const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString()); - - const memberExists = jsonData.members?.some(member => - member.membershipId === membershipId - ); - - if (memberExists) { - const gymId = file.name.replace(`${CACHE_FOLDER}/`, '').replace('.json', ''); - logger.info(`Rebuilding cache for gym ${gymId} after member ${membershipId} deletion`); - await rebuildGymCachee(gymId); - break; - } - } catch (error) { - logger.error(`Error checking cache file ${file.name}:`, error); - } - } - } - } catch (error) { - logger.error('Error handling membership deletion:', 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()); + 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)); + return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); } -} \ No newline at end of file +}