import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { getLogger } from "../shared/config"; import { getAdmin } from "../shared/config"; import * as admin from "firebase-admin"; const app = getAdmin(); const logger = getLogger(); interface NotificationData { senderId?: string; recipientId?: string; type?: string; notificationSent?: boolean; timestamp?: admin.firestore.FieldValue; read?: boolean; data?: { [key: string]: any }; } export const processNotificationOnCreate = onDocumentCreated( { region: "#{SERVICES_RGN}#", document: "notifications/{notificationId}", }, async (event) => { try { const notificationSnapshot = event.data; const notificationId = event.params.notificationId; 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; } logger.info( `Processing notification ${notificationId} of type: ${notification.type}` ); 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; } const message = prepareNotificationMessage(notification, fcmToken); try { 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) { logger.error("Error processing notification:", error); } } ); async function getUserAndFCMToken( notification: NotificationData ): Promise<{ userId: string | null; fcmToken: string | null }> { let targetUserId: string | null = null; let fcmToken: string | null = null; if (notification.recipientId) { targetUserId = notification.recipientId; logger.info(`Using top-level recipientId: ${targetUserId}`); } else if (notification.data?.userId) { targetUserId = notification.data.userId; logger.info(`Using data.userId: ${targetUserId}`); } else if (notification.data?.clientId) { targetUserId = notification.data.clientId; logger.info(`Using data.clientId: ${targetUserId}`); } else if (notification.data?.invitorId) { targetUserId = notification.data.invitorId; logger.info(`Using data.invitorId: ${targetUserId}`); } else if (notification.data?.phoneNumber) { logger.info( `Looking up user by phone number from data: ${notification.data.phoneNumber}` ); const userQuery = await app .firestore() .collection("users") .where("phoneNumber", "==", notification.data.phoneNumber) .limit(1) .get(); if (!userQuery.empty) { const userDoc = userQuery.docs[0]; targetUserId = userDoc.id; 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"); } 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 { 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.TokenMessage { let title = notification.data?.title || "New Notification"; let body = notification.data?.message || "You have a new notification"; let fcmData: Record = { type: notification.type || "general", notificationId: "notification_" + Date.now().toString(), }; if (notification.senderId) fcmData.senderId = notification.senderId; if (notification.recipientId) fcmData.recipientId = notification.recipientId; if (notification.read !== undefined) fcmData.read = String(notification.read); if (notification.data) { for (const key in notification.data) { if (Object.prototype.hasOwnProperty.call(notification.data, key)) { const value = notification.data[key]; if (typeof value === "object" && value !== null) { fcmData[key] = JSON.stringify(value); } else { fcmData[key] = String(value); } } } } switch (notification.type) { case "trainer_response": title = notification.data?.title || (notification.data?.status === "accepted" ? "Trainer Request Accepted" : "Trainer Request Update"); body = notification.data?.message || `${ notification.data?.trainerName } 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 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 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": 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, }, }, }, 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"; } 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"; } } 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"; } } async function markNotificationAsSent(notificationId: string): Promise { try { await app .firestore() .collection("notifications") .doc(notificationId) .update({ notificationSent: true, 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 { try { await app .firestore() .collection("notifications") .doc(notificationId) .update({ notificationError: error, 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}`); } }