feature/fitlien-828 #120
| @ -21,3 +21,13 @@ export { | ||||
|   esslGetUserDetails, esslUpdateUser, | ||||
|   esslDeleteUser, esslGetEmployeePunchLogs | ||||
| } from './dooraccess'; | ||||
| 
 | ||||
| // Add member cache functions
 | ||||
| export { | ||||
|   generateMemberCache, | ||||
|   updateTrainerAssignmentCache, | ||||
|   updateTimeSlotCache, | ||||
|   getCachedMembers, | ||||
|   rebuildGymCachee, | ||||
|   batchRebuildCaches | ||||
| } from './memberCache'; | ||||
							
								
								
									
										8
									
								
								functions/src/memberCache/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								functions/src/memberCache/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| export { | ||||
|   generateMemberCache, | ||||
|   updateTrainerAssignmentCache, | ||||
|   updateTimeSlotCache, | ||||
|   getCachedMembers, | ||||
|   rebuildGymCachee, | ||||
|   batchRebuildCaches | ||||
| } from './memberCache'; | ||||
							
								
								
									
										525
									
								
								functions/src/memberCache/memberCache.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										525
									
								
								functions/src/memberCache/memberCache.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<string>(); | ||||
|        | ||||
|       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<string>(); | ||||
|        | ||||
|       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<void> { | ||||
|   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<CacheEntry> { | ||||
|   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<string | null> { | ||||
|   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<void> { | ||||
|   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)); | ||||
|   } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user