diff --git a/functions/src/index.ts b/functions/src/index.ts index fb48fbe..625c6d7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -7,13 +7,13 @@ setGlobalOptions({ minInstances: 0, maxInstances: 10, concurrency: 80 -}); +}); export * from './shared/config'; export { sendEmailSES } from './email'; export { sendSMSMessage } from './sms'; export { accessFile } from './storage'; -export { processNotificationOnCreate,processMembershipStatusChange } from './notifications'; +export { processNotificationOnCreate,checkExpiredMemberships } from './notifications'; export * from './payments'; export { getPlaceDetails, getPlacesAutocomplete } from './places'; export { registerClient } from './users'; diff --git a/functions/src/notifications/index.ts b/functions/src/notifications/index.ts index eb9cb4d..9e40bc5 100644 --- a/functions/src/notifications/index.ts +++ b/functions/src/notifications/index.ts @@ -1,3 +1,3 @@ export { processNotificationOnCreate } from './processNotification'; -export { processMembershipStatusChange } from "./membershipStatusNotifications"; +export { checkExpiredMemberships } from "./membershipStatusNotifications"; diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index 21b7a79..17d108d 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -1,6 +1,5 @@ -import { onDocumentUpdated } from "firebase-functions/v2/firestore"; -import { getLogger } from "../shared/config"; -import { getAdmin } from "../shared/config"; +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { getLogger, getAdmin } from "../shared/config"; import * as admin from "firebase-admin"; const app = getAdmin(); @@ -14,143 +13,246 @@ interface MembershipData { subscription?: { name: string; duration: number; + frequency: string; + assignedAt: admin.firestore.Timestamp; }; - createdAt: admin.firestore.FieldValue; - updatedAt: admin.firestore.FieldValue; } interface ClientFields { [key: string]: string | undefined; - 'first-name'?: string; + "first-name"?: string; + "last-name"?: string; } -export const processMembershipStatusChange = onDocumentUpdated( +export const checkExpiredMemberships = onSchedule( { + schedule: "0 8,14,20 * * *", + timeZone: "UTC", region: "#{SERVICES_RGN}#", - document: "memberships/{membershipId}", }, async (event) => { - try { - const membershipId = event.params.membershipId; - const beforeData = event.data?.before?.data() as MembershipData; - const afterData = event.data?.after?.data() as MembershipData; + logger.info("Starting scheduled membership expiry check..."); - if (!beforeData || !afterData) { - logger.error(`No data found for membership ${membershipId}`); + try { + const expiredMemberships = await findExpiredMemberships(); + + if (expiredMemberships.length === 0) { + logger.info("No expired memberships found."); return; } - // Check if status changed to EXPIRED - if (beforeData.status !== "EXPIRED" && afterData.status === "EXPIRED") { - logger.info(`Membership ${membershipId} status changed to EXPIRED. Sending notification...`); - - await sendPlanExpiredNotification(membershipId, afterData); - } + logger.info( + `Found ${expiredMemberships.length} expired memberships to process.` + ); + const results = await Promise.allSettled( + expiredMemberships.map((m) => processExpiredMembership(m.id, m.data)) + ); + + const successful = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + logger.info( + `Completed processing. Success: ${successful}, Failed: ${failed}` + ); } catch (error) { - logger.error("Error processing membership status change:", 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 }> = []; + + snapshot.docs.forEach((doc) => { + const data = doc.data() as MembershipData; + const isExpired = checkIfMembershipExpired(data); + if (isExpired) expired.push({ id: doc.id, data }); + }); + + return expired; + } catch (error) { + logger.error("Error finding expired memberships:", error); + throw error; + } +} + +function checkIfMembershipExpired(data: MembershipData): boolean { + try { + // Critical update: Use the assignedAt timestamp from the subscription object + if ( + !data.subscription || + !data.subscription.duration || + !data.subscription.frequency || + !data.subscription.assignedAt + ) { + logger.warn( + `Skipping expiry check for membership ${data.id} with missing subscription data.` + ); + return false; + } + + const startDate = ( + data.subscription.assignedAt as admin.firestore.Timestamp + ).toDate(); + const expiryDate = calculateExpiryDate( + startDate, + data.subscription.duration, + data.subscription.frequency + ); + const now = new Date(); + + return now > expiryDate; + } catch (error) { + logger.error(`Error checking expiry for membership ${data.id}:`, error); + return false; + } +} + +function calculateExpiryDate( + startDate: Date, + duration: number, + frequency: string +): Date { + const expiry = new Date(startDate); + switch (frequency.toLowerCase()) { + case "monthly": + expiry.setMonth(expiry.getMonth() + duration); + break; + case "quarterly": + expiry.setMonth(expiry.getMonth() + 3 * duration); + break; + case "half-yearly": + expiry.setMonth(expiry.getMonth() + 6 * duration); + break; + case "yearly": + expiry.setFullYear(expiry.getFullYear() + duration); + break; + default: + expiry.setMonth(expiry.getMonth() + duration); + } + return expiry; +} + +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); + } catch (error) { + logger.error(`Error processing 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); - const expiryDate = new Date(); - const formattedExpiryDate = expiryDate.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - - // Create notification document - const notificationData = { - senderId: "system", - recipientId: gymOwnerId, - type: "plan_expired", - notificationSent: false, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - read: false, - data: { - title: "Plan Expired", - message: `The plan ${membershipData.subscription?.name || 'Unknown Plan'} for client ${clientName} has expired.`, - planName: membershipData.subscription?.name || 'Unknown Plan', - clientName: clientName, - membershipId: membershipId, - gymName: gymName, - formattedExpiryDate: formattedExpiryDate, - expiryDate: admin.firestore.Timestamp.fromDate(expiryDate), - }, - }; - - const notificationRef = await app + const existing = await app .firestore() .collection("notifications") - .add(notificationData); + .where("type", "==", "plan_expired") + .where("data.membershipId", "==", membershipId) + .limit(1) + .get(); - logger.info(`Plan expired notification created with ID: ${notificationRef.id} for gym owner ${gymOwnerId}, membership ${membershipId}`); + if (!existing.empty) { + logger.info(`Notification already sent for ${membershipId}, skipping...`); + return; + } + + const expiryDate = membershipData.subscription?.assignedAt?.toDate(); + const formattedDate = expiryDate + ? expiryDate.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }) + : "Unknown Date"; + + await app + .firestore() + .collection("notifications") + .add({ + senderId: "system", + recipientId: gymOwnerId, + type: "plan_expired", + notificationSent: false, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + data: { + title: "Plan Expired", + message: `The plan ${ + membershipData.subscription?.name || "Unknown Plan" + } for client ${clientName} has expired.`, + planName: membershipData.subscription?.name || "Unknown Plan", + clientName, + membershipId, + gymName, + formattedExpiryDate: formattedDate, + expiryDate: + membershipData.subscription?.assignedAt || + admin.firestore.Timestamp.fromDate(new Date()), + }, + }); + + logger.info( + `Notification sent for expired plan of membership ${membershipId}` + ); } catch (error) { - logger.error(`Error sending plan expired notification for membership ${membershipId}:`, error); - throw error; + logger.error(`Error sending notification for ${membershipId}:`, error); } } -async function getClientName(membershipId: string, clientId: string): Promise { +async function getClientName( + membershipId: string, + clientId: string +): Promise { try { - const clientProfileDoc = await app + const doc = await app .firestore() .collection("client_profiles") .doc(clientId) .get(); + if (!doc.exists) return "Unknown Client"; - if (!clientProfileDoc.exists) { - logger.warn(`Client profile not found for clientId: ${clientId}`); - return "Unknown Client"; - } - - const clientData = clientProfileDoc.data(); - const fields = clientData?.fields as ClientFields; - - if (fields) { - const firstName = fields['first-name'] || ''; - return firstName || "Unknown Client"; - } - - 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 membership ${membershipId}:`, error); + logger.error(`Error getting client name for ${membershipId}:`, error); return "Unknown Client"; } } async function getGymOwnerId(gymId: string): Promise { try { - const gymDoc = await app - .firestore() - .collection("gyms") - .doc(gymId) - .get(); - - if (!gymDoc.exists) { - throw new Error(`Gym not found: ${gymId}`); - } - - const gymData = gymDoc.data(); - const userId = gymData?.userId; - - if (!userId) { - throw new Error(`No userId found in gym document: ${gymId}`); - } - - return userId; + 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; @@ -159,18 +261,9 @@ async function getGymOwnerId(gymId: string): Promise { async function getGymName(gymId: string): Promise { try { - const gymDoc = await app - .firestore() - .collection("gyms") - .doc(gymId) - .get(); - - if (!gymDoc.exists) { - return "Unknown Gym"; - } - - const gymData = gymDoc.data(); - return gymData?.name || gymData?.gymName || "Unknown Gym"; + 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";