diff --git a/functions/src/index.ts b/functions/src/index.ts index 1156209..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 } 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 c9fed3e..9e40bc5 100644 --- a/functions/src/notifications/index.ts +++ b/functions/src/notifications/index.ts @@ -1 +1,3 @@ export { processNotificationOnCreate } from './processNotification'; +export { checkExpiredMemberships } from "./membershipStatusNotifications"; + diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts new file mode 100644 index 0000000..17d108d --- /dev/null +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -0,0 +1,271 @@ +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(); + +interface MembershipData { + id?: string; + userId: string; + gymId: string; + status: string; + subscription?: { + name: string; + duration: number; + frequency: string; + assignedAt: admin.firestore.Timestamp; + }; +} + +interface ClientFields { + [key: string]: string | undefined; + "first-name"?: string; + "last-name"?: string; +} + +export const checkExpiredMemberships = onSchedule( + { + schedule: "0 8,14,20 * * *", + timeZone: "UTC", + region: "#{SERVICES_RGN}#", + }, + async (event) => { + logger.info("Starting scheduled membership expiry check..."); + + try { + const expiredMemberships = await findExpiredMemberships(); + + if (expiredMemberships.length === 0) { + logger.info("No expired memberships found."); + return; + } + + 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 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 existing = await app + .firestore() + .collection("notifications") + .where("type", "==", "plan_expired") + .where("data.membershipId", "==", membershipId) + .limit(1) + .get(); + + 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 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"; + } +} diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index 5d3c9b0..d232395 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -240,7 +240,7 @@ function prepareNotificationMessage( title = notification.data?.title || "Plan Expired"; body = notification.data?.message || - `The plan ${notification.data?.planName} for client ${notification.data?.clientName} expired on ${notification.data?.formattedExpiryDate}.`; + `The plan ${notification.data?.planName} for client ${notification.data?.clientName} has expired.`; break; case "schedule_update":