Compare commits

...

22 Commits

Author SHA1 Message Date
195262a6de Merge branch 'qa'
Some checks failed
Deploy FitLien services / Deploy (push) Failing after 2m8s
Deploy FitLien services to QA / Deploy to QA (push) Failing after 1m42s
2025-08-18 18:53:46 +05:30
d3c9e86c7c Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 4m10s
2025-08-08 18:53:15 +05:30
4cf5692386 plan-expiry-in-two-days (#83)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m20s
Reviewed-on: #83
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-08 07:55:52 +00:00
8b308fb9a6 Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 5m14s
2025-08-07 20:17:17 +05:30
237dd8a263 Changes Updated (#82)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m28s
Reviewed-on: #82
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-05 14:03:40 +00:00
fb23661080 expiry-using-payment (#81)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m54s
Reviewed-on: #81
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-05 13:20:00 +00:00
cba945c282 expiry-using-payment (#80)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m5s
Reviewed-on: #80
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-05 09:22:10 +00:00
ef166a209c expiry-using-payment (#79)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m54s
Reviewed-on: #79
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-05 06:16:47 +00:00
5d47a78baa expiry-notification (#78)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m4s
Reviewed-on: #78
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-04 07:30:25 +00:00
b594579158 expiry-notification (#77)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m8s
Reviewed-on: #77
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-04 05:47:40 +00:00
943cff74d5 expiry-notification (#76)
Some checks failed
Deploy FitLien services to Dev / Deploy to Dev (push) Failing after 1m40s
Reviewed-on: #76
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-04 04:43:03 +00:00
1fc089a7cb expiry-notification (#75)
Some checks failed
Deploy FitLien services to Dev / Deploy to Dev (push) Failing after 1m42s
Reviewed-on: #75
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-01 16:33:09 +00:00
76a75330c8 Changes Updated (#74)
Some checks failed
Deploy FitLien services to Dev / Deploy to Dev (push) Failing after 1m32s
Reviewed-on: #74
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-08-01 13:20:36 +00:00
ca08d83f98 notification-issue (#73)
Some checks failed
Deploy FitLien services to Dev / Deploy to Dev (push) Failing after 2m3s
PLan Expiry Notification added

Reviewed-on: #73
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-07-29 13:50:50 +00:00
0a32e15d05 notification-issue (#72)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m29s
Reviewed-on: #72
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-07-29 07:46:19 +00:00
3223efc392 notification-issue (#71)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m23s
Reviewed-on: #71
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-07-28 13:01:53 +00:00
9c2431fb7b Changes Updated (#70)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m48s
Reviewed-on: #70
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-07-25 07:57:10 +00:00
7492cdedc1 Accesslog done (#69)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m11s
Reviewed-on: #69
Co-authored-by: DhanshCOSQ <dhanshas@cosq.net>
Co-committed-by: DhanshCOSQ <dhanshas@cosq.net>
2025-07-16 05:31:33 +00:00
d8bf928da8 feature/fitlien-updated-index (#68)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m4s
Reviewed-on: #68
Co-authored-by: DhanshCOSQ <dhanshas@cosq.net>
Co-committed-by: DhanshCOSQ <dhanshas@cosq.net>
2025-07-03 08:45:25 +00:00
9d51393aa5 Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 3m39s
2025-07-02 21:28:42 +05:30
ef2cd80226 Merge branch 'qa'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 4m2s
2025-06-24 15:19:54 +05:30
f2e37e88ed Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 4m24s
2025-06-12 19:47:27 +05:30
7 changed files with 1017 additions and 229 deletions

View File

@ -75,7 +75,7 @@
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
"fields": [ "fields": [
{ {
"fieldPath": "clientId", "fieldPath": "data.clientId",
"order": "ASCENDING" "order": "ASCENDING"
}, },
{ {
@ -89,7 +89,7 @@
"queryScope": "COLLECTION", "queryScope": "COLLECTION",
"fields": [ "fields": [
{ {
"fieldPath": "clientId", "fieldPath": "data.clientId",
"order": "ASCENDING" "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", "collectionGroup": "notifications",
"queryScope": "COLLECTION", "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", "collectionGroup": "workout_logs",
"queryScope": "COLLECTION", "queryScope": "COLLECTION",

View File

@ -207,10 +207,10 @@ function createGetEmployeePunchLogsRequest(username: string, password: string,
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope"> <soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body> <soap12:Body>
<GetEmployeePunchLogs xmlns="http://tempuri.org/"> <GetEmployeePunchLogs xmlns="http://tempuri.org/">
<UserName>cosqclient</UserName> <UserName>${escapeXml(username)}</UserName>
<Password>3bbb58d5</Password> <Password>${escapeXml(password)}</Password>
<EmployeeCode>1</EmployeeCode> <EmployeeCode>${escapeXml(employeeCode)}</EmployeeCode>
<AttendanceDate>2025-05-24</AttendanceDate> <AttendanceDate>${escapeXml(attendanceDate)}</AttendanceDate>
</GetEmployeePunchLogs> </GetEmployeePunchLogs>
</soap12:Body> </soap12:Body>
</soap12:Envelope>`; </soap12:Envelope>`;
@ -232,9 +232,15 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate:
const xmlDoc = parser.parseFromString(soapResponse, "text/xml"); const xmlDoc = parser.parseFromString(soapResponse, "text/xml");
const currentElement = xmlDoc.documentElement.firstChild as HTMLElement; const currentElement = xmlDoc.documentElement.firstChild as HTMLElement;
const resultText = currentElement.textContent; const resultText = currentElement.textContent;
if (!resultText || resultText.trim() === '' || resultText.trim() === ';;' || resultText.trim() === ';') {
return [];
}
const punchLogs: Date[] = []; const punchLogs: Date[] = [];
const parts = resultText!.split(';'); const parts = resultText.split(';');
for (const part of parts) { for (const part of parts) {
if (!part || part.trim() === '') {
continue;
}
try { try {
const logDateTime = new Date(part); const logDateTime = new Date(part);
if (isNaN(logDateTime.getTime())) { if (isNaN(logDateTime.getTime())) {
@ -245,8 +251,12 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate:
try { try {
const timeParts = part.split(','); const timeParts = part.split(',');
for (const timePart of timeParts) { for (const timePart of timeParts) {
if (!timePart || timePart.trim() === '') {
continue;
}
try { try {
const logDateTime = createDateFromTime(rootDate, timePart); const logDateTime = createDateFromTime(rootDate, timePart.trim());
punchLogs.push(logDateTime); punchLogs.push(logDateTime);
} catch { } catch {
continue; continue;
@ -261,6 +271,7 @@ function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate:
return sortedLogs; return sortedLogs;
} }
async function sendSoapRequest(soapRequest: string, endpoint: string) { async function sendSoapRequest(soapRequest: string, endpoint: string) {
try { try {
const headers: any = { const headers: any = {

View File

@ -26,7 +26,7 @@ interface EmailRequest {
interface Attachment { interface Attachment {
filename: string; filename: string;
content: string | Buffer; // Base64 encoded string or Buffer content: string | Buffer;
contentType?: string; contentType?: string;
} }
@ -37,7 +37,7 @@ const stripHtml = (html: string): string => {
async function sendSimpleEmail(data: EmailRequest, recipients: string[]) { async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({ const ses = new SESClient({
region: '#{AWS_REGION}#', region: process.env.AWS_REGION,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' 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[]) { async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({ const ses = new SESClient({
region: 'ap-south-1', region: process.env.AWS_REGION,
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''

View File

@ -13,7 +13,7 @@ export * from './shared/config';
export { sendEmailSES } from './email'; export { sendEmailSES } from './email';
export { sendSMSMessage } from './sms'; export { sendSMSMessage } from './sms';
export { accessFile } from './storage'; export { accessFile } from './storage';
export { processNotificationOnCreate } from './notifications'; export { processNotificationOnCreate,checkExpiredMemberships } from './notifications';
export * from './payments'; export * from './payments';
export { getPlaceDetails, getPlacesAutocomplete } from './places'; export { getPlaceDetails, getPlacesAutocomplete } from './places';
export { registerClient } from './users'; export { registerClient } from './users';

View File

@ -1 +1,3 @@
export { processNotificationOnCreate } from './processNotification'; export { processNotificationOnCreate } from './processNotification';
export { checkExpiredMemberships } from "./membershipStatusNotifications";

View File

@ -0,0 +1,580 @@
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 findMembershipsExpiringIn10Days();
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 10 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 findMembershipsExpiringIn10Days(): 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 isExpiringIn10Days = await checkIfMembershipExpiringIn10Days(doc.id, data);
if (isExpiringIn10Days) {
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 10 days:", error);
throw error;
}
}
async function checkIfMembershipExpired(
membershipId: string,
data: MembershipData
): Promise<boolean> {
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 checkIfMembershipExpiringIn10Days(
membershipId: string,
data: MembershipData
): Promise<boolean> {
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 tenDaysFromNow = new Date();
tenDaysFromNow.setDate(now.getDate() + 10);
const isExpiringIn10Days = expiryDate > now && expiryDate <= tenDaysFromNow;
if (isExpiringIn10Days) {
logger.info(
`Membership ${membershipId} will expire on ${expiryDate.toISOString()} (within 10 days)`
);
}
return isExpiringIn10Days;
} catch (error) {
logger.error(
`Error checking 10-day expiry for membership ${membershipId}:`,
error
);
return false;
}
}
async function getPaymentsForMembership(
membershipId: string
): Promise<PaymentData[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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";
let daysUntilExpiry = 10;
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",
});
const now = new Date();
const timeDiff = expiryDate.getTime() - now.getTime();
daysUntilExpiry = Math.ceil(timeDiff / (1000 * 3600 * 24));
}
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: daysUntilExpiry,
},
});
logger.info(
`Expiring notification sent for membership ${membershipId} (expires on ${formattedDate}, ${daysUntilExpiry} days remaining)`
);
} catch (error) {
logger.error(`Error sending expiring notification for ${membershipId}:`, error);
}
}
async function getClientName(
membershipId: string,
clientId: string
): Promise<string> {
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<string> {
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<string> {
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";
}
}

View File

@ -1,34 +1,27 @@
import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { getLogger } from "../shared/config"; import { getLogger } from "../shared/config";
import { getAdmin } from "../shared/config"; import { getAdmin } from "../shared/config";
import * as admin from 'firebase-admin'; import * as admin from "firebase-admin";
const app = getAdmin(); const app = getAdmin();
const logger = getLogger(); const logger = getLogger();
interface NotificationData { interface NotificationData {
notificationSent?: boolean; senderId?: string;
userId?: string; recipientId?: string;
clientId?: string;
invitorId?: string;
phoneNumber?: string;
message?: string;
type?: string; type?: string;
status?: string; notificationSent?: boolean;
gymName?: string; timestamp?: admin.firestore.FieldValue;
trainerName?: string; read?: boolean;
membershipId?: string; data?: { [key: string]: any };
subscriptionName?: string;
name?: string;
clientEmail?: string;
invitationId?: string;
[key: string]: any;
} }
export const processNotificationOnCreate = onDocumentCreated({ export const processNotificationOnCreate = onDocumentCreated(
region: '#{SERVICES_RGN}#', {
document: 'notifications/{notificationId}' region: "#{SERVICES_RGN}#",
}, async (event) => { document: "notifications/{notificationId}",
},
async (event) => {
try { try {
const notificationSnapshot = event.data; const notificationSnapshot = event.data;
const notificationId = event.params.notificationId; const notificationId = event.params.notificationId;
@ -39,184 +32,386 @@ export const processNotificationOnCreate = onDocumentCreated({
} }
const notification = notificationSnapshot.data() as NotificationData; const notification = notificationSnapshot.data() as NotificationData;
if (notification.notificationSent === true) { if (notification.notificationSent === true) {
logger.info(`Notification ${notificationId} already sent, skipping.`); logger.info(`Notification ${notificationId} already sent, skipping.`);
return; return;
} }
const { fcmToken } = await getUserAndFCMToken(notification); logger.info(
`Processing notification ${notificationId} of type: ${notification.type}`
);
const { userId, fcmToken } = await getUserAndFCMToken(notification);
if (!fcmToken) { if (!fcmToken) {
logger.error(`FCM token not found for notification ${notificationId}`); logger.error(
await updateNotificationWithError(notificationId, 'FCM token not found for user'); `FCM token not found for notification ${notificationId}, user: ${userId}`
);
await updateNotificationWithError(
notificationId,
"FCM token not found for user"
);
return; return;
} }
const message = prepareNotificationMessage(notification, fcmToken); const message = prepareNotificationMessage(notification, fcmToken);
try { try {
const fcmResponse = await app.messaging().send({ const fcmResponse = await app.messaging().send(message);
...message,
token: fcmToken
});
logger.info(`FCM notification sent successfully: ${fcmResponse}`); logger.info(`FCM notification sent successfully: ${fcmResponse}`);
await markNotificationAsSent(notificationId); await markNotificationAsSent(notificationId);
} catch (error) { } catch (error) {
logger.error(`Error sending notification ${notificationId}:`, error); logger.error(`Error sending notification ${notificationId}:`, error);
await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error)); await updateNotificationWithError(
notificationId,
error instanceof Error ? error.message : String(error)
);
} }
} catch (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 }> { async function getUserAndFCMToken(
let userId: string | null = null; notification: NotificationData
): Promise<{ userId: string | null; fcmToken: string | null }> {
let targetUserId: string | null = null;
let fcmToken: string | null = null; let fcmToken: string | null = null;
if (notification.userId) { if (notification.recipientId) {
userId = notification.userId; targetUserId = notification.recipientId;
fcmToken = await getFCMTokenFromUserDoc(userId); logger.info(`Using top-level recipientId: ${targetUserId}`);
} else if (notification.clientId) { } else if (notification.data?.userId) {
userId = notification.clientId; targetUserId = notification.data.userId;
fcmToken = await getFCMTokenFromUserDoc(userId); logger.info(`Using data.userId: ${targetUserId}`);
} else if (notification.invitorId) { } else if (notification.data?.clientId) {
userId = notification.invitorId; targetUserId = notification.data.clientId;
fcmToken = await getFCMTokenFromUserDoc(userId); logger.info(`Using data.clientId: ${targetUserId}`);
} else if (notification.phoneNumber) { } 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 const userQuery = await app
.firestore() .firestore()
.collection('users') .collection("users")
.where('phoneNumber', '==', notification.phoneNumber) .where("phoneNumber", "==", notification.data.phoneNumber)
.limit(1) .limit(1)
.get(); .get();
if (!userQuery.empty) { if (!userQuery.empty) {
const userDoc = userQuery.docs[0]; const userDoc = userQuery.docs[0];
userId = userDoc.id; targetUserId = userDoc.id;
fcmToken = userDoc.data()?.fcmToken; 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<string | null> { async function getFCMTokenFromUserDoc(userId: string): Promise<string | null> {
const userDoc = await app.firestore().collection('users').doc(userId).get(); try {
return userDoc.exists ? userDoc.data()?.fcmToken : null; 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 { function prepareNotificationMessage(
let title = 'New Notification'; notification: NotificationData,
let body = notification.message || 'You have a new notification'; fcmToken: string
let data: Record<string, string> = { ): admin.messaging.TokenMessage {
type: notification.type || 'general', let title = notification.data?.title || "New Notification";
let body = notification.data?.message || "You have a new notification";
let fcmData: Record<string, string> = {
type: notification.type || "general",
notificationId: "notification_" + Date.now().toString(),
}; };
switch (notification.type) { if (notification.senderId) fcmData.senderId = notification.senderId;
case 'day_pass_entry': if (notification.recipientId) fcmData.recipientId = notification.recipientId;
const isAccepted = notification.status === 'ACCEPTED'; if (notification.read !== undefined) fcmData.read = String(notification.read);
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;
case 'trainer_assigned_to_client': if (notification.data) {
title = 'Trainer Assigned'; for (const key in notification.data) {
body = notification.message || `${notification.trainerName} has been assigned as your trainer`; if (Object.prototype.hasOwnProperty.call(notification.data, key)) {
data.trainerName = notification.trainerName || ''; const value = notification.data[key];
data.membershipId = notification.membershipId || ''; if (typeof value === "object" && value !== null) {
break; fcmData[key] = JSON.stringify(value);
} else {
case 'client_invitations': fcmData[key] = String(value);
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`); switch (notification.type) {
} else if (notification.phoneNumber) { case "trainer_response":
const invitationStatus = getInvitationStatus(notification.status); title =
title = getInvitationTitle(invitationStatus); notification.data?.title ||
body = notification.message || getInvitationBody(invitationStatus, notification.name); (notification.data?.status === "accepted"
data.status = invitationStatus; ? "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 membership subscription for ${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 membership subscription for ${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;
} }
data.gymName = notification.gymName || '';
data.clientEmail = notification.clientEmail || '';
data.clientName = notification.name || '';
data.invitationId = notification.invitationId || '';
data.subscriptionName = notification.subscriptionName || '';
break; break;
default: default:
logger.info(`Using default handling for notification type: ${notification.type}`); logger.info(
`Using default handling for notification type: ${notification.type}`
);
title =
notification.data?.title ||
(notification.type
? `${notification.type.replace("_", " ").toUpperCase()}`
: "Notification");
break; break;
} }
const notificationMessage: admin.messaging.Message = { const notificationMessage: admin.messaging.TokenMessage = {
notification: { title, body }, notification: { title, body },
data, data: fcmData,
android: { android: {
priority: 'high', priority: "high",
notification: { notification: {
channelId: 'notifications_channel', channelId: "notifications_channel",
priority: 'high', priority: "high",
defaultSound: true, defaultSound: true,
defaultVibrateTimings: true, defaultVibrateTimings: true,
icon: '@mipmap/ic_launcher', icon: "@mipmap/ic_launcher",
clickAction: 'FLUTTER_NOTIFICATION_CLICK', clickAction: "FLUTTER_NOTIFICATION_CLICK",
}, },
}, },
apns: { apns: {
payload: { payload: {
aps: { aps: {
sound: 'default', sound: "default",
badge: 1, badge: 1,
}, },
}, },
}, },
token: fcmToken, token: fcmToken,
}; };
logger.info(`Prepared notification: ${title} - ${body}`);
return notificationMessage; return notificationMessage;
} }
function getInvitationStatus(status?: string): string { function getInvitationStatus(status?: string): string {
if (status === 'ACCEPTED') return 'accepted'; if (status === "ACCEPTED") return "accepted";
if (status === 'REJECTED') return 'rejected'; if (status === "REJECTED") return "rejected";
if (status === 'PENDING') return 'pending'; if (status === "PENDING") return "pending";
return 'unknown'; return "unknown";
} }
function getInvitationTitle(status: string): string { function getInvitationTitle(status: string): string {
switch (status) { switch (status) {
case 'accepted': return 'Invitation Accepted'; case "accepted":
case 'rejected': return 'Invitation Rejected'; return "Invitation Accepted";
case 'pending': return 'New Invitation'; case "rejected":
default: return 'Invitation Update'; return "Invitation Rejected";
case "pending":
return "New Invitation";
default:
return "Invitation Update";
} }
} }
function getInvitationBody(status: string, name?: string): string { function getInvitationBody(status: string, name?: string): string {
switch (status) { switch (status) {
case 'accepted': return `You have accepted the invitation from ${name}`; case "accepted":
case 'rejected': return `You have rejected the invitation from ${name}`; return `You have accepted the invitation from ${name}`;
case 'pending': return `You have a new invitation pending from ${name}`; case "rejected":
default: return 'There is an update to your invitation'; 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<void> { async function markNotificationAsSent(notificationId: string): Promise<void> {
await app.firestore().collection('notifications').doc(notificationId).update({ try {
await app
.firestore()
.collection("notifications")
.doc(notificationId)
.update({
notificationSent: true, 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<void> { async function updateNotificationWithError(
await app.firestore().collection('notifications').doc(notificationId).update({ notificationId: string,
error: string
): Promise<void> {
try {
await app
.firestore()
.collection("notifications")
.doc(notificationId)
.update({
notificationError: error, 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}`);
}
} }