expiry-notification #76
@ -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,processMembershipStatusChange } 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,3 +1,3 @@
|
|||||||
export { processNotificationOnCreate } from './processNotification';
|
export { processNotificationOnCreate } from './processNotification';
|
||||||
export { processMembershipStatusChange } from "./membershipStatusNotifications";
|
export { checkExpiredMemberships } from "./membershipStatusNotifications";
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { onDocumentUpdated } from "firebase-functions/v2/firestore";
|
import { onSchedule } from "firebase-functions/v2/scheduler";
|
||||||
import { getLogger } from "../shared/config";
|
import { getLogger, 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();
|
||||||
@ -14,65 +13,188 @@ interface MembershipData {
|
|||||||
subscription?: {
|
subscription?: {
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
frequency: string;
|
||||||
|
assignedAt: admin.firestore.Timestamp;
|
||||||
};
|
};
|
||||||
createdAt: admin.firestore.FieldValue;
|
|
||||||
updatedAt: admin.firestore.FieldValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientFields {
|
interface ClientFields {
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | undefined;
|
||||||
'first-name'?: string;
|
"first-name"?: string;
|
||||||
|
"last-name"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processMembershipStatusChange = onDocumentUpdated(
|
export const checkExpiredMemberships = onSchedule(
|
||||||
{
|
{
|
||||||
|
schedule: "0 8,14,20 * * *",
|
||||||
|
timeZone: "UTC",
|
||||||
region: "#{SERVICES_RGN}#",
|
region: "#{SERVICES_RGN}#",
|
||||||
document: "memberships/{membershipId}",
|
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
try {
|
logger.info("Starting scheduled membership expiry check...");
|
||||||
const membershipId = event.params.membershipId;
|
|
||||||
const beforeData = event.data?.before?.data() as MembershipData;
|
|
||||||
const afterData = event.data?.after?.data() as MembershipData;
|
|
||||||
|
|
||||||
if (!beforeData || !afterData) {
|
try {
|
||||||
logger.error(`No data found for membership ${membershipId}`);
|
const expiredMemberships = await findExpiredMemberships();
|
||||||
|
|
||||||
|
if (expiredMemberships.length === 0) {
|
||||||
|
logger.info("No expired memberships found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if status changed to EXPIRED
|
logger.info(
|
||||||
if (beforeData.status !== "EXPIRED" && afterData.status === "EXPIRED") {
|
`Found ${expiredMemberships.length} expired memberships to process.`
|
||||||
logger.info(`Membership ${membershipId} status changed to EXPIRED. Sending notification...`);
|
);
|
||||||
|
|
||||||
await sendPlanExpiredNotification(membershipId, afterData);
|
const results = await Promise.allSettled(
|
||||||
}
|
expiredMemberships.map((m) => processExpiredMembership(m.id, m.data))
|
||||||
|
);
|
||||||
|
|
||||||
|
const successful = results.filter((r) => r.status === "fulfilled").length;
|
||||||
|
const failed = results.filter((r) => r.status === "rejected").length;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Completed processing. Success: ${successful}, Failed: ${failed}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error processing membership status change:", error);
|
logger.error("Error in scheduled membership expiry check:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function findExpiredMemberships(): Promise<
|
||||||
|
Array<{ id: string; data: MembershipData }>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const snapshot = await app
|
||||||
|
.firestore()
|
||||||
|
.collection("memberships")
|
||||||
|
.where("status", "==", "ACTIVE")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const expired: Array<{ id: string; data: MembershipData }> = [];
|
||||||
|
|
||||||
|
snapshot.docs.forEach((doc) => {
|
||||||
|
const data = doc.data() as MembershipData;
|
||||||
|
const isExpired = checkIfMembershipExpired(data);
|
||||||
|
if (isExpired) expired.push({ id: doc.id, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
return expired;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error finding expired memberships:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIfMembershipExpired(data: MembershipData): boolean {
|
||||||
|
try {
|
||||||
|
// Critical update: Use the assignedAt timestamp from the subscription object
|
||||||
|
if (
|
||||||
|
!data.subscription ||
|
||||||
|
!data.subscription.duration ||
|
||||||
|
!data.subscription.frequency ||
|
||||||
|
!data.subscription.assignedAt
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping expiry check for membership ${data.id} with missing subscription data.`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = (
|
||||||
|
data.subscription.assignedAt as admin.firestore.Timestamp
|
||||||
|
).toDate();
|
||||||
|
const expiryDate = calculateExpiryDate(
|
||||||
|
startDate,
|
||||||
|
data.subscription.duration,
|
||||||
|
data.subscription.frequency
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return now > expiryDate;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error checking expiry for membership ${data.id}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateExpiryDate(
|
||||||
|
startDate: Date,
|
||||||
|
duration: number,
|
||||||
|
frequency: string
|
||||||
|
): Date {
|
||||||
|
const expiry = new Date(startDate);
|
||||||
|
switch (frequency.toLowerCase()) {
|
||||||
|
case "monthly":
|
||||||
|
expiry.setMonth(expiry.getMonth() + duration);
|
||||||
|
break;
|
||||||
|
case "quarterly":
|
||||||
|
expiry.setMonth(expiry.getMonth() + 3 * duration);
|
||||||
|
break;
|
||||||
|
case "half-yearly":
|
||||||
|
expiry.setMonth(expiry.getMonth() + 6 * duration);
|
||||||
|
break;
|
||||||
|
case "yearly":
|
||||||
|
expiry.setFullYear(expiry.getFullYear() + duration);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
expiry.setMonth(expiry.getMonth() + duration);
|
||||||
|
}
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processExpiredMembership(
|
||||||
|
membershipId: string,
|
||||||
|
membershipData: MembershipData
|
||||||
|
): Promise<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 sendPlanExpiredNotification(
|
async function sendPlanExpiredNotification(
|
||||||
membershipId: string,
|
membershipId: string,
|
||||||
membershipData: MembershipData
|
membershipData: MembershipData
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const clientName = await getClientName(membershipId, membershipData.userId);
|
const clientName = await getClientName(membershipId, membershipData.userId);
|
||||||
|
|
||||||
const gymOwnerId = await getGymOwnerId(membershipData.gymId);
|
const gymOwnerId = await getGymOwnerId(membershipData.gymId);
|
||||||
|
|
||||||
const gymName = await getGymName(membershipData.gymId);
|
const gymName = await getGymName(membershipData.gymId);
|
||||||
|
|
||||||
const expiryDate = new Date();
|
const existing = await app
|
||||||
const formattedExpiryDate = expiryDate.toLocaleDateString('en-US', {
|
.firestore()
|
||||||
year: 'numeric',
|
.collection("notifications")
|
||||||
month: 'long',
|
.where("type", "==", "plan_expired")
|
||||||
day: 'numeric'
|
.where("data.membershipId", "==", membershipId)
|
||||||
});
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
// Create notification document
|
if (!existing.empty) {
|
||||||
const notificationData = {
|
logger.info(`Notification already sent for ${membershipId}, skipping...`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryDate = membershipData.subscription?.assignedAt?.toDate();
|
||||||
|
const formattedDate = expiryDate
|
||||||
|
? expiryDate.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "Unknown Date";
|
||||||
|
|
||||||
|
await app
|
||||||
|
.firestore()
|
||||||
|
.collection("notifications")
|
||||||
|
.add({
|
||||||
senderId: "system",
|
senderId: "system",
|
||||||
recipientId: gymOwnerId,
|
recipientId: gymOwnerId,
|
||||||
type: "plan_expired",
|
type: "plan_expired",
|
||||||
@ -81,76 +203,56 @@ async function sendPlanExpiredNotification(
|
|||||||
read: false,
|
read: false,
|
||||||
data: {
|
data: {
|
||||||
title: "Plan Expired",
|
title: "Plan Expired",
|
||||||
message: `The plan ${membershipData.subscription?.name || 'Unknown Plan'} for client ${clientName} has expired.`,
|
message: `The plan ${
|
||||||
planName: membershipData.subscription?.name || 'Unknown Plan',
|
membershipData.subscription?.name || "Unknown Plan"
|
||||||
clientName: clientName,
|
} for client ${clientName} has expired.`,
|
||||||
membershipId: membershipId,
|
planName: membershipData.subscription?.name || "Unknown Plan",
|
||||||
gymName: gymName,
|
clientName,
|
||||||
formattedExpiryDate: formattedExpiryDate,
|
membershipId,
|
||||||
expiryDate: admin.firestore.Timestamp.fromDate(expiryDate),
|
gymName,
|
||||||
|
formattedExpiryDate: formattedDate,
|
||||||
|
expiryDate:
|
||||||
|
membershipData.subscription?.assignedAt ||
|
||||||
|
admin.firestore.Timestamp.fromDate(new Date()),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const notificationRef = await app
|
logger.info(
|
||||||
.firestore()
|
`Notification sent for expired plan of membership ${membershipId}`
|
||||||
.collection("notifications")
|
);
|
||||||
.add(notificationData);
|
|
||||||
|
|
||||||
logger.info(`Plan expired notification created with ID: ${notificationRef.id} for gym owner ${gymOwnerId}, membership ${membershipId}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error sending plan expired notification for membership ${membershipId}:`, error);
|
logger.error(`Error sending notification for ${membershipId}:`, error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClientName(membershipId: string, clientId: string): Promise<string> {
|
async function getClientName(
|
||||||
|
membershipId: string,
|
||||||
|
clientId: string
|
||||||
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const clientProfileDoc = await app
|
const doc = await app
|
||||||
.firestore()
|
.firestore()
|
||||||
.collection("client_profiles")
|
.collection("client_profiles")
|
||||||
.doc(clientId)
|
.doc(clientId)
|
||||||
.get();
|
.get();
|
||||||
|
if (!doc.exists) return "Unknown Client";
|
||||||
|
|
||||||
if (!clientProfileDoc.exists) {
|
const fields = doc.data()?.fields as ClientFields;
|
||||||
logger.warn(`Client profile not found for clientId: ${clientId}`);
|
const firstName = fields?.["first-name"] || "";
|
||||||
return "Unknown Client";
|
const lastName = fields?.["last-name"] || "";
|
||||||
}
|
return `${firstName} ${lastName}`.trim() || "Unknown Client";
|
||||||
|
|
||||||
const clientData = clientProfileDoc.data();
|
|
||||||
const fields = clientData?.fields as ClientFields;
|
|
||||||
|
|
||||||
if (fields) {
|
|
||||||
const firstName = fields['first-name'] || '';
|
|
||||||
return firstName || "Unknown Client";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Unknown Client";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error getting client name for membership ${membershipId}:`, error);
|
logger.error(`Error getting client name for ${membershipId}:`, error);
|
||||||
return "Unknown Client";
|
return "Unknown Client";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGymOwnerId(gymId: string): Promise<string> {
|
async function getGymOwnerId(gymId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const gymDoc = await app
|
const doc = await app.firestore().collection("gyms").doc(gymId).get();
|
||||||
.firestore()
|
const data = doc.data();
|
||||||
.collection("gyms")
|
if (!data?.userId) throw new Error(`userId not found for gym ${gymId}`);
|
||||||
.doc(gymId)
|
return data.userId;
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!gymDoc.exists) {
|
|
||||||
throw new Error(`Gym not found: ${gymId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gymData = gymDoc.data();
|
|
||||||
const userId = gymData?.userId;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error(`No userId found in gym document: ${gymId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return userId;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error getting gym owner ID for gym ${gymId}:`, error);
|
logger.error(`Error getting gym owner ID for gym ${gymId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -159,18 +261,9 @@ async function getGymOwnerId(gymId: string): Promise<string> {
|
|||||||
|
|
||||||
async function getGymName(gymId: string): Promise<string> {
|
async function getGymName(gymId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const gymDoc = await app
|
const doc = await app.firestore().collection("gyms").doc(gymId).get();
|
||||||
.firestore()
|
const data = doc.data();
|
||||||
.collection("gyms")
|
return data?.name || data?.gymName || "Unknown Gym";
|
||||||
.doc(gymId)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!gymDoc.exists) {
|
|
||||||
return "Unknown Gym";
|
|
||||||
}
|
|
||||||
|
|
||||||
const gymData = gymDoc.data();
|
|
||||||
return gymData?.name || gymData?.gymName || "Unknown Gym";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error getting gym name for gym ${gymId}:`, error);
|
logger.error(`Error getting gym name for gym ${gymId}:`, error);
|
||||||
return "Unknown Gym";
|
return "Unknown Gym";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user