fitlien-services/functions/src/notifications/processNotification.ts
2025-08-13 18:44:51 +05:30

432 lines
14 KiB
TypeScript

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<string | 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.TokenMessage {
let title = notification.data?.title || "New Notification";
let body = notification.data?.message || "You have a new notification";
let fcmData: Record<string, string> = {
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<void> {
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<void> {
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}`);
}
}