feat: refine creation and registration flows
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
+189
-261
@@ -5,261 +5,201 @@ const logger = require('firebase-functions/logger')
|
||||
const axios = require('axios')
|
||||
const sharp = require('sharp')
|
||||
const crypto = require('crypto')
|
||||
const path = require('path')
|
||||
|
||||
// Imports internes
|
||||
const { GEMINI_API_KEY } = require('../config/secrets')
|
||||
const { generateImageV2 } = require('../helpers/gemini')
|
||||
const { generatePicturePrompt } = require('../helpers/prompts')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
|
||||
// Configuration
|
||||
const bucket = admin.storage().bucket()
|
||||
const BRAND_TEXT = 'By Musicland.ai'
|
||||
const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
const BRAND_BG_COLOR = 'rgba(0,0,0,0.65)'
|
||||
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||
const COVER_OPTION_ID = 'musicland-playbacker'
|
||||
const COVER_SIZE = 1024
|
||||
const PROFILE_SIZE = 228
|
||||
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
|
||||
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
|
||||
|
||||
const escapeSvgText = (value = '') =>
|
||||
String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => {
|
||||
const safeWidth = Math.max(320, Math.round(width))
|
||||
const safeHeight = Math.max(320, Math.round(height))
|
||||
const margin = Math.round(safeWidth * 0.04)
|
||||
const fontSize = Math.max(22, Math.round(safeWidth * 0.045))
|
||||
const letterSpacing = Math.max(1, Math.round(fontSize * 0.06))
|
||||
const strokeWidth = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowDy = Math.max(2, Math.round(fontSize * 0.08))
|
||||
const shadowBlur = Math.max(4, Math.round(fontSize * 0.18))
|
||||
const paddingX = Math.max(10, Math.round(fontSize * 0.7))
|
||||
const paddingY = Math.max(6, Math.round(fontSize * 0.4))
|
||||
const approxCharWidth = fontSize * 0.62
|
||||
const safeText = escapeSvgText(text)
|
||||
const textLength = safeText.length
|
||||
const textWidth =
|
||||
approxCharWidth * textLength + letterSpacing * Math.max(0, textLength - 1)
|
||||
const rectWidth = Math.max(0, Math.round(textWidth + paddingX * 2))
|
||||
const rectHeight = Math.round(fontSize + paddingY * 2)
|
||||
const rectX = Math.max(0, Math.round(safeWidth - margin - rectWidth))
|
||||
const rectY = Math.max(0, Math.round(safeHeight - margin - rectHeight))
|
||||
const textX = rectX + rectWidth / 2
|
||||
const baselineShift = Math.round(fontSize * 0.3)
|
||||
const textY = rectY + rectHeight / 2 + baselineShift
|
||||
const rectRadius = Math.max(6, Math.round(rectHeight * 0.35))
|
||||
const normalizeArtistName = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return 'MusicLand'
|
||||
}
|
||||
const trimmedValue = value.trim()
|
||||
return trimmedValue || 'MusicLand'
|
||||
}
|
||||
|
||||
const getArtistFontSize = (artistName) => {
|
||||
if (artistName.length <= 16) return 52
|
||||
if (artistName.length <= 24) return 44
|
||||
return 36
|
||||
}
|
||||
|
||||
const buildTextOverlay = (artistName) => {
|
||||
const safeArtistName = escapeSvgText(artistName)
|
||||
const artistFontSize = getArtistFontSize(artistName)
|
||||
|
||||
return Buffer.from(`
|
||||
<svg width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="brandShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="${shadowDy}" stdDeviation="${shadowBlur}" flood-color="rgba(0,0,0,0.55)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
x="${rectX}"
|
||||
y="${rectY}"
|
||||
width="${rectWidth}"
|
||||
height="${rectHeight}"
|
||||
rx="${rectRadius}"
|
||||
ry="${rectRadius}"
|
||||
fill="${BRAND_BG_COLOR}"
|
||||
/>
|
||||
<svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
||||
<text
|
||||
x="${textX}"
|
||||
y="${textY}"
|
||||
x="512"
|
||||
y="824"
|
||||
text-anchor="middle"
|
||||
font-family="${BRAND_FONT_FAMILY}"
|
||||
font-size="${fontSize}"
|
||||
font-style="italic"
|
||||
font-weight="700"
|
||||
letter-spacing="${letterSpacing}"
|
||||
fill="rgba(255,255,255,0.95)"
|
||||
stroke="rgba(0,0,0,0.45)"
|
||||
stroke-width="${strokeWidth}"
|
||||
paint-order="stroke"
|
||||
filter="url(#brandShadow)"
|
||||
>${safeText}</text>
|
||||
dominant-baseline="middle"
|
||||
font-family="${FONT_FAMILY}"
|
||||
font-size="${artistFontSize}"
|
||||
font-weight="400"
|
||||
letter-spacing="3"
|
||||
fill="#FFFFFF"
|
||||
>${safeArtistName}</text>
|
||||
<text
|
||||
x="512"
|
||||
y="963"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-family="${FONT_FAMILY}"
|
||||
font-size="54"
|
||||
font-weight="400"
|
||||
letter-spacing="5"
|
||||
fill="#FFFFFF"
|
||||
>PLAYBACKER</text>
|
||||
</svg>`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires Strings
|
||||
*/
|
||||
const pickFirstNonEmpty = (...values) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value.trim()
|
||||
const buildCircularMask = (size) => {
|
||||
const mask = Buffer.alloc(size * size * 4)
|
||||
const radius = size / 2
|
||||
const center = radius - 0.5
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
const offset = (y * size + x) * 4
|
||||
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2)
|
||||
const alpha = distance <= radius ? 255 : 0
|
||||
mask[offset] = 255
|
||||
mask[offset + 1] = 255
|
||||
mask[offset + 2] = 255
|
||||
mask[offset + 3] = alpha
|
||||
}
|
||||
}
|
||||
return ''
|
||||
|
||||
return mask
|
||||
}
|
||||
|
||||
const combineNames = (...parts) =>
|
||||
parts
|
||||
.map((part) => (typeof part === 'string' ? part.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim()
|
||||
async function loadCoverIdentity(project) {
|
||||
const artistName = normalizeArtistName(project.userName)
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
|
||||
/**
|
||||
* Résolution intelligente du nom d'artiste
|
||||
*/
|
||||
async function resolveArtistName(project = {}) {
|
||||
// 1. Vérification directe sur le projet ou le snapshot "owner"
|
||||
const owner = project?.owner || {}
|
||||
const direct = pickFirstNonEmpty(
|
||||
project?.artistName,
|
||||
project?.userName,
|
||||
owner?.artistName,
|
||||
owner?.userName,
|
||||
owner?.displayName
|
||||
)
|
||||
if (direct) return direct
|
||||
|
||||
// 2. Fallback : Récupération depuis la collection Users
|
||||
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
|
||||
if (!userId) return ''
|
||||
if (!userId) {
|
||||
return { artistName, profilePictureURL: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const userSnapshot = await refList.users.doc(userId).get()
|
||||
if (!userSnapshot?.exists) return ''
|
||||
|
||||
const userData = userSnapshot.data() || {}
|
||||
return (
|
||||
pickFirstNonEmpty(
|
||||
userData.artistName,
|
||||
userData.userName,
|
||||
userData.displayName,
|
||||
combineNames(userData.firstName, userData.lastName)
|
||||
) || ''
|
||||
)
|
||||
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
||||
return {
|
||||
artistName: normalizeArtistName(project.userName || userData?.userName),
|
||||
profilePictureURL: userData?.profilePictureURL || null,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ [Cover] Artist name resolution failed', {
|
||||
projectId: project?.id,
|
||||
logger.warn('[Cover] Unable to load profile picture', {
|
||||
projectId: project.id,
|
||||
error: error.message,
|
||||
})
|
||||
return ''
|
||||
return { artistName, profilePictureURL: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le texte de marque en filigrane sur l'image générée
|
||||
*/
|
||||
async function buildCoverWithBrandText(backgroundUrl, targetPath) {
|
||||
logger.info('🖼️ [Cover] Compositing brand text...')
|
||||
async function buildProfileOverlay(profilePictureURL) {
|
||||
if (!profilePictureURL) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Téléchargement background
|
||||
const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' })
|
||||
|
||||
const baseImage = sharp(bgResponse.data)
|
||||
const metadata = await baseImage.metadata()
|
||||
const width = metadata.width || 1024
|
||||
const height = metadata.height || 1024
|
||||
const brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT })
|
||||
|
||||
// Composition
|
||||
const stampedBuffer = await baseImage
|
||||
const response = await axios.get(profilePictureURL, { responseType: 'arraybuffer' })
|
||||
const circularMask = buildCircularMask(PROFILE_SIZE)
|
||||
return await sharp(response.data)
|
||||
.resize(PROFILE_SIZE, PROFILE_SIZE, { fit: 'cover', position: 'centre' })
|
||||
.ensureAlpha()
|
||||
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }])
|
||||
.composite([
|
||||
{
|
||||
input: circularMask,
|
||||
raw: {
|
||||
width: PROFILE_SIZE,
|
||||
height: PROFILE_SIZE,
|
||||
channels: 4,
|
||||
},
|
||||
blend: 'dest-in',
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
// Upload vers Storage
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
await file.save(stampedBuffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
})
|
||||
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
} catch (error) {
|
||||
logger.error('❌ [Cover] buildCoverWithBrandText failed', error)
|
||||
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre
|
||||
return backgroundUrl
|
||||
logger.warn('[Cover] Unable to load profile image, keeping default icon', {
|
||||
error: error.message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur de la logique de génération
|
||||
*/
|
||||
async function performCoverGeneration(project) {
|
||||
const t0 = Date.now()
|
||||
const artistName = await resolveArtistName(project)
|
||||
async function uploadCover(buffer, targetPath) {
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
const baseTimestamp = Date.now()
|
||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
||||
|
||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
|
||||
const uniqueSuffix = `${baseTimestamp}-${index}`
|
||||
const storageBasePath = `users/${project.userId}/projects/${project.id}`
|
||||
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
|
||||
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
|
||||
const prompt = generatePicturePrompt({
|
||||
...project,
|
||||
artistName,
|
||||
variantSeed: `${project.id || 'project'}-${uniqueSuffix}`,
|
||||
})
|
||||
|
||||
logger.info('🎨 [Cover] Prompt generated', {
|
||||
projectId: project.id,
|
||||
artistName,
|
||||
variant: uniqueSuffix,
|
||||
promptPreview: prompt.slice(0, 100) + '...',
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
|
||||
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
|
||||
|
||||
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
|
||||
|
||||
// 2. Ajout du Logo
|
||||
const finalCoverUrl = await buildCoverWithBrandText(generatedUrl, stampedPath)
|
||||
|
||||
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
|
||||
|
||||
return {
|
||||
id: uniqueSuffix,
|
||||
generatedUrl,
|
||||
finalUrl: finalCoverUrl,
|
||||
promptUsed: prompt,
|
||||
}
|
||||
} catch (e) {
|
||||
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
|
||||
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
|
||||
error: e.message,
|
||||
})
|
||||
return null // On retourne null pour filtrer plus tard
|
||||
}
|
||||
await file.save(buffer, {
|
||||
resumable: false,
|
||||
metadata: {
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000',
|
||||
metadata: { firebaseStorageDownloadTokens: token },
|
||||
},
|
||||
})
|
||||
|
||||
// Attente de la résolution de toutes les générations
|
||||
const results = await Promise.all(generationPromises)
|
||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||
}
|
||||
|
||||
// On garde uniquement les tentatives réussies (non null)
|
||||
const options = results.filter(Boolean)
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error('Toutes les tentatives de génération ont échoué.')
|
||||
async function prepareMusiclandCover(project) {
|
||||
const startedAt = Date.now()
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
if (!userId) {
|
||||
throw new Error('Utilisateur du projet introuvable')
|
||||
}
|
||||
|
||||
// Sauvegarde dans Firestore
|
||||
const [firstOption] = options
|
||||
const { artistName, profilePictureURL } = await loadCoverIdentity(project)
|
||||
const profileOverlay = await buildProfileOverlay(profilePictureURL)
|
||||
const overlays = [{ input: buildTextOverlay(artistName), left: 0, top: 0 }]
|
||||
|
||||
if (profileOverlay) {
|
||||
overlays.unshift({ input: profileOverlay, left: 398, top: 454 })
|
||||
}
|
||||
|
||||
const coverBuffer = await sharp(TEMPLATE_PATH)
|
||||
.resize(COVER_SIZE, COVER_SIZE, { fit: 'cover', kernel: sharp.kernel.lanczos3 })
|
||||
.ensureAlpha()
|
||||
.composite(overlays)
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
const storagePath = `users/${userId}/projects/${project.id}/cover-musicland-playbacker.png`
|
||||
const coverUrl = await uploadCover(coverBuffer, storagePath)
|
||||
const option = {
|
||||
id: COVER_OPTION_ID,
|
||||
finalUrl: coverUrl,
|
||||
generatedUrl: null,
|
||||
}
|
||||
|
||||
await refList.projects.doc(project.id).set(
|
||||
{
|
||||
cover: {
|
||||
generatedBackground: firstOption.generatedUrl,
|
||||
result: firstOption.finalUrl,
|
||||
// selectedOptionId: firstOption.id, // disable default selection
|
||||
options, // Sauvegarde de toutes les variantes réussies
|
||||
template: COVER_TEMPLATE,
|
||||
result: coverUrl,
|
||||
generatedBackground: null,
|
||||
selectedOptionId: COVER_OPTION_ID,
|
||||
options: [option],
|
||||
},
|
||||
coverStatus: 'GENERATED',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
@@ -267,82 +207,68 @@ async function performCoverGeneration(project) {
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
logger.info('🏁 [Cover] Process complete', {
|
||||
logger.info('[Cover] MusicLand cover prepared', {
|
||||
projectId: project.id,
|
||||
successCount: options.length,
|
||||
duration: Date.now() - t0,
|
||||
duration: Date.now() - startedAt,
|
||||
})
|
||||
|
||||
return firstOption.finalUrl
|
||||
return coverUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* TRIGGER FIRESTORE
|
||||
* Déclenché à la création d'un document dans 'tasks/{taskId}'
|
||||
*/
|
||||
exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
{
|
||||
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
|
||||
timeoutSeconds: 120,
|
||||
memory: '1GiB',
|
||||
document: 'tasks/{taskId}',
|
||||
secrets: [GEMINI_API_KEY],
|
||||
},
|
||||
async (event) => {
|
||||
const data = event.data?.data() || {}
|
||||
const { type, projectId } = data
|
||||
const taskId = event.params.taskId
|
||||
|
||||
if (!projectId) return // Ignorer les tâches mal formées
|
||||
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
|
||||
if (!projectId || !['cover', 'combine'].includes(type)) return
|
||||
|
||||
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
|
||||
if (type === 'combine') {
|
||||
await event.data.ref.update({
|
||||
status: 'CANCELLED',
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[Task ${taskId}] MusicLand cover started`, { projectId })
|
||||
|
||||
try {
|
||||
// 1. Validation & Setup
|
||||
if (type === 'combine') {
|
||||
// Feature désactivée pour le moment
|
||||
await event.data.ref.update({
|
||||
status: 'CANCELLED',
|
||||
error: "La personnalisation photo n'est plus disponible.",
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mise à jour statut projet
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'GENERATING',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// 2. Chargement Projet
|
||||
const projectSnap = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnap.exists) throw new Error('Projet introuvable')
|
||||
|
||||
const project = { id: projectId, ...projectSnap.data() }
|
||||
|
||||
// Idempotency check (si déjà généré, on ne refait pas)
|
||||
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
|
||||
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
|
||||
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
|
||||
await event.data.ref.update({
|
||||
status: 'DONE',
|
||||
info: 'Already generated',
|
||||
})
|
||||
return
|
||||
const projectSnapshot = await refList.projects.doc(projectId).get()
|
||||
if (!projectSnapshot.exists) {
|
||||
throw new Error('Projet introuvable')
|
||||
}
|
||||
|
||||
// 3. Exécution Génération
|
||||
const coverUrl = await performCoverGeneration(project)
|
||||
const project = { id: projectId, ...projectSnapshot.data() }
|
||||
const existingCover = project.cover || {}
|
||||
let coverUrl = existingCover.template === COVER_TEMPLATE ? existingCover.result : null
|
||||
|
||||
if (!coverUrl) {
|
||||
coverUrl = await prepareMusiclandCover(project)
|
||||
} else {
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'GENERATED',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Finalisation Tâche
|
||||
await event.data.ref.update({
|
||||
status: 'DONE',
|
||||
coverUrl,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// 5. Notification
|
||||
if (project.userId) {
|
||||
const projectTitle = project.title || 'ton projet'
|
||||
await sendNotification({
|
||||
@@ -350,28 +276,30 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
receiver: project.userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Pochette prête !',
|
||||
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
|
||||
message: `La pochette pour "${projectTitle}" est prête.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
coverUrl,
|
||||
},
|
||||
}).catch((err) => logger.warn('Notification failed', err))
|
||||
}).catch((error) => logger.warn('Notification failed', error))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`🔥 [Task ${taskId}] Failed`, error)
|
||||
|
||||
// Mise à jour erreur Tâche
|
||||
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
|
||||
|
||||
// Mise à jour erreur Projet
|
||||
logger.error(`[Task ${taskId}] MusicLand cover failed`, error)
|
||||
await event.data.ref.set(
|
||||
{
|
||||
status: 'ERROR',
|
||||
error: error.message,
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
await refList.projects.doc(projectId).update({
|
||||
coverStatus: 'ERROR',
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
// Notification Erreur
|
||||
const projectData = (await refList.projects.doc(projectId).get()).data()
|
||||
if (projectData?.userId) {
|
||||
await sendNotification({
|
||||
@@ -379,7 +307,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
||||
receiver: projectData.userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Échec pochette',
|
||||
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||
message: `Impossible de préparer la pochette pour "${projectData.title || 'ton projet'}".`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||
projectId,
|
||||
|
||||
Reference in New Issue
Block a user