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}`);
+ }
}