const { onDocumentCreated, onDocumentWritten, } = require("firebase-functions/v2/firestore"); const admin = require("firebase-admin"); const { refList, ALERT_TYPE } = require("../index"); const { Expo } = require("expo-server-sdk"); const { Resend } = require("resend"); const { basicTemplate } = require("../helpers/email"); const { RESEND_API_KEY } = require("../config/keys"); const resendInstance = new Resend(RESEND_API_KEY); // Initialisation de Expo SDK let expo = new Expo(); const EMAIL_FROM = "MusicLand "; function getCollectionRef(collectionName = "") { const ref = refList?.[collectionName]; if (!ref) { throw new Error(`Unknown collection "${collectionName}"`); } return ref; } exports.sendNotificationWhenDocIsCreated = onDocumentCreated( { region: "europe-west1", document: "notifications/{notificationId}" }, async (event) => { try { const { receiver = null, title = "MusicLand", receiverCollection = "users", message = "", data: notifData = {}, mailOnly = false, } = event.data.data(); if (!receiver || !message) { throw new Error("Receiver and message are required"); } const receiverSnap = await getCollectionRef(receiverCollection) .doc(receiver) .get(); const receiverData = receiverSnap.exists ? receiverSnap.data() : {}; const { pushToken = null, pushTokens = [], email = "", emailNotifications = false, } = receiverData; if (!mailOnly) { const tokensSet = new Set( [] .concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(pushToken ? [pushToken] : []) .filter(Boolean), ); const tokens = Array.from(tokensSet); if (!tokens?.length) { console.warn("User push token not found"); return null; } await sendExpoNotification({ tokens, receiverId: receiver, receiverCollection, title: title || "MusicLand", message: message, data: notifData || {}, }); } if (emailNotifications && !!email) { try { if (!email) { throw new Error("userMail is required"); } if (!email?.subject || !email?.html) { throw new Error("Email subject and html are required"); } await resendInstance.emails.send({ from: EMAIL_FROM, to: [email], subject: title, html: basicTemplate({ title, content: message }), }); } catch (e) { console.log("Error sending email:", e); } } else { console.log(`Email disabled for user ${receiver} and type ${type}`); } } catch (e) { console.log(e); return e; } }, ); // Fonction pour envoyer la notification via Expo SDK async function sendExpoNotification({ tokens = [], title = "", message = "", data = {}, receiverId = null, receiverCollection = "users", }) { try { const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]; const validTokens = []; const invalidTokens = []; candidateTokens.forEach((token) => { if (Expo.isExpoPushToken(token)) { validTokens.push(token); } else if (token) { invalidTokens.push(token); } }); if (invalidTokens.length && receiverId) { await removeInvalidTokens({ tokens: invalidTokens, receiverId, receiverCollection, }); } if (!validTokens.length) { console.warn("No valid Expo push tokens to send notification"); return { sent: false }; } const messages = validTokens.map((token) => ({ to: token, sound: "default", title: title, body: message, data: data || {}, priority: "high", badge: 1, channelId: "default", })); const chunks = expo.chunkPushNotifications(messages); const receipts = []; const tokensToPrune = new Set(); for (const chunk of chunks) { const chunkReceipts = await expo.sendPushNotificationsAsync(chunk); chunkReceipts.forEach((receipt, index) => { if (receipt?.status === "error") { const errorCode = receipt?.details?.error || receipt?.details?.code; console.log("Error sending notification:", receipt); if ( errorCode === "DeviceNotRegistered" || errorCode === "PushTokenNotRegistered" ) { const token = chunk[index]?.to; if (token) { tokensToPrune.add(token); } } } }); receipts.push(...chunkReceipts); } if (tokensToPrune.size && receiverId) { await removeInvalidTokens({ tokens: Array.from(tokensToPrune), receiverId, receiverCollection, }); } console.log("Sent push notifications:", receipts); return { sent: true }; } catch (e) { console.log("Error sending notification:", e); throw e; } } async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection, }) { try { if (!receiverId || !tokens.length) { return; } const uniqueTokens = Array.from(new Set(tokens.filter(Boolean))); if (!uniqueTokens.length) { return; } const docRef = getCollectionRef(receiverCollection).doc(receiverId); const userSnap = await docRef.get(); const userData = userSnap?.data() || {}; const updates = { pushTokens: admin.firestore.FieldValue.arrayRemove(...uniqueTokens), }; if (uniqueTokens.includes(userData?.pushToken)) { updates.pushToken = admin.firestore.FieldValue.delete(); } await docRef.set(updates, { merge: true }); console.log( "Pruned invalid push tokens", JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2), ); } catch (error) { console.log("Failed to prune invalid push tokens:", error); } } // Fonction pour ajouter une notification à la base de données const sendNotification = async ({ sender = "SYSTEM", receiver = null, receiverCollection = "users", title = "", message = null, mailOnly = false, data = {}, }) => { try { if (!receiver || !message) { throw new Error("Receiver and message are required"); } const payload = { sender, receiver, receiverCollection, title, message, time: admin.firestore.FieldValue.serverTimestamp(), read: false, readAt: null, mailOnly, data, }; const { id } = await refList.notifications.add(payload); console.log( "[sendNotification] Notification created", JSON.stringify({ id, receiver, receiverCollection }, null, 2), ); return id; } catch (e) { console.log("[sendNotification] Error creating notification:", e); } }; exports.sendNotification = sendNotification; exports.createProjectCommentNotification = onDocumentCreated( { region: "europe-west1", document: "projects/{projectId}/comments/{commentId}", }, async (event) => { try { console.log( "[createProjectCommentNotification] Trigger received", JSON.stringify(event.params || {}, null, 2), ); const { data: snap } = event; const { projectId, commentId } = event.params || {}; const comment = snap?.data(); if (!projectId || !comment) { console.log( "[createProjectCommentNotification] Missing project/comment data", { hasProjectId: !!projectId, hasComment: !!comment }, ); return null; } console.log( "[createProjectCommentNotification] Comment payload", JSON.stringify(comment, null, 2), ); const projectSnap = await refList.projects.doc(projectId).get(); if (!projectSnap.exists) { console.log( "[createProjectCommentNotification] Project not found", projectId, ); return null; } const project = projectSnap.data() || {}; const receiver = project.userId || null; if (!receiver || receiver === comment.userId) { console.log( "[createProjectCommentNotification] Invalid receiver", JSON.stringify({ receiver, commentUserId: comment.userId }), ); return null; } const commenterName = typeof comment?.userName === "string" && comment.userName.trim() ? comment.userName.trim() : "Un utilisateur"; const projectTitle = typeof project.title === "string" && project.title.trim() ? project.title.trim() : "ton projet"; const message = `${commenterName} a commenté ton projet "${projectTitle}"`; console.log( "[createProjectCommentNotification] Creating notification", JSON.stringify( { receiver, message, commentId, projectId, }, null, 2, ), ); await sendNotification({ sender: comment.userId || "SYSTEM", receiver, receiverCollection: "users", title: "Nouveau commentaire", message, data: { type: ALERT_TYPE?.NEW_COMMENT, projectId, commentId, commenterId: comment.userId || null, commenterName: commenterName, commenterProfilePicture: comment?.profilePicture || "", text: typeof comment?.text === "string" && comment.text.trim() ? comment.text.trim() : "", }, }); console.log( "[createProjectCommentNotification] Notification creation complete", ); return null; } catch (error) { console.log("createProjectCommentNotification error:", error); return error; } }, ); exports.createProjectLikeNotification = onDocumentWritten( { region: "europe-west1", document: "projects/{projectId}", }, async (event) => { try { const { projectId } = event.params || {}; const before = event?.data?.before?.data() || {}; const after = event?.data?.after?.data() || {}; if (!projectId || !after) { return null; } const ownerId = after.userId || null; if (!ownerId) { return null; } const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : []; const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : []; const beforePlaybackLikes = Array.isArray(before?.likes?.playback) ? before.likes.playback : []; const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []; const beforeSongSet = new Set(beforeSongLikes); const beforePlaybackSet = new Set(beforePlaybackLikes); const newSongLikers = afterSongLikes.filter( (uid) => uid && !beforeSongSet.has(uid), ); const newPlaybackLikers = afterPlaybackLikes.filter( (uid) => uid && !beforePlaybackSet.has(uid), ); const newLikers = []; newSongLikers.forEach((uid) => { newLikers.push({ likerId: uid, likeType: "song" }); }); newPlaybackLikers.forEach((uid) => { newLikers.push({ likerId: uid, likeType: "playback" }); }); if (!newLikers.length) { return null; } const projectTitle = typeof after.title === "string" && after.title.trim() ? after.title.trim() : "ton projet"; await Promise.all( newLikers.map(async ({ likerId, likeType }) => { if (!likerId || likerId === ownerId) { return null; } const likerSnap = await refList.users.doc(likerId).get(); const liker = likerSnap?.data() || {}; const likerName = typeof liker?.userName === "string" && liker.userName.trim() ? liker.userName.trim() : "Un utilisateur"; const isPlaybackLike = likeType === "playback"; const assetLabel = isPlaybackLike ? "ton playback" : "ta musique"; const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`; await sendNotification({ sender: likerId, receiver: ownerId, receiverCollection: "users", title: "Nouveau like", message, data: { type: ALERT_TYPE?.NEW_LIKE, projectId, likerId, likerName, likeType, }, }); return null; }), ); return null; } catch (error) { console.log("[createProjectLikeNotification] error:", error); return error; } }, ); exports.createNewFollowerNotification = onDocumentWritten( { region: "europe-west1", document: "users/{userId}", }, async (event) => { try { const { userId } = event.params || {}; const before = event?.data?.before?.data() || {}; const after = event?.data?.after?.data() || {}; if (!userId || !after) { return null; } const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []; const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []; if (afterFollowers.length <= beforeFollowers.length) { return null; } const previousSet = new Set(beforeFollowers); const newFollowers = afterFollowers.filter( (uid) => !previousSet.has(uid), ); if (!newFollowers.length) { return null; } await Promise.all( newFollowers.map(async (followerId) => { if (!followerId || followerId === userId) { return null; } const followerSnap = await refList.users.doc(followerId).get(); const follower = followerSnap?.data() || {}; const followerName = typeof follower?.userName === "string" && follower.userName.trim() ? follower.userName.trim() : "Un utilisateur"; const message = `${followerName} te suit maintenant`; await sendNotification({ sender: followerId, receiver: userId, receiverCollection: "users", title: "Nouvel abonné", message, data: { type: ALERT_TYPE?.NEW_FOLLOWER, followerId, followerName, followerProfilePicture: follower?.profilePicture || "", }, }); return null; }), ); return null; } catch (error) { console.log("[createNewFollowerNotification] error:", error); return error; } }, );