Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 5m14s
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 5m14s
This commit is contained in:
commit
8b308fb9a6
@ -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",
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 || ''
|
||||||
|
|||||||
@ -7,13 +7,13 @@ setGlobalOptions({
|
|||||||
minInstances: 0,
|
minInstances: 0,
|
||||||
maxInstances: 10,
|
maxInstances: 10,
|
||||||
concurrency: 80
|
concurrency: 80
|
||||||
});
|
});
|
||||||
|
|
||||||
export * from './shared/config';
|
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';
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export { processNotificationOnCreate } from './processNotification';
|
export { processNotificationOnCreate } from './processNotification';
|
||||||
|
export { checkExpiredMemberships } from "./membershipStatusNotifications";
|
||||||
|
|
||||||
|
|||||||
575
functions/src/notifications/membershipStatusNotifications.ts
Normal file
575
functions/src/notifications/membershipStatusNotifications.ts
Normal file
@ -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<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 checkIfMembershipExpiringIn2Days(
|
||||||
|
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 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<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";
|
||||||
|
|
||||||
|
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<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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,222 +1,417 @@
|
|||||||
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;
|
type?: string;
|
||||||
invitorId?: string;
|
notificationSent?: boolean;
|
||||||
phoneNumber?: string;
|
timestamp?: admin.firestore.FieldValue;
|
||||||
message?: string;
|
read?: boolean;
|
||||||
type?: string;
|
data?: { [key: string]: any };
|
||||||
status?: string;
|
|
||||||
gymName?: string;
|
|
||||||
trainerName?: string;
|
|
||||||
membershipId?: string;
|
|
||||||
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;
|
||||||
|
|
||||||
if (!notificationSnapshot) {
|
if (!notificationSnapshot) {
|
||||||
logger.error(`No data found for notification ${notificationId}`);
|
logger.error(`No data found for notification ${notificationId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = notificationSnapshot.data() as NotificationData;
|
const notification = notificationSnapshot.data() as NotificationData;
|
||||||
if (notification.notificationSent === true) {
|
|
||||||
logger.info(`Notification ${notificationId} already sent, skipping.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fcmToken } = await getUserAndFCMToken(notification);
|
if (notification.notificationSent === true) {
|
||||||
if (!fcmToken) {
|
logger.info(`Notification ${notificationId} already sent, skipping.`);
|
||||||
logger.error(`FCM token not found for notification ${notificationId}`);
|
return;
|
||||||
await updateNotificationWithError(notificationId, 'FCM token not found for user');
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = prepareNotificationMessage(notification, fcmToken);
|
logger.info(
|
||||||
try {
|
`Processing notification ${notificationId} of type: ${notification.type}`
|
||||||
const fcmResponse = await app.messaging().send({
|
);
|
||||||
...message,
|
|
||||||
token: fcmToken
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`FCM notification sent successfully: ${fcmResponse}`);
|
const { userId, fcmToken } = await getUserAndFCMToken(notification);
|
||||||
await markNotificationAsSent(notificationId);
|
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) {
|
const message = prepareNotificationMessage(notification, fcmToken);
|
||||||
logger.error(`Error sending notification ${notificationId}:`, error);
|
try {
|
||||||
await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error));
|
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) {
|
} 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
|
||||||
let fcmToken: string | null = null;
|
): Promise<{ userId: string | null; fcmToken: string | null }> {
|
||||||
|
let targetUserId: 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) {
|
||||||
const userQuery = await app
|
targetUserId = notification.data.invitorId;
|
||||||
.firestore()
|
logger.info(`Using data.invitorId: ${targetUserId}`);
|
||||||
.collection('users')
|
} else if (notification.data?.phoneNumber) {
|
||||||
.where('phoneNumber', '==', notification.phoneNumber)
|
logger.info(
|
||||||
.limit(1)
|
`Looking up user by phone number from data: ${notification.data.phoneNumber}`
|
||||||
.get();
|
);
|
||||||
|
const userQuery = await app
|
||||||
|
.firestore()
|
||||||
|
.collection("users")
|
||||||
|
.where("phoneNumber", "==", notification.data.phoneNumber)
|
||||||
|
.limit(1)
|
||||||
|
.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";
|
||||||
|
|
||||||
switch (notification.type) {
|
let fcmData: Record<string, string> = {
|
||||||
case 'day_pass_entry':
|
type: notification.type || "general",
|
||||||
const isAccepted = notification.status === 'ACCEPTED';
|
notificationId: "notification_" + Date.now().toString(),
|
||||||
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.senderId) fcmData.senderId = notification.senderId;
|
||||||
title = 'Trainer Assigned';
|
if (notification.recipientId) fcmData.recipientId = notification.recipientId;
|
||||||
body = notification.message || `${notification.trainerName} has been assigned as your trainer`;
|
if (notification.read !== undefined) fcmData.read = String(notification.read);
|
||||||
data.trainerName = notification.trainerName || '';
|
|
||||||
data.membershipId = notification.membershipId || '';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'client_invitations':
|
if (notification.data) {
|
||||||
if (notification.userId || notification.invitorId) {
|
for (const key in notification.data) {
|
||||||
const isAccept = notification.status === 'ACCEPTED';
|
if (Object.prototype.hasOwnProperty.call(notification.data, key)) {
|
||||||
title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected';
|
const value = notification.data[key];
|
||||||
body = notification.message || (isAccept ?
|
if (typeof value === "object" && value !== null) {
|
||||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` :
|
fcmData[key] = JSON.stringify(value);
|
||||||
`The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`);
|
} else {
|
||||||
} else if (notification.phoneNumber) {
|
fcmData[key] = String(value);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const notificationMessage: admin.messaging.Message = {
|
switch (notification.type) {
|
||||||
notification: { title, body },
|
case "trainer_response":
|
||||||
data,
|
title =
|
||||||
android: {
|
notification.data?.title ||
|
||||||
priority: 'high',
|
(notification.data?.status === "accepted"
|
||||||
notification: {
|
? "Trainer Request Accepted"
|
||||||
channelId: 'notifications_channel',
|
: "Trainer Request Update");
|
||||||
priority: 'high',
|
body =
|
||||||
defaultSound: true,
|
notification.data?.message ||
|
||||||
defaultVibrateTimings: true,
|
`${
|
||||||
icon: '@mipmap/ic_launcher',
|
notification.data?.trainerName
|
||||||
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
|
} 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: {
|
token: fcmToken,
|
||||||
sound: 'default',
|
};
|
||||||
badge: 1,
|
|
||||||
},
|
logger.info(`Prepared notification: ${title} - ${body}`);
|
||||||
},
|
return notificationMessage;
|
||||||
},
|
|
||||||
token: fcmToken,
|
|
||||||
};
|
|
||||||
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user