import { onSchedule } from "firebase-functions/v2/scheduler"; import { getLogger, getAdmin } from "../shared/config"; import * as admin from "firebase-admin"; const app = getAdmin(); const logger = getLogger(); const kTrainerRole = 'Trainer'; interface MembershipData { id?: string; userId: string; gymId: string; status: string; subscription?: { name: string; frequency: string; }; } interface ClientFields { [key: string]: string | undefined; "first-name"?: string; "last-name"?: string; } interface PaymentData { id: string; date: string; amount: number; paymentMethod: string; referenceNumber: string; dateTimestamp: Date; createdAt: Date; discount?: number; } interface PersonalTrainerAssign { id: string; ownerId: string; trainerId?: string; clientId: string; membershipId: string; gymId: string; } export const checkExpiredMemberships = onSchedule( { schedule: "*/5 * * * *", timeZone: "UTC", region: "#{SERVICES_RGN}#", }, async (event) => { logger.info("Starting scheduled membership expiry check..."); try { const expiredMemberships = await findExpiredMemberships(); const expiringMemberships = await findMembershipsExpiringIn10Days(); if (expiredMemberships.length === 0 && expiringMemberships.length === 0) { logger.info("No expired or expiring memberships found."); return; } logger.info( `Found ${expiredMemberships.length} expired memberships and ${expiringMemberships.length} memberships expiring in 10 days to process.` ); const expiredResults = await Promise.allSettled( expiredMemberships.map((m) => processExpiredMembership(m.id, m.data)) ); const expiringResults = await Promise.allSettled( expiringMemberships.map((m) => processExpiringMembership(m.id, m.data)) ); const expiredSuccessful = expiredResults.filter( (r) => r.status === "fulfilled" ).length; const expiredFailed = expiredResults.filter( (r) => r.status === "rejected" ).length; const expiringSuccessful = expiringResults.filter( (r) => r.status === "fulfilled" ).length; const expiringFailed = expiringResults.filter( (r) => r.status === "rejected" ).length; logger.info( `Completed processing. Expired - Success: ${expiredSuccessful}, Failed: ${expiredFailed}. Expiring - Success: ${expiringSuccessful}, Failed: ${expiringFailed}` ); } catch (error) { logger.error("Error in scheduled membership expiry check:", error); } } ); async function findExpiredMemberships(): Promise< Array<{ id: string; data: MembershipData }> > { try { const snapshot = await app .firestore() .collection("memberships") .where("status", "==", "ACTIVE") .get(); const expired: Array<{ id: string; data: MembershipData }> = []; const batchSize = 10; const docs = snapshot.docs; for (let i = 0; i < docs.length; i += batchSize) { const batch = docs.slice(i, i + batchSize); const batchResults = await Promise.allSettled( batch.map(async (doc) => { const data = doc.data() as MembershipData; const isExpired = await checkIfMembershipExpired(doc.id, data); if (isExpired) { return { id: doc.id, data }; } return null; }) ); batchResults.forEach((result) => { if (result.status === "fulfilled" && result.value) { expired.push(result.value); } }); } return expired; } catch (error) { logger.error("Error finding expired memberships:", error); throw error; } } async function findMembershipsExpiringIn10Days(): Promise< Array<{ id: string; data: MembershipData }> > { try { const snapshot = await app .firestore() .collection("memberships") .where("status", "==", "ACTIVE") .get(); const expiring: Array<{ id: string; data: MembershipData }> = []; const batchSize = 10; const docs = snapshot.docs; for (let i = 0; i < docs.length; i += batchSize) { const batch = docs.slice(i, i + batchSize); const batchResults = await Promise.allSettled( batch.map(async (doc) => { const data = doc.data() as MembershipData; const isExpiringIn10Days = await checkIfMembershipExpiringIn10Days( doc.id, data ); if (isExpiringIn10Days) { return { id: doc.id, data }; } return null; }) ); batchResults.forEach((result) => { if (result.status === "fulfilled" && result.value) { expiring.push(result.value); } }); } return expiring; } catch (error) { logger.error("Error finding memberships expiring in 10 days:", error); throw error; } } async function checkIfMembershipExpired( membershipId: string, data: MembershipData ): Promise { try { if (!data.subscription || !data.subscription.frequency) { logger.warn( `Skipping expiry check for membership ${membershipId} with missing subscription data.` ); return false; } let startDate: Date; const payments = await getPaymentsForMembership(membershipId); if (payments.length === 0) { logger.warn( `No payments found for membership ${membershipId}, cannot determine expiry` ); return false; } const latestPayment = payments[0]; startDate = latestPayment.dateTimestamp; logger.info( `Using latest payment date ${startDate.toISOString()} for membership ${membershipId}` ); const expiryDate = calculateExpiryDate( startDate, data.subscription.frequency ); const now = new Date(); const isExpired = now > expiryDate; if (isExpired) { logger.info( `Membership ${membershipId} expired on ${expiryDate.toISOString()}` ); } return isExpired; } catch (error) { logger.error( `Error checking expiry for membership ${membershipId}:`, error ); return false; } } async function checkIfMembershipExpiringIn10Days( membershipId: string, data: MembershipData ): Promise { try { if (!data.subscription || !data.subscription.frequency) { logger.warn( `Skipping expiry check for membership ${membershipId} with missing subscription data.` ); return false; } const payments = await getPaymentsForMembership(membershipId); if (payments.length === 0) { logger.warn( `No payments found for membership ${membershipId}, cannot determine expiry` ); return false; } const latestPayment = payments[0]; const startDate = latestPayment.dateTimestamp; const expiryDate = calculateExpiryDate( startDate, data.subscription.frequency ); const now = new Date(); const tenDaysFromNow = new Date(); tenDaysFromNow.setDate(now.getDate() + 10); const isExpiringIn10Days = expiryDate > now && expiryDate <= tenDaysFromNow; if (isExpiringIn10Days) { logger.info( `Membership ${membershipId} will expire on ${expiryDate.toISOString()} (within 10 days)` ); } return isExpiringIn10Days; } catch (error) { logger.error( `Error checking 10-day expiry for membership ${membershipId}:`, error ); return false; } } async function getPaymentsForMembership( membershipId: string ): Promise { try { const docSnapshot = await app .firestore() .collection("membership_payments") .doc(membershipId) .get(); if (!docSnapshot.exists) { return []; } const data = docSnapshot.data(); const paymentsData = data?.payments || []; const payments: PaymentData[] = paymentsData.map((payment: any) => ({ id: payment.id, date: payment.date, amount: payment.amount, paymentMethod: payment.paymentMethod, referenceNumber: payment.referenceNumber, dateTimestamp: payment.dateTimestamp.toDate ? payment.dateTimestamp.toDate() : new Date(payment.dateTimestamp), createdAt: payment.createdAt.toDate ? payment.createdAt.toDate() : new Date(payment.createdAt), discount: payment.discount, })); payments.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); return payments; } catch (error) { logger.error( `Error getting payments for membership ${membershipId}:`, error ); return []; } } function calculateExpiryDate(startDate: Date, frequency: string): Date { const expiry = new Date(startDate); switch (frequency.toLowerCase()) { case "monthly": expiry.setMonth(expiry.getMonth() + 1); break; case "quarterly": expiry.setMonth(expiry.getMonth() + 3); break; case "half-yearly": expiry.setMonth(expiry.getMonth() + 6); break; case "yearly": expiry.setFullYear(expiry.getFullYear() + 1); break; default: expiry.setMonth(expiry.getMonth() + 1); } return expiry; } function calculateRenewalDateFromPayment( subscription: any, paymentDate: Date ): Date { const renewalDate = new Date(paymentDate); const frequency = subscription.frequency || "Monthly"; switch (frequency.toLowerCase()) { case "monthly": renewalDate.setMonth(renewalDate.getMonth() + 1); break; case "quarterly": renewalDate.setMonth(renewalDate.getMonth() + 3); break; case "half-yearly": renewalDate.setMonth(renewalDate.getMonth() + 6); break; case "yearly": renewalDate.setFullYear(renewalDate.getFullYear() + 1); break; default: renewalDate.setMonth(renewalDate.getMonth() + 1); } return renewalDate; } async function getTrainerAssignmentsForMembership( membershipId: string ): Promise { try { const querySnapshot = await app .firestore() .collection("personal_trainer_assignments") .where("membershipId", "==", membershipId) .get(); return querySnapshot.docs.map((doc) => ({ id: doc.id, ...doc.data(), })) as PersonalTrainerAssign[]; } catch (error) { logger.error( `Error getting trainer assignments for membership ${membershipId}:`, error ); return []; } } async function getTrainerName(trainerId: string): Promise { try { const doc = await app .firestore() .collection("trainer_profiles") .doc(trainerId) .get(); if (!doc.exists) { const userDoc = await app .firestore() .collection("users") .doc(trainerId) .get(); if (userDoc.exists) { const userData = userDoc.data(); return userData?.name || userData?.displayName || "Unknown Trainer"; } return "Unknown Trainer"; } const data = doc.data(); const fields = data?.fields; if (fields) { const firstName = fields["first-name"] || ""; const lastName = fields["last-name"] || ""; return `${firstName} ${lastName}`.trim() || "Unknown Trainer"; } return data?.name || data?.displayName || "Unknown Trainer"; } catch (error) { logger.error(`Error getting trainer name for ${trainerId}:`, error); return "Unknown Trainer"; } } async function processExpiredMembership( membershipId: string, membershipData: MembershipData ): Promise { try { await app.firestore().collection("memberships").doc(membershipId).update({ status: "EXPIRED", updatedAt: admin.firestore.FieldValue.serverTimestamp(), }); logger.info(`Marked membership ${membershipId} as EXPIRED`); await sendPlanExpiredNotification(membershipId, membershipData); await sendTrainerNotifications(membershipId, membershipData, "expired"); } catch (error) { logger.error(`Error processing membership ${membershipId}:`, error); } } async function processExpiringMembership( membershipId: string, membershipData: MembershipData ): Promise { try { logger.info(`Processing expiring membership ${membershipId}`); await sendPlanExpiringNotification(membershipId, membershipData); await sendTrainerNotifications(membershipId, membershipData, "expiring"); } catch (error) { logger.error( `Error processing expiring membership ${membershipId}:`, error ); } } async function getTrainerUserId(trainerId: string): Promise { try { const trainerDoc = await app .firestore() .collection("trainer_profiles") .doc(trainerId) .get(); if (!trainerDoc.exists) { throw new Error(`Trainer profile not found for ID: ${trainerId}`); } const trainerData = trainerDoc.data(); if (!trainerData?.userId) { throw new Error(`userId not found in trainer profile: ${trainerId}`); } return trainerData.userId; } catch (error) { logger.error(`Error getting userId for trainer ${trainerId}:`, error); return trainerId; } } async function sendTrainerNotifications( membershipId: string, membershipData: MembershipData, notificationType: "expired" | "expiring" ): Promise { try { const trainerAssignments = await getTrainerAssignmentsForMembership( membershipId ); if (trainerAssignments.length === 0) { logger.info( `No trainer assignments found for membership ${membershipId}` ); return; } const clientName = await getClientName(membershipId, membershipData.userId); const gymName = await getGymName(membershipData.gymId); let expiryDate: Date | undefined; let formattedDate = "Unknown Date"; let daysUntilExpiry = 0; const payments = await getPaymentsForMembership(membershipId); if (payments.length > 0) { const latestPayment = payments[0]; expiryDate = calculateRenewalDateFromPayment( membershipData.subscription, latestPayment.dateTimestamp ); formattedDate = expiryDate.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); if (notificationType === "expiring") { const now = new Date(); const timeDiff = expiryDate.getTime() - now.getTime(); daysUntilExpiry = Math.ceil(timeDiff / (1000 * 3600 * 24)); } } for (const assignment of trainerAssignments) { if (!assignment.trainerId) continue; try { const trainerName = await getTrainerName(assignment.trainerId); const trainerUserId = await getTrainerUserId(assignment.trainerId); const notifType = notificationType === "expired" ? "trainer_client_plan_expired" : "trainer_client_plan_expiring"; const existing = await app .firestore() .collection("notifications") .where("type", "==", notifType) .where("recipientId", "==", trainerUserId) .where("data.membershipId", "==", membershipId) .where( "data.expiryDate", "==", expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()) ) .limit(1) .get(); if (!existing.empty) { logger.info( `${notificationType} notification already sent to trainer ${assignment.trainerId} for membership ${membershipId}, skipping...` ); continue; } const notificationData: any = { senderId: "system", recipientId: trainerUserId, type: notifType, notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, readBy: [], data: { planName: membershipData.subscription?.name || "Unknown Plan", clientName, membershipId, gymName, assignmentId: assignment.id, formattedExpiryDate: formattedDate, role: kTrainerRole, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()), }, }; if (notificationType === "expiring") { notificationData.data.daysUntilExpiry = daysUntilExpiry; } await app.firestore().collection("notifications").add(notificationData); logger.info( `${notificationType} notification sent to trainer ${assignment.trainerId} (${trainerName}) for client ${clientName}'s membership ${membershipId}` ); } catch (trainerError) { logger.error( `Error sending notification to trainer ${assignment.trainerId} for membership ${membershipId}:`, trainerError ); } } } catch (error) { logger.error( `Error sending trainer notifications for membership ${membershipId}:`, error ); } } async function sendPlanExpiredNotification( membershipId: string, membershipData: MembershipData ): Promise { try { const clientName = await getClientName(membershipId, membershipData.userId); const gymOwnerId = await getGymOwnerId(membershipData.gymId); const gymName = await getGymName(membershipData.gymId); let expiryDate: Date | undefined; let formattedDate = "Unknown Date"; const payments = await getPaymentsForMembership(membershipId); if (payments.length > 0) { const latestPayment = payments[0]; expiryDate = calculateRenewalDateFromPayment( membershipData.subscription, latestPayment.dateTimestamp ); formattedDate = expiryDate.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); } const existing = await app .firestore() .collection("notifications") .where("type", "==", "plan_expired") .where("data.membershipId", "==", membershipId) .where( "data.expiryDate", "==", expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()) ) .limit(1) .get(); if (!existing.empty) { logger.info(`Notification already sent for ${membershipId}, skipping...`); return; } await app .firestore() .collection("notifications") .add({ senderId: "system", recipientId: gymOwnerId, type: "plan_expired", notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, readBy: [], data: { planName: membershipData.subscription?.name || "Unknown Plan", clientName, membershipId, gymName, ownerId: gymOwnerId, formattedExpiryDate: formattedDate, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()), }, }); logger.info( `Notification sent for expired plan of membership ${membershipId}` ); } catch (error) { logger.error(`Error sending notification for ${membershipId}:`, error); } } async function sendPlanExpiringNotification( membershipId: string, membershipData: MembershipData ): Promise { try { const clientName = await getClientName(membershipId, membershipData.userId); const gymOwnerId = await getGymOwnerId(membershipData.gymId); const gymName = await getGymName(membershipData.gymId); let expiryDate: Date | undefined; let formattedDate = "Unknown Date"; let daysUntilExpiry = 10; const payments = await getPaymentsForMembership(membershipId); if (payments.length > 0) { const latestPayment = payments[0]; expiryDate = calculateRenewalDateFromPayment( membershipData.subscription, latestPayment.dateTimestamp ); formattedDate = expiryDate.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); const now = new Date(); const timeDiff = expiryDate.getTime() - now.getTime(); daysUntilExpiry = Math.ceil(timeDiff / (1000 * 3600 * 24)); } const existing = await app .firestore() .collection("notifications") .where("type", "==", "plan_expiring_soon") .where("data.membershipId", "==", membershipId) .where( "data.expiryDate", "==", expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()) ) .limit(1) .get(); if (!existing.empty) { logger.info( `Expiring notification already sent for ${membershipId}, skipping...` ); return; } await app .firestore() .collection("notifications") .add({ senderId: "system", recipientId: gymOwnerId, type: "plan_expiring_soon", notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, readBy: [], data: { planName: membershipData.subscription?.name || "Unknown Plan", clientName, membershipId, gymName, ownerId: gymOwnerId, formattedExpiryDate: formattedDate, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) : admin.firestore.Timestamp.fromDate(new Date()), daysUntilExpiry: daysUntilExpiry, }, }); logger.info( `Expiring notification sent for membership ${membershipId} (expires on ${formattedDate}, ${daysUntilExpiry} days remaining)` ); } catch (error) { logger.error( `Error sending expiring notification for ${membershipId}:`, error ); } } async function getClientName( membershipId: string, clientId: string ): Promise { try { const doc = await app .firestore() .collection("client_profiles") .doc(clientId) .get(); if (!doc.exists) return "Unknown Client"; const fields = doc.data()?.fields as ClientFields; const firstName = fields?.["first-name"] || ""; const lastName = fields?.["last-name"] || ""; return `${firstName} ${lastName}`.trim() || "Unknown Client"; } catch (error) { logger.error(`Error getting client name for ${membershipId}:`, error); return "Unknown Client"; } } async function getGymOwnerId(gymId: string): Promise { try { const doc = await app.firestore().collection("gyms").doc(gymId).get(); const data = doc.data(); if (!data?.userId) throw new Error(`userId not found for gym ${gymId}`); return data.userId; } catch (error) { logger.error(`Error getting gym owner ID for gym ${gymId}:`, error); throw error; } } async function getGymName(gymId: string): Promise { try { const doc = await app.firestore().collection("gyms").doc(gymId).get(); const data = doc.data(); return data?.name || data?.gymName || "Unknown Gym"; } catch (error) { logger.error(`Error getting gym name for gym ${gymId}:`, error); return "Unknown Gym"; } }