570 lines
16 KiB
JavaScript
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
|
|
}
|
|
}
|
|
)
|