From d8bf928da8b8e46d683a8671d378c30fb76da206 Mon Sep 17 00:00:00 2001 From: DhanshCOSQ Date: Thu, 3 Jul 2025 08:45:25 +0000 Subject: [PATCH 01/15] feature/fitlien-updated-index (#68) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/68 Co-authored-by: DhanshCOSQ Co-committed-by: DhanshCOSQ --- functions/src/email/sendEmailSES.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/src/email/sendEmailSES.ts b/functions/src/email/sendEmailSES.ts index d461557..ff486be 100644 --- a/functions/src/email/sendEmailSES.ts +++ b/functions/src/email/sendEmailSES.ts @@ -26,7 +26,7 @@ interface EmailRequest { interface Attachment { filename: string; - content: string | Buffer; // Base64 encoded string or Buffer + content: string | Buffer; contentType?: string; } @@ -37,7 +37,7 @@ const stripHtml = (html: string): string => { async function sendSimpleEmail(data: EmailRequest, recipients: string[]) { const ses = new SESClient({ - region: '#{AWS_REGION}#', + region: process.env.AWS_REGION, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' @@ -63,7 +63,7 @@ async function sendSimpleEmail(data: EmailRequest, recipients: string[]) { async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) { const ses = new SESClient({ - region: 'ap-south-1', + region: process.env.AWS_REGION, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' From 7492cdedc14a3b1d5fca77fdd4415a6cc2b6e439 Mon Sep 17 00:00:00 2001 From: DhanshCOSQ Date: Wed, 16 Jul 2025 05:31:33 +0000 Subject: [PATCH 02/15] Accesslog done (#69) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/69 Co-authored-by: DhanshCOSQ Co-committed-by: DhanshCOSQ --- functions/src/dooraccess/essl.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/functions/src/dooraccess/essl.ts b/functions/src/dooraccess/essl.ts index ff26ad3..5e49730 100644 --- a/functions/src/dooraccess/essl.ts +++ b/functions/src/dooraccess/essl.ts @@ -207,10 +207,10 @@ function createGetEmployeePunchLogsRequest(username: string, password: string, - cosqclient - 3bbb58d5 - 1 - 2025-05-24 + ${escapeXml(username)} + ${escapeXml(password)} + ${escapeXml(employeeCode)} + ${escapeXml(attendanceDate)} `; @@ -232,9 +232,15 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate: const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); const currentElement = xmlDoc.documentElement.firstChild as HTMLElement; const resultText = currentElement.textContent; + if (!resultText || resultText.trim() === '' || resultText.trim() === ';;' || resultText.trim() === ';') { + return []; + } const punchLogs: Date[] = []; - const parts = resultText!.split(';'); + const parts = resultText.split(';'); for (const part of parts) { + if (!part || part.trim() === '') { + continue; + } try { const logDateTime = new Date(part); if (isNaN(logDateTime.getTime())) { @@ -245,8 +251,12 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate: try { const timeParts = part.split(','); for (const timePart of timeParts) { + if (!timePart || timePart.trim() === '') { + continue; + } + try { - const logDateTime = createDateFromTime(rootDate, timePart); + const logDateTime = createDateFromTime(rootDate, timePart.trim()); punchLogs.push(logDateTime); } catch { continue; @@ -261,6 +271,7 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate: return sortedLogs; } + async function sendSoapRequest(soapRequest: string, endpoint: string) { try { const headers: any = { From 9c2431fb7b3282e5457e5ef5bcc5339ec66b82e6 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Fri, 25 Jul 2025 07:57:10 +0000 Subject: [PATCH 03/15] Changes Updated (#70) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/70 Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../src/notifications/processNotification.ts | 512 ++++++++++++------ 1 file changed, 342 insertions(+), 170 deletions(-) diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index b995b87..ada5636 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -1,222 +1,394 @@ import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { getLogger } from "../shared/config"; import { getAdmin } from "../shared/config"; -import * as admin from 'firebase-admin'; +import * as admin from "firebase-admin"; const app = getAdmin(); const logger = getLogger(); interface NotificationData { - notificationSent?: boolean; - userId?: string; - clientId?: string; - invitorId?: string; - phoneNumber?: string; - message?: string; - type?: string; - status?: string; - gymName?: string; - trainerName?: string; - membershipId?: string; - subscriptionName?: string; - name?: string; - clientEmail?: string; - invitationId?: string; - [key: string]: any; + senderId?: string; + recipientId?: string; + notificationSent?: boolean; + userId?: string; + clientId?: string; + invitorId?: string; + phoneNumber?: string; + message?: string; + type?: string; + status?: string; + gymName?: string; + trainerName?: string; + membershipId?: string; + subscriptionName?: string; + name?: string; + clientEmail?: string; + invitationId?: string; + [key: string]: any; } -export const processNotificationOnCreate = onDocumentCreated({ - region: '#{SERVICES_RGN}#', - document: 'notifications/{notificationId}' -}, async (event) => { +export const processNotificationOnCreate = onDocumentCreated( + { + region: "#{SERVICES_RGN}#", + document: "notifications/{notificationId}", + }, + async (event) => { try { - const notificationSnapshot = event.data; - const notificationId = event.params.notificationId; + const notificationSnapshot = event.data; + const notificationId = event.params.notificationId; - if (!notificationSnapshot) { - logger.error(`No data found for notification ${notificationId}`); - return; - } + if (!notificationSnapshot) { + logger.error(`No data found for notification ${notificationId}`); + return; + } - const notification = notificationSnapshot.data() as NotificationData; - if (notification.notificationSent === true) { - logger.info(`Notification ${notificationId} already sent, skipping.`); - return; - } + const notification = notificationSnapshot.data() as NotificationData; - const { fcmToken } = await getUserAndFCMToken(notification); - if (!fcmToken) { - logger.error(`FCM token not found for notification ${notificationId}`); - await updateNotificationWithError(notificationId, 'FCM token not found for user'); - return; - } + if (notification.notificationSent === true) { + logger.info(`Notification ${notificationId} already sent, skipping.`); + return; + } - const message = prepareNotificationMessage(notification, fcmToken); - try { - const fcmResponse = await app.messaging().send({ - ...message, - token: fcmToken - }); + logger.info( + `Processing notification ${notificationId} of type: ${notification.type}` + ); - logger.info(`FCM notification sent successfully: ${fcmResponse}`); - await markNotificationAsSent(notificationId); + const { userId, fcmToken } = await getUserAndFCMToken(notification); + if (!fcmToken) { + logger.error( + `FCM token not found for notification ${notificationId}, user: ${userId}` + ); + await updateNotificationWithError( + notificationId, + "FCM token not found for user" + ); + return; + } - } catch (error) { - logger.error(`Error sending notification ${notificationId}:`, error); - await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error)); - } + const message = prepareNotificationMessage(notification, fcmToken); + try { + const fcmResponse = await app.messaging().send({ + ...message, + token: fcmToken, + }); + + logger.info(`FCM notification sent successfully: ${fcmResponse}`); + await markNotificationAsSent(notificationId); + } catch (error) { + logger.error(`Error sending notification ${notificationId}:`, error); + await updateNotificationWithError( + notificationId, + error instanceof Error ? error.message : String(error) + ); + } } catch (error) { - logger.error('Error processing notification:', error); + logger.error("Error processing notification:", error); } -}); + } +); -async function getUserAndFCMToken(notification: NotificationData): Promise<{ userId: string | null; fcmToken: string | null }> { - let userId: string | null = null; - let fcmToken: string | null = null; +async function getUserAndFCMToken( + notification: NotificationData +): Promise<{ userId: string | null; fcmToken: string | null }> { + let userId: string | null = null; + let fcmToken: string | null = null; - if (notification.userId) { - userId = notification.userId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.clientId) { - userId = notification.clientId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.invitorId) { - userId = notification.invitorId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.phoneNumber) { - const userQuery = await app - .firestore() - .collection('users') - .where('phoneNumber', '==', notification.phoneNumber) - .limit(1) - .get(); + if (notification.recipientId) { + userId = notification.recipientId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using recipientId: ${userId}`); + } else if (notification.userId) { + userId = notification.userId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using userId: ${userId}`); + } else if (notification.clientId) { + userId = notification.clientId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using clientId: ${userId}`); + } else if (notification.invitorId) { + userId = notification.invitorId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using invitorId: ${userId}`); + } else if (notification.phoneNumber) { + logger.info(`Looking up user by phone number: ${notification.phoneNumber}`); + const userQuery = await app + .firestore() + .collection("users") + .where("phoneNumber", "==", notification.phoneNumber) + .limit(1) + .get(); - if (!userQuery.empty) { - const userDoc = userQuery.docs[0]; - userId = userDoc.id; - fcmToken = userDoc.data()?.fcmToken; - } + if (!userQuery.empty) { + const userDoc = userQuery.docs[0]; + userId = userDoc.id; + fcmToken = userDoc.data()?.fcmToken; + logger.info(`Found user by phone: ${userId}`); + } else { + logger.warn( + `No user found with phone number: ${notification.phoneNumber}` + ); } + } else { + logger.error("No valid user identifier found in notification"); + } - return { userId, fcmToken }; + if (userId && !fcmToken) { + logger.warn(`User ${userId} found but no FCM token available`); + } + + return { userId, fcmToken }; } async function getFCMTokenFromUserDoc(userId: string): Promise { - const userDoc = await app.firestore().collection('users').doc(userId).get(); - return userDoc.exists ? userDoc.data()?.fcmToken : null; + try { + const userDoc = await app.firestore().collection("users").doc(userId).get(); + if (userDoc.exists) { + const userData = userDoc.data(); + const fcmToken = userData?.fcmToken; + if (!fcmToken) { + logger.warn(`User ${userId} exists but has no FCM token`); + } + return fcmToken; + } else { + logger.warn(`User document not found: ${userId}`); + return null; + } + } catch (error) { + logger.error(`Error fetching user ${userId}:`, error); + return null; + } } -function prepareNotificationMessage(notification: NotificationData, fcmToken: string): admin.messaging.Message { - let title = 'New Notification'; - let body = notification.message || 'You have a new notification'; - let data: Record = { - type: notification.type || 'general', - }; +function prepareNotificationMessage( + notification: NotificationData, + fcmToken: string +): admin.messaging.Message { + let title = "New Notification"; + let body = notification.message || "You have a new notification"; + let data: Record = { + type: notification.type || "general", + notificationId: "notification_" + Date.now(), + }; - switch (notification.type) { - case 'day_pass_entry': - const isAccepted = notification.status === 'ACCEPTED'; - title = isAccepted ? 'Day Pass Approved' : 'Day Pass Denied'; - body = notification.message || (isAccepted ? - 'Your day pass has been approved' : - 'Your day pass has been denied'); - data.gymName = notification.gymName || ''; - break; + switch (notification.type) { + case "trainer_response": + title = + notification.status === "ACCEPTED" + ? "Trainer Request Accepted" + : "Trainer Request Update"; + body = + notification.message || + `${ + notification.trainerName + } has ${notification.status?.toLowerCase()} your request`; + data.trainerName = notification.trainerName || ""; + data.status = notification.status || ""; + break; - case 'trainer_assigned_to_client': - title = 'Trainer Assigned'; - body = notification.message || `${notification.trainerName} has been assigned as your trainer`; - data.trainerName = notification.trainerName || ''; - data.membershipId = notification.membershipId || ''; - break; + case "trainer_assignment": + title = "New Client Assignment"; + body = + notification.message || + `You have been assigned to ${notification.name}`; + data.clientName = notification.name || ""; + data.membershipId = notification.membershipId || ""; + break; - case 'client_invitations': - if (notification.userId || notification.invitorId) { - const isAccept = notification.status === 'ACCEPTED'; - title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected'; - body = notification.message || (isAccept ? - `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` : - `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`); - } else if (notification.phoneNumber) { - const invitationStatus = getInvitationStatus(notification.status); - title = getInvitationTitle(invitationStatus); - body = notification.message || getInvitationBody(invitationStatus, notification.name); - data.status = invitationStatus; - } - data.gymName = notification.gymName || ''; - data.clientEmail = notification.clientEmail || ''; - data.clientName = notification.name || ''; - data.invitationId = notification.invitationId || ''; - data.subscriptionName = notification.subscriptionName || ''; - break; + case "trainer_assigned_to_client": + title = "Trainer Assigned"; + body = + notification.message || + `${notification.trainerName} has been assigned as your trainer`; + data.trainerName = notification.trainerName || ""; + data.membershipId = notification.membershipId || ""; + break; - default: - logger.info(`Using default handling for notification type: ${notification.type}`); - break; - } + case "trainer_update_owner": + title = "Trainer Schedule Update"; + body = notification.message || "A trainer has updated their schedule"; + data.membershipId = notification.membershipId || ""; + break; - const notificationMessage: admin.messaging.Message = { - notification: { title, body }, - data, - android: { - priority: 'high', - notification: { - channelId: 'notifications_channel', - priority: 'high', - defaultSound: true, - defaultVibrateTimings: true, - icon: '@mipmap/ic_launcher', - clickAction: 'FLUTTER_NOTIFICATION_CLICK', - }, + case "trainer_update_client": + title = "Schedule Update"; + body = notification.message || "Your training schedule has been updated"; + data.membershipId = notification.membershipId || ""; + break; + + case "plan_renewal": + title = "Plan Renewal"; + body = + notification.message || + `Plan ${notification.subscriptionName} has been renewed`; + data.planName = notification.subscriptionName || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "plan_assigned": + title = "New Plan Assigned"; + body = + notification.message || + `You have been assigned ${notification.subscriptionName} at ${notification.gymName}`; + data.planName = notification.subscriptionName || ""; + data.gymName = notification.gymName || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "schedule_update": + title = "Schedule Update"; + body = notification.message || "Your training schedule has been updated"; + data.gymName = notification.gymName || ""; + break; + + case "attendance_dispute": + title = "Attendance Dispute"; + body = + notification.message || + `${notification.name} has disputed an attendance record`; + data.disputedBy = notification.name || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "day_pass_entry": + const isAccepted = notification.status === "ACCEPTED"; + title = isAccepted ? "Day Pass Approved" : "Day Pass Denied"; + body = + notification.message || + (isAccepted + ? "Your day pass has been approved" + : "Your day pass has been denied"); + data.gymName = notification.gymName || ""; + data.status = notification.status || ""; + break; + + case "client_invitations": + if (notification.userId || notification.invitorId) { + const isAccept = notification.status === "ACCEPTED"; + title = isAccept ? "Invitation Accepted" : "Invitation Rejected"; + body = + notification.message || + (isAccept + ? `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` + : `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`); + } else if (notification.phoneNumber) { + const invitationStatus = getInvitationStatus(notification.status); + title = getInvitationTitle(invitationStatus); + body = + notification.message || + getInvitationBody(invitationStatus, notification.name); + data.status = invitationStatus; + } + data.gymName = notification.gymName || ""; + data.clientEmail = notification.clientEmail || ""; + data.clientName = notification.name || ""; + data.invitationId = notification.invitationId || ""; + data.subscriptionName = notification.subscriptionName || ""; + break; + + default: + logger.info( + `Using default handling for notification type: ${notification.type}` + ); + title = notification.type + ? `${notification.type.replace("_", " ").toUpperCase()}` + : "Notification"; + break; + } + + const notificationMessage: admin.messaging.Message = { + notification: { title, body }, + data, + android: { + priority: "high", + notification: { + channelId: "notifications_channel", + priority: "high", + defaultSound: true, + defaultVibrateTimings: true, + icon: "@mipmap/ic_launcher", + clickAction: "FLUTTER_NOTIFICATION_CLICK", + }, + }, + apns: { + payload: { + aps: { + sound: "default", + badge: 1, }, - apns: { - payload: { - aps: { - sound: 'default', - badge: 1, - }, - }, - }, - token: fcmToken, - }; - return notificationMessage; + }, + }, + token: fcmToken, + }; + + logger.info(`Prepared notification: ${title} - ${body}`); + return notificationMessage; } function getInvitationStatus(status?: string): string { - if (status === 'ACCEPTED') return 'accepted'; - if (status === 'REJECTED') return 'rejected'; - if (status === 'PENDING') return 'pending'; - return 'unknown'; + if (status === "ACCEPTED") return "accepted"; + if (status === "REJECTED") return "rejected"; + if (status === "PENDING") return "pending"; + return "unknown"; } function getInvitationTitle(status: string): string { - switch (status) { - case 'accepted': return 'Invitation Accepted'; - case 'rejected': return 'Invitation Rejected'; - case 'pending': return 'New Invitation'; - default: return 'Invitation Update'; - } + switch (status) { + case "accepted": + return "Invitation Accepted"; + case "rejected": + return "Invitation Rejected"; + case "pending": + return "New Invitation"; + default: + return "Invitation Update"; + } } function getInvitationBody(status: string, name?: string): string { - switch (status) { - case 'accepted': return `You have accepted the invitation from ${name}`; - case 'rejected': return `You have rejected the invitation from ${name}`; - case 'pending': return `You have a new invitation pending from ${name}`; - default: return 'There is an update to your invitation'; - } + switch (status) { + case "accepted": + return `You have accepted the invitation from ${name}`; + case "rejected": + return `You have rejected the invitation from ${name}`; + case "pending": + return `You have a new invitation pending from ${name}`; + default: + return "There is an update to your invitation"; + } } async function markNotificationAsSent(notificationId: string): Promise { - await app.firestore().collection('notifications').doc(notificationId).update({ + try { + await app + .firestore() + .collection("notifications") + .doc(notificationId) + .update({ notificationSent: true, - sentAt: app.firestore.FieldValue.serverTimestamp() - }); + sentAt: admin.firestore.FieldValue.serverTimestamp(), + }); + logger.info(`Notification ${notificationId} marked as sent`); + } catch (error) { + logger.error(`Error marking notification as sent: ${error}`); + } } -async function updateNotificationWithError(notificationId: string, error: string): Promise { - await app.firestore().collection('notifications').doc(notificationId).update({ +async function updateNotificationWithError( + notificationId: string, + error: string +): Promise { + try { + await app + .firestore() + .collection("notifications") + .doc(notificationId) + .update({ notificationError: error, - updatedAt: app.firestore.FieldValue.serverTimestamp() - }); + notificationSent: false, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + logger.info(`Notification ${notificationId} marked with error: ${error}`); + } catch (updateError) { + logger.error(`Error updating notification with error: ${updateError}`); + } } From 3223efc3929e63c9bc8034527d5d3c328e16a227 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Mon, 28 Jul 2025 13:01:53 +0000 Subject: [PATCH 04/15] notification-issue (#71) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/71 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../src/notifications/processNotification.ts | 252 +++++++++--------- 1 file changed, 133 insertions(+), 119 deletions(-) diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index ada5636..a9a5563 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -9,22 +9,12 @@ const logger = getLogger(); interface NotificationData { senderId?: string; recipientId?: string; - notificationSent?: boolean; - userId?: string; - clientId?: string; - invitorId?: string; - phoneNumber?: string; - message?: string; + ownerId?: string; type?: string; - status?: string; - gymName?: string; - trainerName?: string; - membershipId?: string; - subscriptionName?: string; - name?: string; - clientEmail?: string; - invitationId?: string; - [key: string]: any; + notificationSent?: boolean; + timestamp?: admin.firestore.FieldValue; + read?: boolean; + data?: { [key: string]: any }; } export const processNotificationOnCreate = onDocumentCreated( @@ -67,10 +57,7 @@ export const processNotificationOnCreate = onDocumentCreated( const message = prepareNotificationMessage(notification, fcmToken); try { - const fcmResponse = await app.messaging().send({ - ...message, - token: fcmToken, - }); + const fcmResponse = await app.messaging().send(message); logger.info(`FCM notification sent successfully: ${fcmResponse}`); await markNotificationAsSent(notificationId); @@ -90,53 +77,58 @@ export const processNotificationOnCreate = onDocumentCreated( async function getUserAndFCMToken( notification: NotificationData ): Promise<{ userId: string | null; fcmToken: string | null }> { - let userId: string | null = null; + let targetUserId: string | null = null; let fcmToken: string | null = null; if (notification.recipientId) { - userId = notification.recipientId; - fcmToken = await getFCMTokenFromUserDoc(userId); - logger.info(`Using recipientId: ${userId}`); - } else if (notification.userId) { - userId = notification.userId; - fcmToken = await getFCMTokenFromUserDoc(userId); - logger.info(`Using userId: ${userId}`); - } else if (notification.clientId) { - userId = notification.clientId; - fcmToken = await getFCMTokenFromUserDoc(userId); - logger.info(`Using clientId: ${userId}`); - } else if (notification.invitorId) { - userId = notification.invitorId; - fcmToken = await getFCMTokenFromUserDoc(userId); - logger.info(`Using invitorId: ${userId}`); - } else if (notification.phoneNumber) { - logger.info(`Looking up user by phone number: ${notification.phoneNumber}`); + targetUserId = notification.recipientId; + logger.info(`Using top-level recipientId: ${targetUserId}`); + } else if (notification.ownerId) { + targetUserId = notification.ownerId; + logger.info(`Using top-level ownerId: ${targetUserId}`); + } else if (notification.data?.userId) { + targetUserId = notification.data.userId; + logger.info(`Using data.userId: ${targetUserId}`); + } else if (notification.data?.clientId) { + targetUserId = notification.data.clientId; + logger.info(`Using data.clientId: ${targetUserId}`); + } else if (notification.data?.invitorId) { + targetUserId = notification.data.invitorId; + logger.info(`Using data.invitorId: ${targetUserId}`); + } else if (notification.data?.phoneNumber) { + logger.info( + `Looking up user by phone number from data: ${notification.data.phoneNumber}` + ); const userQuery = await app .firestore() .collection("users") - .where("phoneNumber", "==", notification.phoneNumber) + .where("phoneNumber", "==", notification.data.phoneNumber) .limit(1) .get(); if (!userQuery.empty) { const userDoc = userQuery.docs[0]; - userId = userDoc.id; + targetUserId = userDoc.id; fcmToken = userDoc.data()?.fcmToken; - logger.info(`Found user by phone: ${userId}`); + logger.info(`Found user by phone: ${targetUserId}`); } else { logger.warn( - `No user found with phone number: ${notification.phoneNumber}` + `No user found with phone number from data: ${notification.data.phoneNumber}` ); } } else { - logger.error("No valid user identifier found in notification"); + logger.error("No valid user identifier found in notification or its data"); } - if (userId && !fcmToken) { - logger.warn(`User ${userId} found but no FCM token available`); + if (targetUserId && !fcmToken) { + fcmToken = await getFCMTokenFromUserDoc(targetUserId); } - return { userId, fcmToken }; + if (targetUserId && !fcmToken) { + logger.warn(`User ${targetUserId} found but no FCM token available`); + } + + return { userId: targetUserId, fcmToken }; } async function getFCMTokenFromUserDoc(userId: string): Promise { @@ -162,142 +154,164 @@ async function getFCMTokenFromUserDoc(userId: string): Promise { function prepareNotificationMessage( notification: NotificationData, fcmToken: string -): admin.messaging.Message { - let title = "New Notification"; - let body = notification.message || "You have a new notification"; - let data: Record = { +): admin.messaging.TokenMessage { + let title = notification.data?.title || "New Notification"; + let body = notification.data?.message || "You have a new notification"; + + let fcmData: Record = { type: notification.type || "general", - notificationId: "notification_" + Date.now(), + notificationId: "notification_" + Date.now().toString(), }; + if (notification.senderId) fcmData.senderId = notification.senderId; + if (notification.recipientId) fcmData.recipientId = notification.recipientId; + if (notification.ownerId) fcmData.ownerId = notification.ownerId; + if (notification.read !== undefined) fcmData.read = String(notification.read); + + if (notification.data) { + for (const key in notification.data) { + if (Object.prototype.hasOwnProperty.call(notification.data, key)) { + const value = notification.data[key]; + if (typeof value === "object" && value !== null) { + fcmData[key] = JSON.stringify(value); + } else { + fcmData[key] = String(value); + } + } + } + } + switch (notification.type) { case "trainer_response": title = - notification.status === "ACCEPTED" + notification.data?.title || + (notification.data?.status === "ACCEPTED" ? "Trainer Request Accepted" - : "Trainer Request Update"; + : "Trainer Request Update"); body = - notification.message || + notification.data?.message || `${ - notification.trainerName - } has ${notification.status?.toLowerCase()} your request`; - data.trainerName = notification.trainerName || ""; - data.status = notification.status || ""; + notification.data?.trainerName + } has ${notification.data?.status?.toLowerCase()} your request`; break; case "trainer_assignment": - title = "New Client Assignment"; + title = notification.data?.title || "New Client Assignment"; body = - notification.message || - `You have been assigned to ${notification.name}`; - data.clientName = notification.name || ""; - data.membershipId = notification.membershipId || ""; + notification.data?.message || + `You have been assigned to ${notification.data?.name}`; break; case "trainer_assigned_to_client": - title = "Trainer Assigned"; + title = notification.data?.title || "Trainer Assigned"; body = - notification.message || - `${notification.trainerName} has been assigned as your trainer`; - data.trainerName = notification.trainerName || ""; - data.membershipId = notification.membershipId || ""; + notification.data?.message || + `${notification.data?.trainerName} has been assigned as your trainer`; break; case "trainer_update_owner": - title = "Trainer Schedule Update"; - body = notification.message || "A trainer has updated their schedule"; - data.membershipId = notification.membershipId || ""; + title = notification.data?.title || "Trainer Schedule Update"; + body = + notification.data?.message || "A trainer has updated their schedule"; break; case "trainer_update_client": - title = "Schedule Update"; - body = notification.message || "Your training schedule has been updated"; - data.membershipId = notification.membershipId || ""; + title = notification.data?.title || "Schedule Update"; + body = + notification.data?.message || "Your training schedule has been updated"; + if (notification.data?.oldTimeSlot && notification.data?.newTimeSlot) { + body += ` from ${notification.data.oldTimeSlot} to ${notification.data.newTimeSlot}`; + if (notification.data?.formattedDate) { + body += ` on ${notification.data.formattedDate}`; + } + } break; case "plan_renewal": - title = "Plan Renewal"; + title = notification.data?.title || "Plan Renewal"; body = - notification.message || - `Plan ${notification.subscriptionName} has been renewed`; - data.planName = notification.subscriptionName || ""; - data.membershipId = notification.membershipId || ""; + notification.data?.message || + `Plan ${notification.data?.subscriptionName} has been renewed`; break; case "plan_assigned": - title = "New Plan Assigned"; + title = notification.data?.title || "New Plan Assigned"; body = - notification.message || - `You have been assigned ${notification.subscriptionName} at ${notification.gymName}`; - data.planName = notification.subscriptionName || ""; - data.gymName = notification.gymName || ""; - data.membershipId = notification.membershipId || ""; + notification.data?.message || + `You have been assigned ${notification.data?.subscriptionName} at ${notification.data?.gymName}`; break; case "schedule_update": - title = "Schedule Update"; - body = notification.message || "Your training schedule has been updated"; - data.gymName = notification.gymName || ""; + title = notification.data?.title || "Schedule Update"; + body = + notification.data?.message || "Your training schedule has been updated"; + if (notification.data?.oldTimeSlot && notification.data?.newTimeSlot) { + body += ` from ${notification.data.oldTimeSlot} to ${notification.data.newTimeSlot}`; + if (notification.data?.formattedDate) { + body += ` on ${notification.data.formattedDate}`; + } + } break; case "attendance_dispute": - title = "Attendance Dispute"; + title = notification.data?.title || "Attendance Dispute"; body = - notification.message || - `${notification.name} has disputed an attendance record`; - data.disputedBy = notification.name || ""; - data.membershipId = notification.membershipId || ""; + notification.data?.message || + `${notification.data?.name} has disputed an attendance record`; + if (notification.data?.logTime) { + body += ` for ${notification.data.logTime}`; + } break; case "day_pass_entry": - const isAccepted = notification.status === "ACCEPTED"; - title = isAccepted ? "Day Pass Approved" : "Day Pass Denied"; + const isAccepted = notification.data?.status === "ACCEPTED"; + title = + notification.data?.title || + (isAccepted ? "Day Pass Approved" : "Day Pass Denied"); body = - notification.message || + notification.data?.message || (isAccepted ? "Your day pass has been approved" : "Your day pass has been denied"); - data.gymName = notification.gymName || ""; - data.status = notification.status || ""; break; case "client_invitations": - if (notification.userId || notification.invitorId) { - const isAccept = notification.status === "ACCEPTED"; - title = isAccept ? "Invitation Accepted" : "Invitation Rejected"; + if (notification.data?.userId || notification.data?.invitorId) { + const isAccept = notification.data?.status === "ACCEPTED"; + title = + notification.data?.title || + (isAccept ? "Invitation Accepted" : "Invitation Rejected"); body = - notification.message || + notification.data?.message || (isAccept - ? `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` - : `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`); - } else if (notification.phoneNumber) { - const invitationStatus = getInvitationStatus(notification.status); - title = getInvitationTitle(invitationStatus); + ? `The invitation for ${notification.data?.subscriptionName} you shared with ${notification.data?.name} has been accepted` + : `The invitation for ${notification.data?.subscriptionName} you shared with ${notification.data?.name} has been rejected`); + } else if (notification.data?.phoneNumber) { + const invitationStatus = getInvitationStatus(notification.data?.status); + title = + notification.data?.title || getInvitationTitle(invitationStatus); body = - notification.message || - getInvitationBody(invitationStatus, notification.name); - data.status = invitationStatus; + notification.data?.message || + getInvitationBody(invitationStatus, notification.data?.name); + fcmData.status = invitationStatus; } - data.gymName = notification.gymName || ""; - data.clientEmail = notification.clientEmail || ""; - data.clientName = notification.name || ""; - data.invitationId = notification.invitationId || ""; - data.subscriptionName = notification.subscriptionName || ""; break; default: logger.info( `Using default handling for notification type: ${notification.type}` ); - title = notification.type - ? `${notification.type.replace("_", " ").toUpperCase()}` - : "Notification"; + title = + notification.data?.title || + (notification.type + ? `${notification.type.replace("_", " ").toUpperCase()}` + : "Notification"); break; } - const notificationMessage: admin.messaging.Message = { + const notificationMessage: admin.messaging.TokenMessage = { notification: { title, body }, - data, + data: fcmData, android: { priority: "high", notification: { From 0a32e15d05a1b61ca422d4f808e0cbd2d2105f7b Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 29 Jul 2025 07:46:19 +0000 Subject: [PATCH 05/15] notification-issue (#72) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/72 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- functions/src/notifications/processNotification.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index a9a5563..e952fb0 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -9,7 +9,6 @@ const logger = getLogger(); interface NotificationData { senderId?: string; recipientId?: string; - ownerId?: string; type?: string; notificationSent?: boolean; timestamp?: admin.firestore.FieldValue; @@ -83,9 +82,6 @@ async function getUserAndFCMToken( if (notification.recipientId) { targetUserId = notification.recipientId; logger.info(`Using top-level recipientId: ${targetUserId}`); - } else if (notification.ownerId) { - targetUserId = notification.ownerId; - logger.info(`Using top-level ownerId: ${targetUserId}`); } else if (notification.data?.userId) { targetUserId = notification.data.userId; logger.info(`Using data.userId: ${targetUserId}`); @@ -165,7 +161,6 @@ function prepareNotificationMessage( if (notification.senderId) fcmData.senderId = notification.senderId; if (notification.recipientId) fcmData.recipientId = notification.recipientId; - if (notification.ownerId) fcmData.ownerId = notification.ownerId; if (notification.read !== undefined) fcmData.read = String(notification.read); if (notification.data) { @@ -185,7 +180,7 @@ function prepareNotificationMessage( case "trainer_response": title = notification.data?.title || - (notification.data?.status === "ACCEPTED" + (notification.data?.status === "accepted" ? "Trainer Request Accepted" : "Trainer Request Update"); body = @@ -199,14 +194,14 @@ function prepareNotificationMessage( title = notification.data?.title || "New Client Assignment"; body = notification.data?.message || - `You have been assigned to ${notification.data?.name}`; + `You have been assigned to train ${notification.data?.name}.`; break; case "trainer_assigned_to_client": title = notification.data?.title || "Trainer Assigned"; body = notification.data?.message || - `${notification.data?.trainerName} has been assigned as your trainer`; + `${notification.data?.trainerName} has been assigned as your trainer.`; break; case "trainer_update_owner": From ca08d83f987b98db9eef2e966224c5373481bab1 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 29 Jul 2025 13:50:50 +0000 Subject: [PATCH 06/15] notification-issue (#73) PLan Expiry Notification added Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/73 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- firestore.indexes.json | 36 +++++++++++++++++++ .../src/notifications/processNotification.ts | 7 ++++ 2 files changed, 43 insertions(+) diff --git a/firestore.indexes.json b/firestore.indexes.json index 5609cde..def7a3d 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -166,6 +166,42 @@ } ] }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "data.ownerId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "data.trainerId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "DESCENDING" + }, + { + "fieldPath": "__name__", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "workout_logs", "queryScope": "COLLECTION", diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index e952fb0..5d3c9b0 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -236,6 +236,13 @@ function prepareNotificationMessage( `You have been assigned ${notification.data?.subscriptionName} at ${notification.data?.gymName}`; break; + case "plan_expired": + 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}.`; + break; + case "schedule_update": title = notification.data?.title || "Schedule Update"; body = From 76a75330c8be84835d3250f14dda5b2d69930d5a Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Fri, 1 Aug 2025 13:20:36 +0000 Subject: [PATCH 07/15] Changes Updated (#74) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/74 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- functions/src/index.ts | 4 +- functions/src/notifications/index.ts | 2 + .../membershipStatusNotifications.ts | 271 ++++++++++++++++++ .../src/notifications/processNotification.ts | 2 +- 4 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 functions/src/notifications/membershipStatusNotifications.ts 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": From 1fc089a7cbd4ef613a35b49cd580ef873c3b37af Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Fri, 1 Aug 2025 16:33:09 +0000 Subject: [PATCH 08/15] expiry-notification (#75) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/75 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- firestore.indexes.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firestore.indexes.json b/firestore.indexes.json index def7a3d..7308d4d 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -107,7 +107,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "ownerId", + "fieldPath": "data.ownerId", "order": "ASCENDING" }, { @@ -121,7 +121,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "ownerId", + "fieldPath": "data.ownerId", "order": "ASCENDING" }, { @@ -139,7 +139,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "trainerId", + "fieldPath": "data.trainerId", "order": "ASCENDING" }, { From 943cff74d5eb61e909df364e1eaeb8dcf2d25e63 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Mon, 4 Aug 2025 04:43:03 +0000 Subject: [PATCH 09/15] expiry-notification (#76) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/76 Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- firestore.indexes.json | 62 +----------------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/firestore.indexes.json b/firestore.indexes.json index 7308d4d..49c7af5 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -75,21 +75,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "clientId", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "clientId", + "fieldPath": "data.clientId", "order": "ASCENDING" }, { @@ -102,52 +88,6 @@ } ] }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "data.ownerId", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "data.ownerId", - "order": "ASCENDING" - }, - { - "fieldPath": "type", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "data.trainerId", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, { "collectionGroup": "notifications", "queryScope": "COLLECTION", From b594579158598eb525b6afcd472ff96366eaf3f2 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Mon, 4 Aug 2025 05:47:40 +0000 Subject: [PATCH 10/15] expiry-notification (#77) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/77 Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- firestore.indexes.json | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/firestore.indexes.json b/firestore.indexes.json index 49c7af5..4e84252 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -70,6 +70,20 @@ } ] }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "data.clientId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "notifications", "queryScope": "COLLECTION", @@ -117,9 +131,23 @@ { "fieldPath": "timestamp", "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "data.ownerId", + "order": "ASCENDING" }, { - "fieldPath": "__name__", + "fieldPath": "type", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", "order": "DESCENDING" } ] @@ -135,10 +163,6 @@ { "fieldPath": "timestamp", "order": "DESCENDING" - }, - { - "fieldPath": "__name__", - "order": "DESCENDING" } ] }, From 5d47a78baa36d82b3450e626d2b642be4ae3bdd7 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Mon, 4 Aug 2025 07:30:25 +0000 Subject: [PATCH 11/15] expiry-notification (#78) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/78 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../membershipStatusNotifications.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index 17d108d..797e0fb 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -12,7 +12,6 @@ interface MembershipData { status: string; subscription?: { name: string; - duration: number; frequency: string; assignedAt: admin.firestore.Timestamp; }; @@ -88,10 +87,8 @@ async function findExpiredMemberships(): Promise< 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 ) { @@ -106,7 +103,6 @@ function checkIfMembershipExpired(data: MembershipData): boolean { ).toDate(); const expiryDate = calculateExpiryDate( startDate, - data.subscription.duration, data.subscription.frequency ); const now = new Date(); @@ -118,27 +114,23 @@ function checkIfMembershipExpired(data: MembershipData): boolean { } } -function calculateExpiryDate( - startDate: Date, - duration: number, - frequency: string -): Date { +function calculateExpiryDate(startDate: Date, frequency: string): Date { const expiry = new Date(startDate); switch (frequency.toLowerCase()) { case "monthly": - expiry.setMonth(expiry.getMonth() + duration); + expiry.setMonth(expiry.getMonth() + 1); break; case "quarterly": - expiry.setMonth(expiry.getMonth() + 3 * duration); + expiry.setMonth(expiry.getMonth() + 3); break; case "half-yearly": - expiry.setMonth(expiry.getMonth() + 6 * duration); + expiry.setMonth(expiry.getMonth() + 6); break; case "yearly": - expiry.setFullYear(expiry.getFullYear() + duration); + expiry.setFullYear(expiry.getFullYear() + 1); break; default: - expiry.setMonth(expiry.getMonth() + duration); + expiry.setMonth(expiry.getMonth() + 1); } return expiry; } From ef166a209ccfd3a4f73b6507be5d57d4d3b7b5a4 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 5 Aug 2025 06:16:47 +0000 Subject: [PATCH 12/15] expiry-using-payment (#79) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/79 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../membershipStatusNotifications.ts | 190 +++++++++++++++--- 1 file changed, 160 insertions(+), 30 deletions(-) diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index 797e0fb..ef0c238 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -13,7 +13,6 @@ interface MembershipData { subscription?: { name: string; frequency: string; - assignedAt: admin.firestore.Timestamp; }; } @@ -23,6 +22,17 @@ interface ClientFields { "last-name"?: string; } +interface PaymentData { + id: string; + date: string; + amount: number; + paymentMethod: string; + referenceNumber: string; + dateTimestamp: Date; + createdAt: Date; + discount?: number; +} + export const checkExpiredMemberships = onSchedule( { schedule: "0 8,14,20 * * *", @@ -72,11 +82,28 @@ async function findExpiredMemberships(): Promise< 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 }); - }); + 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) { @@ -85,35 +112,103 @@ async function findExpiredMemberships(): Promise< } } -function checkIfMembershipExpired(data: MembershipData): boolean { +async function checkIfMembershipExpired( + membershipId: string, + data: MembershipData +): Promise { try { - if ( - !data.subscription || - !data.subscription.frequency || - !data.subscription.assignedAt - ) { + if (!data.subscription || !data.subscription.frequency) { logger.warn( - `Skipping expiry check for membership ${data.id} with missing subscription data.` + `Skipping expiry check for membership ${membershipId} with missing subscription data.` ); return false; } - const startDate = ( - data.subscription.assignedAt as admin.firestore.Timestamp - ).toDate(); + 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(); - return now > expiryDate; + 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 ${data.id}:`, error); + logger.error( + `Error checking expiry for membership ${membershipId}:`, + error + ); return false; } } +async function getPaymentsForMembership( + membershipId: string +): Promise { + try { + const docSnapshot = await app + .firestore() + .collection("membershipPayments") + .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()) { @@ -130,11 +225,38 @@ function calculateExpiryDate(startDate: Date, frequency: string): Date { expiry.setFullYear(expiry.getFullYear() + 1); break; default: - expiry.setMonth(expiry.getMonth() + 1); + 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 processExpiredMembership( membershipId: string, membershipData: MembershipData @@ -174,14 +296,22 @@ async function sendPlanExpiredNotification( return; } - const expiryDate = membershipData.subscription?.assignedAt?.toDate(); - const formattedDate = expiryDate - ? expiryDate.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }) - : "Unknown Date"; + 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", + }); + } await app .firestore() @@ -203,9 +333,9 @@ async function sendPlanExpiredNotification( membershipId, gymName, formattedExpiryDate: formattedDate, - expiryDate: - membershipData.subscription?.assignedAt || - admin.firestore.Timestamp.fromDate(new Date()), + expiryDate: expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()), }, }); From cba945c282124864058b59ae9d1b3f4e18d62329 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 5 Aug 2025 09:22:10 +0000 Subject: [PATCH 13/15] expiry-using-payment (#80) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/80 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- functions/src/notifications/membershipStatusNotifications.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index ef0c238..bc908e2 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -35,7 +35,7 @@ interface PaymentData { export const checkExpiredMemberships = onSchedule( { - schedule: "0 8,14,20 * * *", + schedule: "*/5 * * * *", timeZone: "UTC", region: "#{SERVICES_RGN}#", }, @@ -171,7 +171,7 @@ async function getPaymentsForMembership( try { const docSnapshot = await app .firestore() - .collection("membershipPayments") + .collection("membership_payments") .doc(membershipId) .get(); From fb23661080d55a874a098141cb7879ddae945588 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 5 Aug 2025 13:20:00 +0000 Subject: [PATCH 14/15] expiry-using-payment (#81) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/81 Reviewed-by: Dhansh A S Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../src/notifications/membershipStatusNotifications.ts | 8 ++++---- functions/src/notifications/processNotification.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index bc908e2..bb0887b 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -274,6 +274,7 @@ async function processExpiredMembership( } } + async function sendPlanExpiredNotification( membershipId: string, membershipData: MembershipData @@ -311,6 +312,7 @@ async function sendPlanExpiredNotification( month: "long", day: "numeric", }); + } await app @@ -323,15 +325,13 @@ async function sendPlanExpiredNotification( notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, + readBy: [], 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, + ownerId: gymOwnerId, formattedExpiryDate: formattedDate, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index d232395..37b6865 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} has expired.`; + `${notification.data?.clientName}/s subscription for plan ${notification.data?.planName} has expired.`; break; case "schedule_update": From 237dd8a263cd37ea8a3fa0bb3a7d38fc8cff9769 Mon Sep 17 00:00:00 2001 From: Sharon Dcruz Date: Tue, 5 Aug 2025 14:03:40 +0000 Subject: [PATCH 15/15] Changes Updated (#82) Reviewed-on: https://git.cosqnet.com/cosqnet/fitlien-services/pulls/82 Co-authored-by: Sharon Dcruz Co-committed-by: Sharon Dcruz --- .../membershipStatusNotifications.ts | 198 +++++++++++++++++- .../src/notifications/processNotification.ts | 9 +- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index bb0887b..3f6e564 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -44,25 +44,32 @@ export const checkExpiredMemberships = onSchedule( try { const expiredMemberships = await findExpiredMemberships(); + const expiringMemberships = await findMembershipsExpiringIn2Days(); - if (expiredMemberships.length === 0) { - logger.info("No expired memberships found."); + if (expiredMemberships.length === 0 && expiringMemberships.length === 0) { + logger.info("No expired or expiring memberships found."); return; } logger.info( - `Found ${expiredMemberships.length} expired memberships to process.` + `Found ${expiredMemberships.length} expired memberships and ${expiringMemberships.length} memberships expiring in 2 days to process.` ); - const results = await Promise.allSettled( + const expiredResults = 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; + 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. Success: ${successful}, Failed: ${failed}` + `Completed processing. Expired - Success: ${expiredSuccessful}, Failed: ${expiredFailed}. Expiring - Success: ${expiringSuccessful}, Failed: ${expiringFailed}` ); } catch (error) { logger.error("Error in scheduled membership expiry check:", error); @@ -112,6 +119,48 @@ async function findExpiredMemberships(): Promise< } } +async function findMembershipsExpiringIn2Days(): 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 isExpiringIn2Days = await checkIfMembershipExpiringIn2Days(doc.id, data); + if (isExpiringIn2Days) { + 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 2 days:", error); + throw error; + } +} + async function checkIfMembershipExpired( membershipId: string, data: MembershipData @@ -165,6 +214,56 @@ async function checkIfMembershipExpired( } } +async function checkIfMembershipExpiringIn2Days( + 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 twoDaysFromNow = new Date(); + twoDaysFromNow.setDate(now.getDate() + 2); + + const isExpiringIn2Days = expiryDate > now && expiryDate <= twoDaysFromNow; + + if (isExpiringIn2Days) { + logger.info( + `Membership ${membershipId} will expire on ${expiryDate.toISOString()} (within 2 days)` + ); + } + + return isExpiringIn2Days; + } catch (error) { + logger.error( + `Error checking 2-day expiry for membership ${membershipId}:`, + error + ); + return false; + } +} + async function getPaymentsForMembership( membershipId: string ): Promise { @@ -274,6 +373,17 @@ async function processExpiredMembership( } } +async function processExpiringMembership( + membershipId: string, + membershipData: MembershipData +): Promise { + try { + logger.info(`Processing expiring membership ${membershipId}`); + await sendPlanExpiringNotification(membershipId, membershipData); + } catch (error) { + logger.error(`Error processing expiring membership ${membershipId}:`, error); + } +} async function sendPlanExpiredNotification( membershipId: string, @@ -347,6 +457,78 @@ async function sendPlanExpiredNotification( } } +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); + + const existing = await app + .firestore() + .collection("notifications") + .where("type", "==", "plan_expiring_soon") + .where("data.membershipId", "==", membershipId) + .limit(1) + .get(); + + if (!existing.empty) { + logger.info(`Expiring notification already sent for ${membershipId}, skipping...`); + return; + } + + 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", + }); + } + + 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: 2, + }, + }); + + logger.info( + `Expiring notification sent for membership ${membershipId} (expires on ${formattedDate})` + ); + } catch (error) { + logger.error(`Error sending expiring notification for ${membershipId}:`, error); + } +} + async function getClientName( membershipId: string, clientId: string @@ -390,4 +572,4 @@ async function getGymName(gymId: string): Promise { logger.error(`Error getting gym name for gym ${gymId}:`, error); return "Unknown Gym"; } -} +} \ No newline at end of file diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index 37b6865..418476e 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -240,7 +240,14 @@ function prepareNotificationMessage( title = notification.data?.title || "Plan Expired"; body = notification.data?.message || - `${notification.data?.clientName}/s subscription for plan ${notification.data?.planName} has expired.`; + `${notification.data?.clientName}'s subscription for plan ${notification.data?.planName} has expired.`; + break; + + case "plan_expiring_soon": + title = notification.data?.title || "Plan Expiring Soon"; + body = + notification.data?.message || + `${notification.data?.clientName}'s subscription for plan ${notification.data?.planName} will expire on ${notification.data?.formattedExpiryDate}.`; break; case "schedule_update":