Files
musicland/functions/src/notifications.js
T
Thomas Demirdjian c8656d6273 heygen
2026-08-04 10:59:27 +02:00

570 lines
16 KiB
JavaScript

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 <musicland@musicland.ai>'
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
}
}
)