diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index bb0887b..3f6e564 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -44,25 +44,32 @@ export const checkExpiredMemberships = onSchedule( try { const expiredMemberships = await findExpiredMemberships(); + const expiringMemberships = await findMembershipsExpiringIn2Days(); - if (expiredMemberships.length === 0) { - logger.info("No expired memberships found."); + if (expiredMemberships.length === 0 && expiringMemberships.length === 0) { + logger.info("No expired or expiring memberships found."); return; } logger.info( - `Found ${expiredMemberships.length} expired memberships to process.` + `Found ${expiredMemberships.length} expired memberships and ${expiringMemberships.length} memberships expiring in 2 days to process.` ); - const results = await Promise.allSettled( + const expiredResults = 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; + 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. Success: ${successful}, Failed: ${failed}` + `Completed processing. Expired - Success: ${expiredSuccessful}, Failed: ${expiredFailed}. Expiring - Success: ${expiringSuccessful}, Failed: ${expiringFailed}` ); } catch (error) { logger.error("Error in scheduled membership expiry check:", error); @@ -112,6 +119,48 @@ async function findExpiredMemberships(): Promise< } } +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 @@ -165,6 +214,56 @@ async function checkIfMembershipExpired( } } +async function checkIfMembershipExpiringIn2Days( + membershipId: string, + data: MembershipData +): Promise { + try { + if (!data.subscription || !data.subscription.frequency) { + logger.warn( + `Skipping expiry check for membership ${membershipId} with missing subscription data.` + ); + return false; + } + + const payments = await getPaymentsForMembership(membershipId); + if (payments.length === 0) { + logger.warn( + `No payments found for membership ${membershipId}, cannot determine expiry` + ); + return false; + } + + const latestPayment = payments[0]; + const startDate = latestPayment.dateTimestamp; + + const expiryDate = calculateExpiryDate( + startDate, + data.subscription.frequency + ); + + const now = new Date(); + const twoDaysFromNow = new Date(); + twoDaysFromNow.setDate(now.getDate() + 2); + + const isExpiringIn2Days = expiryDate > now && expiryDate <= twoDaysFromNow; + + if (isExpiringIn2Days) { + logger.info( + `Membership ${membershipId} will expire on ${expiryDate.toISOString()} (within 2 days)` + ); + } + + return isExpiringIn2Days; + } catch (error) { + logger.error( + `Error checking 2-day expiry for membership ${membershipId}:`, + error + ); + return false; + } +} + async function getPaymentsForMembership( membershipId: string ): Promise { @@ -274,6 +373,17 @@ async function processExpiredMembership( } } +async function processExpiringMembership( + membershipId: string, + membershipData: MembershipData +): Promise { + try { + logger.info(`Processing expiring membership ${membershipId}`); + await sendPlanExpiringNotification(membershipId, membershipData); + } catch (error) { + logger.error(`Error processing expiring membership ${membershipId}:`, error); + } +} async function sendPlanExpiredNotification( membershipId: string, @@ -347,6 +457,78 @@ async function sendPlanExpiredNotification( } } +async function sendPlanExpiringNotification( + membershipId: string, + membershipData: MembershipData +): Promise { + try { + const clientName = await getClientName(membershipId, membershipData.userId); + const gymOwnerId = await getGymOwnerId(membershipData.gymId); + const gymName = await getGymName(membershipData.gymId); + + const existing = await app + .firestore() + .collection("notifications") + .where("type", "==", "plan_expiring_soon") + .where("data.membershipId", "==", membershipId) + .limit(1) + .get(); + + if (!existing.empty) { + logger.info(`Expiring notification already sent for ${membershipId}, skipping...`); + return; + } + + let expiryDate: Date | undefined; + let formattedDate = "Unknown Date"; + + const payments = await getPaymentsForMembership(membershipId); + if (payments.length > 0) { + const latestPayment = payments[0]; + expiryDate = calculateRenewalDateFromPayment( + membershipData.subscription, + latestPayment.dateTimestamp + ); + formattedDate = expiryDate.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + } + + await app + .firestore() + .collection("notifications") + .add({ + senderId: "system", + recipientId: gymOwnerId, + type: "plan_expiring_soon", + notificationSent: false, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + readBy: [], + data: { + planName: membershipData.subscription?.name || "Unknown Plan", + clientName, + membershipId, + gymName, + ownerId: gymOwnerId, + formattedExpiryDate: formattedDate, + expiryDate: expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()), + daysUntilExpiry: 2, + }, + }); + + logger.info( + `Expiring notification sent for membership ${membershipId} (expires on ${formattedDate})` + ); + } catch (error) { + logger.error(`Error sending expiring notification for ${membershipId}:`, error); + } +} + async function getClientName( membershipId: string, clientId: string @@ -390,4 +572,4 @@ async function getGymName(gymId: string): Promise { logger.error(`Error getting gym name for gym ${gymId}:`, error); return "Unknown Gym"; } -} +} \ No newline at end of file diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index 37b6865..418476e 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -240,7 +240,14 @@ function prepareNotificationMessage( title = notification.data?.title || "Plan Expired"; body = notification.data?.message || - `${notification.data?.clientName}/s subscription for plan ${notification.data?.planName} has expired.`; + `${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":