diff --git a/firestore.indexes.json b/firestore.indexes.json index 4e84252..2016f51 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -166,6 +166,20 @@ } ] }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "recipientId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "workout_logs", "queryScope": "COLLECTION", diff --git a/functions/src/notifications/membershipStatusNotifications.ts b/functions/src/notifications/membershipStatusNotifications.ts index 3b0b61b..7dd0541 100644 --- a/functions/src/notifications/membershipStatusNotifications.ts +++ b/functions/src/notifications/membershipStatusNotifications.ts @@ -33,6 +33,15 @@ interface PaymentData { discount?: number; } +interface PersonalTrainerAssign { + id: string; + ownerId: string; + trainerId?: string; + clientId: string; + membershipId: string; + gymId: string; +} + export const checkExpiredMemberships = onSchedule( { schedule: "*/5 * * * *", @@ -63,10 +72,18 @@ export const checkExpiredMemberships = onSchedule( 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; + 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}` @@ -139,7 +156,10 @@ async function findMembershipsExpiringIn10Days(): Promise< const batchResults = await Promise.allSettled( batch.map(async (doc) => { const data = doc.data() as MembershipData; - const isExpiringIn10Days = await checkIfMembershipExpiringIn10Days(doc.id, data); + const isExpiringIn10Days = await checkIfMembershipExpiringIn10Days( + doc.id, + data + ); if (isExpiringIn10Days) { return { id: doc.id, data }; } @@ -241,11 +261,11 @@ async function checkIfMembershipExpiringIn10Days( 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) { @@ -356,6 +376,66 @@ function calculateRenewalDateFromPayment( return renewalDate; } +async function getTrainerAssignmentsForMembership( + membershipId: string +): Promise { + try { + const querySnapshot = await app + .firestore() + .collection("personal_trainer_assignments") + .where("membershipId", "==", membershipId) + .get(); + + return querySnapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })) as PersonalTrainerAssign[]; + } catch (error) { + logger.error( + `Error getting trainer assignments for membership ${membershipId}:`, + error + ); + return []; + } +} + +async function getTrainerName(trainerId: string): Promise { + try { + const doc = await app + .firestore() + .collection("trainer_profiles") + .doc(trainerId) + .get(); + + if (!doc.exists) { + const userDoc = await app + .firestore() + .collection("users") + .doc(trainerId) + .get(); + + if (userDoc.exists) { + const userData = userDoc.data(); + return userData?.name || userData?.displayName || "Unknown Trainer"; + } + return "Unknown Trainer"; + } + + const data = doc.data(); + const fields = data?.fields; + if (fields) { + const firstName = fields["first-name"] || ""; + const lastName = fields["last-name"] || ""; + return `${firstName} ${lastName}`.trim() || "Unknown Trainer"; + } + + return data?.name || data?.displayName || "Unknown Trainer"; + } catch (error) { + logger.error(`Error getting trainer name for ${trainerId}:`, error); + return "Unknown Trainer"; + } +} + async function processExpiredMembership( membershipId: string, membershipData: MembershipData @@ -367,7 +447,10 @@ async function processExpiredMembership( }); logger.info(`Marked membership ${membershipId} as EXPIRED`); + await sendPlanExpiredNotification(membershipId, membershipData); + + await sendTrainerNotifications(membershipId, membershipData, "expired"); } catch (error) { logger.error(`Error processing membership ${membershipId}:`, error); } @@ -379,9 +462,130 @@ async function processExpiringMembership( ): Promise { try { logger.info(`Processing expiring membership ${membershipId}`); + await sendPlanExpiringNotification(membershipId, membershipData); + + await sendTrainerNotifications(membershipId, membershipData, "expiring"); } catch (error) { - logger.error(`Error processing expiring membership ${membershipId}:`, error); + logger.error( + `Error processing expiring membership ${membershipId}:`, + error + ); + } +} + +async function sendTrainerNotifications( + membershipId: string, + membershipData: MembershipData, + notificationType: "expired" | "expiring" +): Promise { + try { + const trainerAssignments = await getTrainerAssignmentsForMembership(membershipId); + + if (trainerAssignments.length === 0) { + logger.info(`No trainer assignments found for membership ${membershipId}`); + return; + } + + const clientName = await getClientName(membershipId, membershipData.userId); + const gymName = await getGymName(membershipData.gymId); + + let expiryDate: Date | undefined; + let formattedDate = "Unknown Date"; + let daysUntilExpiry = 0; + + 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", + }); + + if (notificationType === "expiring") { + const now = new Date(); + const timeDiff = expiryDate.getTime() - now.getTime(); + daysUntilExpiry = Math.ceil(timeDiff / (1000 * 3600 * 24)); + } + } + + for (const assignment of trainerAssignments) { + if (!assignment.trainerId) continue; + + try { + const trainerName = await getTrainerName(assignment.trainerId); + + const notifType = notificationType === "expired" ? "trainer_client_plan_expired" : "trainer_client_plan_expiring"; + const existing = await app + .firestore() + .collection("notifications") + .where("type", "==", notifType) + .where("recipientId", "==", assignment.trainerId) + .where("data.membershipId", "==", membershipId) + .where( + "data.expiryDate", + "==", + expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()) + ) + .limit(1) + .get(); + + if (!existing.empty) { + logger.info( + `${notificationType} notification already sent to trainer ${assignment.trainerId} for membership ${membershipId}, skipping...` + ); + continue; + } + + const notificationData: any = { + senderId: "system", + recipientId: assignment.trainerId, + type: notifType, + notificationSent: false, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + read: false, + readBy: [], + data: { + planName: membershipData.subscription?.name || "Unknown Plan", + clientName, + membershipId, + gymName, + assignmentId: assignment.id, + formattedExpiryDate: formattedDate, + expiryDate: expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()), + }, + }; + + if (notificationType === "expiring") { + notificationData.data.daysUntilExpiry = daysUntilExpiry; + } + + await app.firestore().collection("notifications").add(notificationData); + + logger.info( + `${notificationType} notification sent to trainer ${assignment.trainerId} (${trainerName}) for client ${clientName}'s membership ${membershipId}` + ); + } catch (trainerError) { + logger.error( + `Error sending notification to trainer ${assignment.trainerId} for membership ${membershipId}:`, + trainerError + ); + } + } + } catch (error) { + logger.error( + `Error sending trainer notifications for membership ${membershipId}:`, + error + ); } } @@ -394,19 +598,6 @@ async function sendPlanExpiredNotification( 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"; @@ -422,7 +613,26 @@ async function sendPlanExpiredNotification( month: "long", day: "numeric", }); - + } + + const existing = await app + .firestore() + .collection("notifications") + .where("type", "==", "plan_expired") + .where("data.membershipId", "==", membershipId) + .where( + "data.expiryDate", + "==", + expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()) + ) + .limit(1) + .get(); + + if (!existing.empty) { + logger.info(`Notification already sent for ${membershipId}, skipping...`); + return; } await app @@ -435,13 +645,13 @@ async function sendPlanExpiredNotification( notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, - readBy: [], + readBy: [], data: { planName: membershipData.subscription?.name || "Unknown Plan", clientName, membershipId, gymName, - ownerId: gymOwnerId, + ownerId: gymOwnerId, formattedExpiryDate: formattedDate, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) @@ -466,19 +676,6 @@ async function sendPlanExpiringNotification( 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; @@ -495,12 +692,34 @@ async function sendPlanExpiringNotification( month: "long", day: "numeric", }); - + const now = new Date(); const timeDiff = expiryDate.getTime() - now.getTime(); daysUntilExpiry = Math.ceil(timeDiff / (1000 * 3600 * 24)); } + const existing = await app + .firestore() + .collection("notifications") + .where("type", "==", "plan_expiring_soon") + .where("data.membershipId", "==", membershipId) + .where( + "data.expiryDate", + "==", + expiryDate + ? admin.firestore.Timestamp.fromDate(expiryDate) + : admin.firestore.Timestamp.fromDate(new Date()) + ) + .limit(1) + .get(); + + if (!existing.empty) { + logger.info( + `Expiring notification already sent for ${membershipId}, skipping...` + ); + return; + } + await app .firestore() .collection("notifications") @@ -511,13 +730,13 @@ async function sendPlanExpiringNotification( notificationSent: false, timestamp: admin.firestore.FieldValue.serverTimestamp(), read: false, - readBy: [], + readBy: [], data: { planName: membershipData.subscription?.name || "Unknown Plan", clientName, membershipId, gymName, - ownerId: gymOwnerId, + ownerId: gymOwnerId, formattedExpiryDate: formattedDate, expiryDate: expiryDate ? admin.firestore.Timestamp.fromDate(expiryDate) @@ -530,7 +749,10 @@ async function sendPlanExpiringNotification( `Expiring notification sent for membership ${membershipId} (expires on ${formattedDate}, ${daysUntilExpiry} days remaining)` ); } catch (error) { - logger.error(`Error sending expiring notification for ${membershipId}:`, error); + logger.error( + `Error sending expiring notification for ${membershipId}:`, + error + ); } } diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index 3cc4811..d8edce7 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -240,14 +240,28 @@ function prepareNotificationMessage( title = notification.data?.title || "Plan Expired"; body = notification.data?.message || - `${notification.data?.clientName}'s membership subscription for ${notification.data?.planName} has expired.`; + `${notification.data?.clientName}'s membership 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}.`; + `${notification.data?.clientName}'s membership for ${notification.data?.planName} will expire on ${notification.data?.formattedExpiryDate}.`; + break; + + case "trainer_client_plan_expired": + title = notification.data?.title || "Client Plan Expired"; + body = + notification.data?.message || + `${notification.data?.clientName}'s membership for ${notification.data?.planName} has expired.`; + break; + + case "trainer_client_plan_expiring": + title = notification.data?.title || "Client Plan Expiring Soon"; + body = + notification.data?.message || + `${notification.data?.clientName}'s membership for ${notification.data?.planName} will expire on ${notification.data?.formattedExpiryDate}.`; break; case "schedule_update":