diff --git a/functions/src/index.ts b/functions/src/index.ts index 625c6d7..e03316d 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -13,7 +13,7 @@ export * from './shared/config'; export { sendEmailSES } from './email'; export { sendSMSMessage } from './sms'; export { accessFile } from './storage'; -export { processNotificationOnCreate,checkExpiredMemberships } from './notifications'; +export { processNotificationOnCreate, checkExpiredMemberships } from './notifications'; export * from './payments'; export { getPlaceDetails, getPlacesAutocomplete } from './places'; export { registerClient } from './users'; @@ -21,3 +21,13 @@ export { esslGetUserDetails, esslUpdateUser, esslDeleteUser, esslGetEmployeePunchLogs } from './dooraccess'; + +// Add member cache functions +export { + generateMemberCache, + updateTrainerAssignmentCache, + updateTimeSlotCache, + getCachedMembers, + rebuildGymCachee, + batchRebuildCaches +} from './memberCache'; \ No newline at end of file diff --git a/functions/src/memberCache/index.ts b/functions/src/memberCache/index.ts new file mode 100644 index 0000000..15ba89b --- /dev/null +++ b/functions/src/memberCache/index.ts @@ -0,0 +1,8 @@ +export { + generateMemberCache, + updateTrainerAssignmentCache, + updateTimeSlotCache, + getCachedMembers, + rebuildGymCachee, + batchRebuildCaches +} from './memberCache'; \ No newline at end of file diff --git a/functions/src/memberCache/memberCache.ts b/functions/src/memberCache/memberCache.ts new file mode 100644 index 0000000..9a1d5bd --- /dev/null +++ b/functions/src/memberCache/memberCache.ts @@ -0,0 +1,525 @@ +import { onDocumentWritten } from "firebase-functions/v2/firestore"; +import { onCall, HttpsError } 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; + 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]: string }; + [key: string]: any; +} + +interface PaymentData { + dateTimestamp?: admin.firestore.Timestamp; + [key: string]: any; +} + +interface TrainerAssignment { + id?: string; + membershipId?: string; + createdAt?: admin.firestore.Timestamp; + [key: string]: any; +} + +interface TimeSlot { + id?: string; + membershipId?: string; + startTime?: admin.firestore.Timestamp; + endTime?: admin.firestore.Timestamp; + createdAt?: admin.firestore.Timestamp; + [key: string]: any; +} + +interface CacheEntry { + membershipId: string; + memberData: { + daysUntilExpiry?: number | null; + hasPartialPayment: boolean; + createdAt?: string | null; + updatedAt?: string | null; + [key: string]: any; + }; + fields: { [key: string]: string }; + renewalDate?: string | null; + trainerAssignments: any[]; + timeSlots: any[]; + lastUpdated: string; +} + +interface JsonCacheData { + gymId: string; + members: CacheEntry[]; + totalMembers: number; + lastUpdated: string; + cacheVersion: string; +} + +export const generateMemberCache = onDocumentWritten( + { + region: "#{SERVICES_RGN}#", + document: "memberships/{membershipId}", + }, + async (event) => { + try { + const membershipId = event.params.membershipId; + const membershipData = event.data?.after?.exists + ? event.data.after.data() as MembershipData + : null; + + if (!membershipData) { + await rebuildGymCacheFromDeletion(membershipId); + return; + } + + const gymId = membershipData.gymId; + if (!gymId) { + logger.warn(`No gymId found for membership ${membershipId}`); + return; + } + + await rebuildGymCache(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 rebuildGymCache(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 updateTimeSlotCache = onDocumentWritten( + { + region: "#{SERVICES_RGN}#", + document: "scheduled_trainings/{slotId}", + }, + async (event) => { + try { + const slotData = event.data?.after?.exists + ? event.data.after.data() as TimeSlot + : null; + const oldSlotData = event.data?.before?.exists + ? event.data.before.data() as TimeSlot + : null; + + const gymIds = new Set(); + + if (slotData?.membershipId) { + const gymId = await getGymIdFromMembershipId(slotData.membershipId); + if (gymId) gymIds.add(gymId); + } + + if (oldSlotData?.membershipId) { + const gymId = await getGymIdFromMembershipId(oldSlotData.membershipId); + if (gymId) gymIds.add(gymId); + } + + for (const gymId of gymIds) { + await rebuildGymCache(gymId); + } + + if (gymIds.size > 0) { + logger.info(`Updated time slot cache for ${gymIds.size} gym(s)`); + } + } catch (error) { + logger.error('Error updating time slot 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 rebuildGymCache(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 || []; + } catch (error) { + logger.error(`Error reading cache file for gym ${gymId}:`, error); + + await rebuildGymCache(gymId); + const [fileBuffer] = await file.download(); + const jsonData: JsonCacheData = JSON.parse(fileBuffer.toString()); + return jsonData.members || []; + } + } catch (error) { + logger.error('Error getting cached members:', error); + throw new HttpsError('internal', 'Error retrieving cached data'); + } + } +); + +export const rebuildGymCachee = onCall( + { + region: "#{SERVICES_RGN}#", + }, + async (request) => { + const { gymId } = request.data; + + if (!gymId) { + throw new HttpsError('invalid-argument', 'gymId is required'); + } + + await rebuildGymCache(gymId); + return { success: true, message: `JSON cache rebuilt for gym ${gymId}` }; + } +); + +export const batchRebuildCaches = onCall( + { + region: "#{SERVICES_RGN}#", + }, + async (request) => { + try { + const { gymIds } = request.data; + + if (!gymIds || !Array.isArray(gymIds)) { + throw new HttpsError('invalid-argument', 'gymIds array is required'); + } + + const results: Array<{ gymId: string; status: string; error?: string }> = []; + + for (const gymId of gymIds) { + try { + await rebuildGymCache(gymId); + results.push({ gymId, status: 'success' }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + results.push({ gymId, status: 'error', error: errorMessage }); + } + } + + logger.info(`Batch rebuild completed for ${gymIds.length} gyms`); + return { results }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new HttpsError('internal', errorMessage); + } + } +); + +async function rebuildGymCache(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(); + + const members: CacheEntry[] = []; + 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 { + return await generateCacheEntry(doc.id, doc.data() as 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); + } + + const jsonData: JsonCacheData = { + gymId, + members, + totalMembers: members.length, + lastUpdated: new Date().toISOString(), + cacheVersion: '1.0' + }; + + 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() + } + } + }); + + 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(membershipId: string, membershipData: MembershipData): Promise { + try { + let fields: { [key: string]: string } = {}; + try { + const clientFieldsSnapshot = await app + .firestore() + .collection('client_profiles') + .where('membershipId', '==', membershipId) + .get(); + + if (!clientFieldsSnapshot.empty) { + const fieldDoc = clientFieldsSnapshot.docs[0]; + const fieldData = fieldDoc.data() as ClientFields; + fields = fieldData.fields || {}; + } + } catch (error) { + logger.error(`Error getting fields for ${membershipId}:`, 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 ${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(); + + trainerAssignments = assignmentsSnapshot.docs.map(doc => { + const data = doc.data() as TrainerAssignment; + return { + id: doc.id, + ...data, + createdAt: data.createdAt ? data.createdAt.toDate().toISOString() : null + }; + }); + + const timeSlotsSnapshot = await app + .firestore() + .collection('scheduled_trainings') + .where('membershipId', '==', membershipId) + .get(); + + timeSlots = timeSlotsSnapshot.docs.map(doc => { + const data = doc.data() as TimeSlot; + return { + id: doc.id, + ...data, + startTime: data.startTime ? data.startTime.toDate().toISOString() : null, + endTime: data.endTime ? data.endTime.toDate().toISOString() : null, + createdAt: data.createdAt ? data.createdAt.toDate().toISOString() : null + }; + }); + } catch (error) { + logger.error(`Error getting trainer data for ${membershipId}:`, error); + } + } + + const cacheEntry: CacheEntry = { + membershipId, + memberData: { + ...membershipData, + daysUntilExpiry, + hasPartialPayment, + 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; + } +} + +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; + } +} + +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 rebuildGymCache(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()); + default: + return new Date(Date.now() + (30 * 24 * 60 * 60 * 1000)); + } +} \ No newline at end of file