diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts index b995b87..ada5636 100644 --- a/functions/src/notifications/processNotification.ts +++ b/functions/src/notifications/processNotification.ts @@ -1,222 +1,394 @@ import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { getLogger } from "../shared/config"; import { getAdmin } from "../shared/config"; -import * as admin from 'firebase-admin'; +import * as admin from "firebase-admin"; const app = getAdmin(); const logger = getLogger(); interface NotificationData { - notificationSent?: boolean; - userId?: string; - clientId?: string; - invitorId?: string; - phoneNumber?: string; - message?: string; - type?: string; - status?: string; - gymName?: string; - trainerName?: string; - membershipId?: string; - subscriptionName?: string; - name?: string; - clientEmail?: string; - invitationId?: string; - [key: string]: any; + senderId?: string; + recipientId?: string; + notificationSent?: boolean; + userId?: string; + clientId?: string; + invitorId?: string; + phoneNumber?: string; + message?: string; + type?: string; + status?: string; + gymName?: string; + trainerName?: string; + membershipId?: string; + subscriptionName?: string; + name?: string; + clientEmail?: string; + invitationId?: string; + [key: string]: any; } -export const processNotificationOnCreate = onDocumentCreated({ - region: '#{SERVICES_RGN}#', - document: 'notifications/{notificationId}' -}, async (event) => { +export const processNotificationOnCreate = onDocumentCreated( + { + region: "#{SERVICES_RGN}#", + document: "notifications/{notificationId}", + }, + async (event) => { try { - const notificationSnapshot = event.data; - const notificationId = event.params.notificationId; + const notificationSnapshot = event.data; + const notificationId = event.params.notificationId; - if (!notificationSnapshot) { - logger.error(`No data found for notification ${notificationId}`); - return; - } + if (!notificationSnapshot) { + logger.error(`No data found for notification ${notificationId}`); + return; + } - const notification = notificationSnapshot.data() as NotificationData; - if (notification.notificationSent === true) { - logger.info(`Notification ${notificationId} already sent, skipping.`); - return; - } + const notification = notificationSnapshot.data() as NotificationData; - const { fcmToken } = await getUserAndFCMToken(notification); - if (!fcmToken) { - logger.error(`FCM token not found for notification ${notificationId}`); - await updateNotificationWithError(notificationId, 'FCM token not found for user'); - return; - } + if (notification.notificationSent === true) { + logger.info(`Notification ${notificationId} already sent, skipping.`); + return; + } - const message = prepareNotificationMessage(notification, fcmToken); - try { - const fcmResponse = await app.messaging().send({ - ...message, - token: fcmToken - }); + logger.info( + `Processing notification ${notificationId} of type: ${notification.type}` + ); - logger.info(`FCM notification sent successfully: ${fcmResponse}`); - await markNotificationAsSent(notificationId); + const { userId, fcmToken } = await getUserAndFCMToken(notification); + 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) { - logger.error(`Error sending notification ${notificationId}:`, error); - await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error)); - } + const message = prepareNotificationMessage(notification, fcmToken); + try { + const fcmResponse = await app.messaging().send({ + ...message, + token: fcmToken, + }); + + 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) { - logger.error('Error processing notification:', error); + logger.error("Error processing notification:", error); } -}); + } +); -async function getUserAndFCMToken(notification: NotificationData): Promise<{ userId: string | null; fcmToken: string | null }> { - let userId: string | null = null; - let fcmToken: string | null = null; +async function getUserAndFCMToken( + notification: NotificationData +): Promise<{ userId: string | null; fcmToken: string | null }> { + let userId: string | null = null; + let fcmToken: string | null = null; - if (notification.userId) { - userId = notification.userId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.clientId) { - userId = notification.clientId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.invitorId) { - userId = notification.invitorId; - fcmToken = await getFCMTokenFromUserDoc(userId); - } else if (notification.phoneNumber) { - const userQuery = await app - .firestore() - .collection('users') - .where('phoneNumber', '==', notification.phoneNumber) - .limit(1) - .get(); + if (notification.recipientId) { + userId = notification.recipientId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using recipientId: ${userId}`); + } else if (notification.userId) { + userId = notification.userId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using userId: ${userId}`); + } else if (notification.clientId) { + userId = notification.clientId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using clientId: ${userId}`); + } else if (notification.invitorId) { + userId = notification.invitorId; + fcmToken = await getFCMTokenFromUserDoc(userId); + logger.info(`Using invitorId: ${userId}`); + } else if (notification.phoneNumber) { + logger.info(`Looking up user by phone number: ${notification.phoneNumber}`); + const userQuery = await app + .firestore() + .collection("users") + .where("phoneNumber", "==", notification.phoneNumber) + .limit(1) + .get(); - if (!userQuery.empty) { - const userDoc = userQuery.docs[0]; - userId = userDoc.id; - fcmToken = userDoc.data()?.fcmToken; - } + if (!userQuery.empty) { + const userDoc = userQuery.docs[0]; + userId = userDoc.id; + fcmToken = userDoc.data()?.fcmToken; + logger.info(`Found user by phone: ${userId}`); + } else { + logger.warn( + `No user found with phone number: ${notification.phoneNumber}` + ); } + } else { + logger.error("No valid user identifier found in notification"); + } - return { userId, fcmToken }; + if (userId && !fcmToken) { + logger.warn(`User ${userId} found but no FCM token available`); + } + + return { userId, fcmToken }; } async function getFCMTokenFromUserDoc(userId: string): Promise { - const userDoc = await app.firestore().collection('users').doc(userId).get(); - return userDoc.exists ? userDoc.data()?.fcmToken : null; + try { + 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 { - let title = 'New Notification'; - let body = notification.message || 'You have a new notification'; - let data: Record = { - type: notification.type || 'general', - }; +function prepareNotificationMessage( + notification: NotificationData, + fcmToken: string +): admin.messaging.Message { + let title = "New Notification"; + let body = notification.message || "You have a new notification"; + let data: Record = { + type: notification.type || "general", + notificationId: "notification_" + Date.now(), + }; - switch (notification.type) { - case 'day_pass_entry': - const isAccepted = notification.status === 'ACCEPTED'; - 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; + switch (notification.type) { + case "trainer_response": + title = + notification.status === "ACCEPTED" + ? "Trainer Request Accepted" + : "Trainer Request Update"; + body = + notification.message || + `${ + notification.trainerName + } has ${notification.status?.toLowerCase()} your request`; + data.trainerName = notification.trainerName || ""; + data.status = notification.status || ""; + break; - case 'trainer_assigned_to_client': - title = 'Trainer Assigned'; - body = notification.message || `${notification.trainerName} has been assigned as your trainer`; - data.trainerName = notification.trainerName || ''; - data.membershipId = notification.membershipId || ''; - break; + case "trainer_assignment": + title = "New Client Assignment"; + body = + notification.message || + `You have been assigned to ${notification.name}`; + data.clientName = notification.name || ""; + data.membershipId = notification.membershipId || ""; + break; - case 'client_invitations': - 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`); - } else if (notification.phoneNumber) { - 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; + case "trainer_assigned_to_client": + title = "Trainer Assigned"; + body = + notification.message || + `${notification.trainerName} has been assigned as your trainer`; + data.trainerName = notification.trainerName || ""; + data.membershipId = notification.membershipId || ""; + break; - default: - logger.info(`Using default handling for notification type: ${notification.type}`); - break; - } + case "trainer_update_owner": + title = "Trainer Schedule Update"; + body = notification.message || "A trainer has updated their schedule"; + data.membershipId = notification.membershipId || ""; + break; - const notificationMessage: admin.messaging.Message = { - notification: { title, body }, - data, - android: { - priority: 'high', - notification: { - channelId: 'notifications_channel', - priority: 'high', - defaultSound: true, - defaultVibrateTimings: true, - icon: '@mipmap/ic_launcher', - clickAction: 'FLUTTER_NOTIFICATION_CLICK', - }, + case "trainer_update_client": + title = "Schedule Update"; + body = notification.message || "Your training schedule has been updated"; + data.membershipId = notification.membershipId || ""; + break; + + case "plan_renewal": + title = "Plan Renewal"; + body = + notification.message || + `Plan ${notification.subscriptionName} has been renewed`; + data.planName = notification.subscriptionName || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "plan_assigned": + title = "New Plan Assigned"; + body = + notification.message || + `You have been assigned ${notification.subscriptionName} at ${notification.gymName}`; + data.planName = notification.subscriptionName || ""; + data.gymName = notification.gymName || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "schedule_update": + title = "Schedule Update"; + body = notification.message || "Your training schedule has been updated"; + data.gymName = notification.gymName || ""; + break; + + case "attendance_dispute": + title = "Attendance Dispute"; + body = + notification.message || + `${notification.name} has disputed an attendance record`; + data.disputedBy = notification.name || ""; + data.membershipId = notification.membershipId || ""; + break; + + case "day_pass_entry": + const isAccepted = notification.status === "ACCEPTED"; + 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 || ""; + data.status = notification.status || ""; + break; + + case "client_invitations": + 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`); + } else if (notification.phoneNumber) { + 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}` + ); + title = notification.type + ? `${notification.type.replace("_", " ").toUpperCase()}` + : "Notification"; + break; + } + + const notificationMessage: admin.messaging.Message = { + notification: { title, body }, + data, + 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: { - sound: 'default', - badge: 1, - }, - }, - }, - token: fcmToken, - }; - return notificationMessage; + }, + }, + token: fcmToken, + }; + + logger.info(`Prepared notification: ${title} - ${body}`); + return notificationMessage; } function getInvitationStatus(status?: string): string { - if (status === 'ACCEPTED') return 'accepted'; - if (status === 'REJECTED') return 'rejected'; - if (status === 'PENDING') return 'pending'; - return 'unknown'; + if (status === "ACCEPTED") return "accepted"; + if (status === "REJECTED") return "rejected"; + if (status === "PENDING") return "pending"; + return "unknown"; } function getInvitationTitle(status: string): string { - switch (status) { - case 'accepted': return 'Invitation Accepted'; - case 'rejected': return 'Invitation Rejected'; - case 'pending': return 'New Invitation'; - default: return 'Invitation Update'; - } + switch (status) { + case "accepted": + return "Invitation Accepted"; + case "rejected": + return "Invitation Rejected"; + case "pending": + return "New Invitation"; + default: + return "Invitation Update"; + } } function getInvitationBody(status: string, name?: string): string { - switch (status) { - case 'accepted': return `You have accepted the invitation from ${name}`; - case 'rejected': 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'; - } + switch (status) { + case "accepted": + return `You have accepted the invitation from ${name}`; + case "rejected": + 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 { - await app.firestore().collection('notifications').doc(notificationId).update({ + try { + await app + .firestore() + .collection("notifications") + .doc(notificationId) + .update({ 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 { - await app.firestore().collection('notifications').doc(notificationId).update({ +async function updateNotificationWithError( + notificationId: string, + error: string +): Promise { + try { + await app + .firestore() + .collection("notifications") + .doc(notificationId) + .update({ 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}`); + } }