const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore') const { FieldValue } = require('firebase-admin/firestore') 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/secrets') let resendInstance = null const getResendInstance = () => { if (!resendInstance) { resendInstance = new Resend(RESEND_API_KEY.value()) } return resendInstance } // Initialisation de Expo SDK let expo = new Expo() const EMAIL_FROM = 'MusicLand ' const DEFAULT_EMAIL_TITLE = 'MusicLand' function getCollectionRef(collectionName = '') { const ref = refList?.[collectionName] if (!ref) { throw new Error(`Unknown collection "${collectionName}"`) } return ref } function cleanString(value) { return typeof value === 'string' && value.trim() ? value.trim() : null } function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) { const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE const fallbackContent = cleanString(message) || '' const overrides = template && typeof template === 'object' ? template : {} const subject = cleanString(overrides.subject) || fallbackTitle const emailTitle = cleanString(overrides.title) || fallbackTitle const content = cleanString(overrides.content) || fallbackContent let button = null if (overrides.button && typeof overrides.button === 'object') { const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href) if (buttonUrl) { button = { url: buttonUrl, label: cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined, } } } const templatePayload = { title: emailTitle, content } if (button) { templatePayload.button = button } return { subject, html: basicTemplate(templatePayload), } } exports.sendNotificationWhenDocIsCreated = onDocumentCreated( { region: 'europe-west1', document: 'notifications/{notificationId}', secrets: [RESEND_API_KEY], }, 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: receiverEmail = '', emailNotifications = false, } = receiverData if (!mailOnly) { try { const tokensSet = new Set( [] .concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(pushToken ? [pushToken] : []) .filter(Boolean) ) const tokens = Array.from(tokensSet) if (tokens.length) { await sendExpoNotification({ tokens, receiverId: receiver, receiverCollection, title: title || 'MusicLand', message: message, data: notifData || {}, }) } else { console.log('User push token not found') } } catch (e) { console.log('Error sending notif:', e) } } if ((emailNotifications || mailOnly) && !!receiverEmail) { try { const { subject, html } = buildNotificationEmailPayload({ title, message, template: notifData?.email, }) await getResendInstance().emails.send({ from: EMAIL_FROM, to: [receiverEmail], subject, html, }) } catch (e) { console.log('Error sending email:', e) } } else { console.log( `[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}` ) } } 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: FieldValue.arrayRemove(...uniqueTokens), } if (uniqueTokens.includes(userData?.pushToken)) { updates.pushToken = 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: 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 } } )