diff --git a/firestore.indexes.json b/firestore.indexes.json index 5609cde..4e84252 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -75,7 +75,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "clientId", + "fieldPath": "data.clientId", "order": "ASCENDING" }, { @@ -89,7 +89,7 @@ "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "clientId", + "fieldPath": "data.clientId", "order": "ASCENDING" }, { @@ -102,52 +102,6 @@ } ] }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "ownerId", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "ownerId", - "order": "ASCENDING" - }, - { - "fieldPath": "type", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, - { - "collectionGroup": "notifications", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "trainerId", - "order": "ASCENDING" - }, - { - "fieldPath": "timestamp", - "order": "DESCENDING" - } - ] - }, { "collectionGroup": "notifications", "queryScope": "COLLECTION", @@ -166,6 +120,52 @@ } ] }, + { + "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": "workout_logs", "queryScope": "COLLECTION", 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 = { 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 || '' 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..3f6e564 --- /dev/null +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -0,0 +1,575 @@ +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; + frequency: string; + }; +} + +interface ClientFields { + [key: string]: string | undefined; + "first-name"?: string; + "last-name"?: string; +} + +interface PaymentData { + id: string; + date: string; + amount: number; + paymentMethod: string; + referenceNumber: string; + dateTimestamp: Date; + createdAt: Date; + discount?: number; +} + +export const checkExpiredMemberships = onSchedule( + { + schedule: "*/5 * * * *", + timeZone: "UTC", + region: "#{SERVICES_RGN}#", + }, + async (event) => { + logger.info("Starting scheduled membership expiry check..."); + + try { + const expiredMemberships = await findExpiredMemberships(); + const expiringMemberships = await findMembershipsExpiringIn2Days(); + + if (expiredMemberships.length === 0 && expiringMemberships.length === 0) { + logger.info("No expired or expiring memberships found."); + return; + } + + logger.info( + `Found ${expiredMemberships.length} expired memberships and ${expiringMemberships.length} memberships expiring in 2 days to process.` + ); + + const expiredResults = await Promise.allSettled( + expiredMemberships.map((m) => processExpiredMembership(m.id, m.data)) + ); + + const expiringResults = await Promise.allSettled( + expiringMemberships.map((m) => processExpiringMembership(m.id, m.data)) + ); + + const expiredSuccessful = expiredResults.filter((r) => r.status === "fulfilled").length; + const expiredFailed = expiredResults.filter((r) => r.status === "rejected").length; + const expiringSuccessful = expiringResults.filter((r) => r.status === "fulfilled").length; + const expiringFailed = expiringResults.filter((r) => r.status === "rejected").length; + + logger.info( + `Completed processing. Expired - Success: ${expiredSuccessful}, Failed: ${expiredFailed}. Expiring - Success: ${expiringSuccessful}, Failed: ${expiringFailed}` + ); + } catch (error) { + logger.error("Error in scheduled membership expiry check:", error); + } + } +); + +async function findExpiredMemberships(): Promise< + Array<{ id: string; data: MembershipData }> +> { + try { + const snapshot = await app + .firestore() + .collection("memberships") + .where("status", "==", "ACTIVE") + .get(); + + const expired: Array<{ id: string; data: MembershipData }> = []; + + const batchSize = 10; + const docs = snapshot.docs; + + for (let i = 0; i < docs.length; i += batchSize) { + const batch = docs.slice(i, i + batchSize); + const batchResults = await Promise.allSettled( + batch.map(async (doc) => { + const data = doc.data() as MembershipData; + const isExpired = await checkIfMembershipExpired(doc.id, data); + if (isExpired) { + return { id: doc.id, data }; + } + return null; + }) + ); + + batchResults.forEach((result) => { + if (result.status === "fulfilled" && result.value) { + expired.push(result.value); + } + }); + } + + return expired; + } catch (error) { + logger.error("Error finding expired memberships:", error); + throw error; + } +} + +async function 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 +): Promise { + try { + if (!data.subscription || !data.subscription.frequency) { + logger.warn( + `Skipping expiry check for membership ${membershipId} with missing subscription data.` + ); + return false; + } + + let startDate: Date; + + const payments = await getPaymentsForMembership(membershipId); + if (payments.length === 0) { + logger.warn( + `No payments found for membership ${membershipId}, cannot determine expiry` + ); + return false; + } + + const latestPayment = payments[0]; + startDate = latestPayment.dateTimestamp; + + logger.info( + `Using latest payment date ${startDate.toISOString()} for membership ${membershipId}` + ); + + const expiryDate = calculateExpiryDate( + startDate, + data.subscription.frequency + ); + const now = new Date(); + + const isExpired = now > expiryDate; + + if (isExpired) { + logger.info( + `Membership ${membershipId} expired on ${expiryDate.toISOString()}` + ); + } + + return isExpired; + } catch (error) { + logger.error( + `Error checking expiry for membership ${membershipId}:`, + error + ); + return false; + } +} + +async function 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 { + try { + const docSnapshot = await app + .firestore() + .collection("membership_payments") + .doc(membershipId) + .get(); + + if (!docSnapshot.exists) { + return []; + } + + const data = docSnapshot.data(); + const paymentsData = data?.payments || []; + + const payments: PaymentData[] = paymentsData.map((payment: any) => ({ + id: payment.id, + date: payment.date, + amount: payment.amount, + paymentMethod: payment.paymentMethod, + referenceNumber: payment.referenceNumber, + dateTimestamp: payment.dateTimestamp.toDate + ? payment.dateTimestamp.toDate() + : new Date(payment.dateTimestamp), + createdAt: payment.createdAt.toDate + ? payment.createdAt.toDate() + : new Date(payment.createdAt), + discount: payment.discount, + })); + + payments.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return payments; + } catch (error) { + logger.error( + `Error getting payments for membership ${membershipId}:`, + error + ); + return []; + } +} + +function calculateExpiryDate(startDate: Date, frequency: string): Date { + const expiry = new Date(startDate); + switch (frequency.toLowerCase()) { + case "monthly": + expiry.setMonth(expiry.getMonth() + 1); + break; + case "quarterly": + expiry.setMonth(expiry.getMonth() + 3); + break; + case "half-yearly": + expiry.setMonth(expiry.getMonth() + 6); + break; + case "yearly": + expiry.setFullYear(expiry.getFullYear() + 1); + break; + default: + expiry.setMonth(expiry.getMonth() + 1); + } + return expiry; +} + +function calculateRenewalDateFromPayment( + subscription: any, + paymentDate: Date +): Date { + const renewalDate = new Date(paymentDate); + const frequency = subscription.frequency || "Monthly"; + + switch (frequency.toLowerCase()) { + case "monthly": + renewalDate.setMonth(renewalDate.getMonth() + 1); + break; + case "quarterly": + renewalDate.setMonth(renewalDate.getMonth() + 3); + break; + case "half-yearly": + renewalDate.setMonth(renewalDate.getMonth() + 6); + break; + case "yearly": + renewalDate.setFullYear(renewalDate.getFullYear() + 1); + break; + default: + renewalDate.setMonth(renewalDate.getMonth() + 1); + } + + return renewalDate; +} + +async function 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 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, + 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; + } + + 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_expired", + notificationSent: false, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + readBy: [], + data: { + planName: membershipData.subscription?.name || "Unknown Plan", + clientName, + membershipId, + gymName, + ownerId: gymOwnerId, + formattedExpiryDate: formattedDate, + expiryDate: expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()), + }, + }); + + logger.info( + `Notification sent for expired plan of membership ${membershipId}` + ); + } catch (error) { + logger.error(`Error sending notification for ${membershipId}:`, error); + } +} + +async function sendPlanExpiringNotification( + membershipId: string, + membershipData: MembershipData +): Promise { + try { + const clientName = await getClientName(membershipId, membershipData.userId); + const gymOwnerId = await getGymOwnerId(membershipData.gymId); + const gymName = await getGymName(membershipData.gymId); + + 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 +): 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"; + } +} \ No newline at end of file diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index b995b87..418476e 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -1,222 +1,417 @@ 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; + type?: string; + notificationSent?: boolean; + timestamp?: admin.firestore.FieldValue; + read?: boolean; + data?: { [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); + + 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 targetUserId: 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) { + targetUserId = notification.recipientId; + logger.info(`Using top-level recipientId: ${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.data.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]; + targetUserId = userDoc.id; + fcmToken = userDoc.data()?.fcmToken; + logger.info(`Found user by phone: ${targetUserId}`); + } else { + logger.warn( + `No user found with phone number from data: ${notification.data.phoneNumber}` + ); } + } else { + logger.error("No valid user identifier found in notification or its data"); + } - return { userId, fcmToken }; + if (targetUserId && !fcmToken) { + fcmToken = await getFCMTokenFromUserDoc(targetUserId); + } + + if (targetUserId && !fcmToken) { + logger.warn(`User ${targetUserId} found but no FCM token available`); + } + + return { userId: targetUserId, 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.TokenMessage { + let title = notification.data?.title || "New Notification"; + let body = notification.data?.message || "You have a new notification"; - 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; + let fcmData: Record = { + type: notification.type || "general", + notificationId: "notification_" + Date.now().toString(), + }; - 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; + if (notification.senderId) fcmData.senderId = notification.senderId; + if (notification.recipientId) fcmData.recipientId = notification.recipientId; + if (notification.read !== undefined) fcmData.read = String(notification.read); - 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}`); - break; + 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); + } + } } + } - 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', - }, + switch (notification.type) { + case "trainer_response": + title = + notification.data?.title || + (notification.data?.status === "accepted" + ? "Trainer Request Accepted" + : "Trainer Request Update"); + body = + notification.data?.message || + `${ + notification.data?.trainerName + } has ${notification.data?.status?.toLowerCase()} your request`; + break; + + case "trainer_assignment": + title = notification.data?.title || "New Client Assignment"; + body = + notification.data?.message || + `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.`; + break; + + case "trainer_update_owner": + title = notification.data?.title || "Trainer Schedule Update"; + body = + notification.data?.message || "A trainer has updated their schedule"; + break; + + case "trainer_update_client": + 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 = notification.data?.title || "Plan Renewal"; + body = + notification.data?.message || + `Plan ${notification.data?.subscriptionName} has been renewed`; + break; + + case "plan_assigned": + title = notification.data?.title || "New Plan Assigned"; + body = + notification.data?.message || + `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 || + `${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": + 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 = notification.data?.title || "Attendance Dispute"; + body = + 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.data?.status === "ACCEPTED"; + title = + notification.data?.title || + (isAccepted ? "Day Pass Approved" : "Day Pass Denied"); + body = + notification.data?.message || + (isAccepted + ? "Your day pass has been approved" + : "Your day pass has been denied"); + break; + + case "client_invitations": + if (notification.data?.userId || notification.data?.invitorId) { + const isAccept = notification.data?.status === "ACCEPTED"; + title = + notification.data?.title || + (isAccept ? "Invitation Accepted" : "Invitation Rejected"); + body = + notification.data?.message || + (isAccept + ? `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.data?.message || + getInvitationBody(invitationStatus, notification.data?.name); + fcmData.status = invitationStatus; + } + break; + + default: + logger.info( + `Using default handling for notification type: ${notification.type}` + ); + title = + notification.data?.title || + (notification.type + ? `${notification.type.replace("_", " ").toUpperCase()}` + : "Notification"); + break; + } + + const notificationMessage: admin.messaging.TokenMessage = { + notification: { title, body }, + data: fcmData, + 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}`); + } }