feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+6 -6
View File
@@ -1,10 +1,10 @@
exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
exports.GEMINI_API_KEY = 'AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y'
exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key
exports.SUNO_API_KEY = 'c1636e04f606811511e19ec6e1545aa6' // api key
exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu";
exports.RESEND_API_KEY = 're_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu'
exports.STRIPE_SECRET_KEY =
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
exports.STRIPE_RETURN_URL = "";
'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu'
exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW'
exports.STRIPE_RETURN_URL = ''
+6 -7
View File
@@ -1,8 +1,7 @@
exports.SUNO_API_BASE = "https://api.sunoapi.org";
exports.SUNO_API_PATH = "/api/v1/generate";
exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info";
exports.SUNO_TIMESTAMPED_LYRICS_PATH =
"/api/v1/generate/get-timestamped-lyrics";
exports.SUNO_MODEL = "V5";
exports.SUNO_API_BASE = 'https://api.sunoapi.org'
exports.SUNO_API_PATH = '/api/v1/generate'
exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info'
exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics'
exports.SUNO_MODEL = 'V5'
exports.SUNO_CALLBACK_URL =
"https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback";
'https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback'
+4 -4
View File
@@ -1,5 +1,5 @@
exports.BATCH_TYPE = {
ADD: "ADD",
UPDATE: "UPDATE",
DELETE: "DELETE",
};
ADD: 'ADD',
UPDATE: 'UPDATE',
DELETE: 'DELETE',
}
+16 -17
View File
@@ -1,18 +1,17 @@
const fs = require("fs");
const path = require("path");
const fs = require('fs')
const path = require('path')
const musicLandLogoBase64 = fs.readFileSync(
path.join(__dirname, "../assets/musicLandLogo.png"),
{ encoding: "base64" },
);
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`;
const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), {
encoding: 'base64',
})
const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`
function basicTemplate({ title = "", content = "", button = null }) {
function basicTemplate({ title = '', content = '', button = null }) {
const btn = button?.url
? `<p><a href="${button.url}" style="display:inline-block;padding:10px 16px;background:#6C5CE7;color:#fff;border-radius:8px;text-decoration:none">${
button?.label || "Ouvrir"
button?.label || 'Ouvrir'
}</a></p>`
: "";
: ''
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
@@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) {
</td>
</tr>
</table>
</body></html>`;
</body></html>`
}
function welcomeTemplate({ firstName = "", lastName = "" }) {
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
function welcomeTemplate({ firstName = '', lastName = '' }) {
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
@@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
</td>
</tr>
</table>
</body></html>`;
</body></html>`
}
exports.basicTemplate = basicTemplate;
exports.welcomeTemplate = welcomeTemplate;
exports.basicTemplate = basicTemplate
exports.welcomeTemplate = welcomeTemplate
+34 -40
View File
@@ -1,88 +1,82 @@
const admin = require("firebase-admin");
const { BATCH_TYPE } = require("../config/types");
const admin = require('firebase-admin')
const { BATCH_TYPE } = require('../config/types')
async function deleteFolder(path) {
try {
console.log(`Deleting folder: ${path}`);
const bucket = admin.storage().bucket();
await bucket.deleteFiles({ prefix: path, force: true });
console.log(`Folder ${path} deleted successfully`);
console.log(`Deleting folder: ${path}`)
const bucket = admin.storage().bucket()
await bucket.deleteFiles({ prefix: path, force: true })
console.log(`Folder ${path} deleted successfully`)
} catch (e) {
console.log(e);
console.log(e)
}
}
async function batchFirestore({
path = "", // ADD only
path = '', // ADD only
docs = [], // not for ADD
data = {}, // not for DELETE
type = BATCH_TYPE.UPDATE,
}) {
try {
if (docs?.length === 0 && type !== BATCH_TYPE.ADD) {
console.log(`Aucun document trouvé dans la collection ${path}.`);
return;
console.log(`Aucun document trouvé dans la collection ${path}.`)
return
}
// Préparer des lots pour les opérations
const batches = [];
let currentBatch = admin.firestore().batch();
let operationCounter = 0;
const batches = []
let currentBatch = admin.firestore().batch()
let operationCounter = 0
docs.forEach((doc) => {
const ref =
doc?.ref ||
(typeof doc?.path === "string"
? admin.firestore().doc(doc.path)
: null);
doc?.ref || (typeof doc?.path === 'string' ? admin.firestore().doc(doc.path) : null)
const payload =
doc && typeof doc.data === "object" && doc.data !== null && !Array.isArray(doc.data)
doc && typeof doc.data === 'object' && doc.data !== null && !Array.isArray(doc.data)
? doc.data
: data;
: data
if (type === BATCH_TYPE.ADD) {
// Pour ADD, créer un nouveau document avec ID automatique
const newDocRef = admin.firestore().collection(path).doc();
currentBatch.set(newDocRef, data);
const newDocRef = admin.firestore().collection(path).doc()
currentBatch.set(newDocRef, data)
} else if (type === BATCH_TYPE.UPDATE) {
if (!ref) {
throw new Error("batchFirestore UPDATE nécessite une référence de document.");
throw new Error('batchFirestore UPDATE nécessite une référence de document.')
}
if (!payload || Object.keys(payload).length === 0) {
throw new Error("batchFirestore UPDATE nécessite des données à écrire.");
throw new Error('batchFirestore UPDATE nécessite des données à écrire.')
}
currentBatch.set(ref, payload, { merge: true });
currentBatch.set(ref, payload, { merge: true })
} else if (type === BATCH_TYPE.DELETE) {
if (!ref) {
throw new Error("batchFirestore DELETE nécessite une référence de document.");
throw new Error('batchFirestore DELETE nécessite une référence de document.')
}
currentBatch.delete(ref);
currentBatch.delete(ref)
} else {
throw new Error(`Opération non supportée : ${type}`);
throw new Error(`Opération non supportée : ${type}`)
}
operationCounter++;
operationCounter++
// Si le lot atteint la limite de 500 opérations, le sauvegarder et en créer un nouveau
if (operationCounter === 500) {
batches.push(currentBatch);
currentBatch = admin.firestore().batch();
operationCounter = 0;
batches.push(currentBatch)
currentBatch = admin.firestore().batch()
operationCounter = 0
}
});
})
// Ajouter le dernier lot si des opérations y sont présentes
if (operationCounter > 0) {
batches.push(currentBatch);
batches.push(currentBatch)
}
// Exécuter tous les lots en parallèle
await Promise.all(batches.map((batch) => batch.commit()));
await Promise.all(batches.map((batch) => batch.commit()))
} catch (error) {
console.error(
`Erreur lors des opérations ${type} pour la collection ${path} :`,
error,
);
console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error)
}
}
exports.batchFirestore = batchFirestore;
exports.deleteFolder = deleteFolder;
exports.batchFirestore = batchFirestore
exports.deleteFolder = deleteFolder
+99 -114
View File
@@ -1,44 +1,42 @@
const { googleAI } = require("@genkit-ai/googleai");
const { genkit, z } = require("genkit");
const { GEMINI_API_KEY } = require("../config/keys");
const admin = require("firebase-admin");
const { Buffer } = require("buffer");
const { setTimeout } = require("timers/promises");
const { googleAI } = require('@genkit-ai/googleai')
const { genkit, z } = require('genkit')
const { GEMINI_API_KEY } = require('../config/keys')
const admin = require('firebase-admin')
const { Buffer } = require('buffer')
const { setTimeout } = require('timers/promises')
// --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
const TEXT_MODEL_NAME = "gemini-3-pro-preview";
const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview";
const TEXT_MODEL_NAME = 'gemini-3-pro-preview'
const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview'
// --- SINGLETON PATTERN (WARM START) ---
// On stocke l'instance en dehors de la fonction pour la réutiliser
// entre les invocations si le conteneur est "chaud".
let aiInstance = null;
let aiInstance = null
const getAiInstance = () => {
if (!aiInstance) {
console.log("⚡ [Gemini] Initialisation froide (Cold Start)");
console.log('⚡ [Gemini] Initialisation froide (Cold Start)')
aiInstance = genkit({
plugins: [googleAI({ apiKey: GEMINI_API_KEY })],
});
})
}
return aiInstance;
};
return aiInstance
}
/**
* Génération de texte générique
*/
exports.generateAI = async ({ system = "", prompt = "", schema }) => {
const ai = getAiInstance(); // Récupère l'instance singleton
exports.generateAI = async ({ system = '', prompt = '', schema }) => {
const ai = getAiInstance() // Récupère l'instance singleton
if (prompt?.length < 1) {
throw new Error(
"Vous devez spécifier un prompt pour effectuer cette action.",
);
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
}
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now()
try {
const { output } = await ai.generate({
@@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => {
config: {
temperature: 0.7, // Créativité équilibrée
},
});
})
return output;
return output
} catch (error) {
console.error("❌ [generateAI] Error:", error.message);
throw error;
console.error('❌ [generateAI] Error:', error.message)
throw error
} finally {
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`);
console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`)
}
};
}
/**
* Analyse la toxicité des paroles.
* Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse.
*/
exports.analyseLyrics = async ({ title = "", lyrics }) => {
const ai = getAiInstance();
exports.analyseLyrics = async ({ title = '', lyrics }) => {
const ai = getAiInstance()
// --- Normalisation ---
const normalizeLyrics = (raw) => {
if (!raw) return "";
if (typeof raw === "string") return raw;
if (!raw) return ''
if (typeof raw === 'string') return raw
if (Array.isArray(raw)) {
return raw
.map((s) => {
if (!s) return "";
const label = s.type ? String(s.type).toUpperCase() : "SECTION";
return `[${label}]\n${s.lyrics || ""}`;
if (!s) return ''
const label = s.type ? String(s.type).toUpperCase() : 'SECTION'
return `[${label}]\n${s.lyrics || ''}`
})
.filter(Boolean)
.join("\n\n");
.join('\n\n')
}
if (typeof raw === "object") {
const parts = [];
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`);
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`);
return parts.join("\n\n");
if (typeof raw === 'object') {
const parts = []
if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`)
if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`)
return parts.join('\n\n')
}
return String(raw || "");
};
return String(raw || '')
}
const lyricsText = normalizeLyrics(lyrics).trim();
if (!lyricsText) throw new Error("analyseLyrics: paroles requises.");
const lyricsText = normalizeLyrics(lyrics).trim()
if (!lyricsText) throw new Error('analyseLyrics: paroles requises.')
// --- Schéma ---
const moderationSchema = z.object({
title: z.string().describe("Titre analysé"),
flagged: z
.boolean()
.describe("Vrai si le contenu nécessite un avertissement."),
blocked: z
.boolean()
.describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."),
score: z
.number()
.min(0)
.max(1)
.describe("Score de risque (0=Sûr, 1=Dangereux)."),
reasons: z.array(z.string()).describe("Liste concise des raisons."),
title: z.string().describe('Titre analysé'),
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
excerpts: z
.array(
z.object({
quote: z.string(),
category: z.string(),
severity: z.string(),
}),
})
)
.max(10),
success: z.boolean(),
});
})
// --- Prompt ---
const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety).
@@ -127,20 +117,20 @@ TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu
1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel.
2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide.
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`;
Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`
const userPrompt = `
ANALYSE CETTE CHANSON :
Titre : ${title || "Inconnu"}
Titre : ${title || 'Inconnu'}
PAROLES :
"""
${lyricsText}
"""
`;
`
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`)
const startedAt = Date.now()
try {
const { output } = await ai.generate({
@@ -151,108 +141,103 @@ ${lyricsText}
config: {
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
safetySettings: [
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_NONE",
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
threshold: 'BLOCK_NONE',
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_NONE",
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
threshold: 'BLOCK_NONE',
},
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
],
},
});
})
if (!output) throw new Error("Échec de l'analyse de modération.");
if (!output) throw new Error("Échec de l'analyse de modération.")
// Correction de cohérence
if (output.blocked) {
output.flagged = true;
if (output.score < 0.7) output.score = 0.85;
output.flagged = true
if (output.score < 0.7) output.score = 0.85
}
return output;
return output
} finally {
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`);
console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`)
}
};
}
/**
* Génération d'image via Imagen 3
*/
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
const ai = getAiInstance();
exports.generateImageV2 = async (prompt, size = 1024, path = '') => {
const ai = getAiInstance()
if (typeof prompt !== "string" || prompt.trim().length < 1) {
throw new Error("Prompt requis.");
if (typeof prompt !== 'string' || prompt.trim().length < 1) {
throw new Error('Prompt requis.')
}
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`);
const startedAt = Date.now();
console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`)
const startedAt = Date.now()
// Optimisation du prompt pour Imagen
let enhancedPrompt = prompt.trim();
if (!enhancedPrompt.toLowerCase().includes("high quality")) {
enhancedPrompt += ", high quality, detailed, 4k";
let enhancedPrompt = prompt.trim()
if (!enhancedPrompt.toLowerCase().includes('high quality')) {
enhancedPrompt += ', high quality, detailed, 4k'
}
// Aspect ratio 1:1 pour les pochettes
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`;
enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`
const maxAttempts = 3;
let lastError = null;
const maxAttempts = 3
let lastError = null
try {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`);
console.log(`🔄 Tentative ${attempt}/${maxAttempts}`)
const response = await ai.generate({
model: googleAI.model(IMAGE_MODEL_NAME),
prompt: enhancedPrompt,
});
})
const media = response.media;
const media = response.media
if (media && media.url) {
console.log("✅ Image générée.");
console.log('✅ Image générée.')
// --- Sauvegarde dans Firebase Storage ---
const dataUrl = String(media.url);
const commaIdx = dataUrl.indexOf(",");
const b64 =
commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl;
const buffer = Buffer.from(b64, "base64");
const dataUrl = String(media.url)
const commaIdx = dataUrl.indexOf(',')
const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl
const buffer = Buffer.from(b64, 'base64')
const bucket = admin.storage().bucket();
const token = require("crypto").randomUUID();
const file = bucket.file(path);
const bucket = admin.storage().bucket()
const token = require('crypto').randomUUID()
const file = bucket.file(path)
await file.save(buffer, {
resumable: false,
metadata: {
contentType: media.contentType || "image/png",
contentType: media.contentType || 'image/png',
metadata: { firebaseStorageDownloadTokens: token },
},
});
})
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`
}
throw new Error("Pas de média dans la réponse IA.");
throw new Error('Pas de média dans la réponse IA.')
} catch (err) {
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message);
lastError = err;
if (attempt < maxAttempts) await setTimeout(2000 * attempt);
console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message)
lastError = err
if (attempt < maxAttempts) await setTimeout(2000 * attempt)
}
}
throw new Error(
`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`,
);
throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`)
} finally {
console.log(
`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`,
);
console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`)
}
};
}
+47 -69
View File
@@ -1,137 +1,115 @@
exports.generatePicturePrompt = (project = {}) => {
const {
title = "",
title = '',
lyrics: lyricsRaw,
musicConfig = {},
coverStyle: coverStyleRaw = "",
artistName: artistNameRaw = "",
} = project || {};
coverStyle: coverStyleRaw = '',
artistName: artistNameRaw = '',
} = project || {}
// --- 1. Nettoyage et Normalisation ---
const sanitizeInline = (value = "") => {
if (typeof value !== "string") return "";
const sanitizeInline = (value = '') => {
if (typeof value !== 'string') return ''
return value
.replace(/[\r\n]+/g, " ")
.replace(/[<>]/g, "")
.trim();
};
.replace(/[\r\n]+/g, ' ')
.replace(/[<>]/g, '')
.trim()
}
const titleForPrompt = sanitizeInline(title) || "Sans titre";
const artistName =
sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || "");
const hasArtistName = artistName.length > 0;
const titleForPrompt = sanitizeInline(title) || 'Sans titre'
const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '')
const hasArtistName = artistName.length > 0
const {
genres = [],
tempo = "",
mood = "",
instruments = [],
} = musicConfig || {};
const userStyle = sanitizeInline(coverStyleRaw);
const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {}
const userStyle = sanitizeInline(coverStyleRaw)
// --- 2. Intelligence Visuelle (Mapping) ---
// Détermination de l'énergie visuelle
const isEnergetic =
tempo &&
/\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(
String(tempo),
);
tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo))
const isDark =
mood &&
/\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(
String(mood),
);
mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood))
// Construction de la Palette & Lumière
let visualAtmosphere = "";
let visualAtmosphere = ''
if (isDark) {
visualAtmosphere =
"Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.";
'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.'
} else if (isEnergetic) {
visualAtmosphere =
"Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.";
'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.'
} else {
visualAtmosphere =
"Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.";
'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.'
}
// Définition du Style de Rendu (Si l'utilisateur est vague, on renforce)
let renderingStyle = userStyle
? `Art Style: ${userStyle}`
: "Art Style: Digital Art, Mixed Media";
let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media'
if (
userStyle.toLowerCase().includes("realist") ||
userStyle.toLowerCase().includes("photo")
) {
if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) {
renderingStyle +=
", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.";
', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.'
} else if (
userStyle.toLowerCase().includes("illu") ||
userStyle.toLowerCase().includes("dessin")
userStyle.toLowerCase().includes('illu') ||
userStyle.toLowerCase().includes('dessin')
) {
renderingStyle +=
", vector art, clean lines, professional illustration, flat design or detailed painting.";
', vector art, clean lines, professional illustration, flat design or detailed painting.'
} else {
// Style par défaut "Album Cover" qui marche bien
renderingStyle +=
", abstract surrealism, conceptual album art, high fidelity, masterpiece.";
renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.'
}
// --- 3. Extraction de l'Inspiration (Lyrics) ---
// On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [];
const chorus = sections.find(
(s) => s.type === "refrain" || s.type === "chorus",
);
const verse = sections.find(
(s) => s.type === "couplet" || s.type === "verse",
);
const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []
const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus')
const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse')
// On prend 2 lignes max du refrain, ou du premier couplet
const visualHook = (chorus?.lyrics || verse?.lyrics || "")
.split("\n")
const visualHook = (chorus?.lyrics || verse?.lyrics || '')
.split('\n')
.filter((l) => l.length > 10) // On évite les lignes trop courtes
.slice(0, 2)
.join(". ");
.join('. ')
const imageryPrompt = visualHook
? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".`
: "Visual Inspiration: Abstract visual representation of the song's mood.";
: "Visual Inspiration: Abstract visual representation of the song's mood."
// --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) ---
const promptParts = [
// Rôle
"Design a professional, high-quality music album cover.",
'Design a professional, high-quality music album cover.',
// 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair)
`**Typography & Text:**`,
`The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`,
hasArtistName
? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.`
: "",
"Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.",
: '',
'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.',
// 2. Le Visuel
`**Visuals:**`,
renderingStyle,
imageryPrompt,
`Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`,
`Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`,
// 3. L'Atmosphère (Context from music config)
`**Mood & Color:**`,
visualAtmosphere,
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "",
genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '',
// 4. Contraintes Négatives (Phrasées positivement pour l'IA)
"**Constraints:**",
"Use a square 1:1 aspect ratio.",
"Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.",
"No blurry text. No messy borders. No watermarks.",
];
'**Constraints:**',
'Use a square 1:1 aspect ratio.',
'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.',
'No blurry text. No messy borders. No watermarks.',
]
return promptParts.filter(Boolean).join("\n\n");
};
return promptParts.filter(Boolean).join('\n\n')
}
+19 -19
View File
@@ -1,26 +1,26 @@
const normalizeDate = (input) =>
typeof input?.toDate === "function" ? input.toDate() : new Date(input);
typeof input?.toDate === 'function' ? input.toDate() : new Date(input)
const buildMonthKey = (timestamp) => {
const date = normalizeDate(timestamp);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
const date = normalizeDate(timestamp)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
const buildPreviousMonthContext = (referenceDate) => {
const current = referenceDate ? new Date(referenceDate) : new Date();
current.setUTCHours(0, 0, 0, 0);
current.setUTCDate(1);
const current = referenceDate ? new Date(referenceDate) : new Date()
current.setUTCHours(0, 0, 0, 0)
current.setUTCDate(1)
const target = new Date(current);
target.setUTCMonth(target.getUTCMonth() - 1);
const target = new Date(current)
target.setUTCMonth(target.getUTCMonth() - 1)
const year = target.getUTCFullYear();
const monthIndex = target.getUTCMonth();
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0));
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999));
const monthKey = buildMonthKey(rangeStart);
const year = target.getUTCFullYear()
const monthIndex = target.getUTCMonth()
const rangeStart = new Date(Date.UTC(year, monthIndex, 1, 0, 0, 0, 0))
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0, 23, 59, 59, 999))
const monthKey = buildMonthKey(rangeStart)
return {
year,
@@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => {
monthKey,
rangeStart,
rangeEnd,
};
};
}
}
module.exports = {
buildMonthKey,
buildPreviousMonthContext,
};
}
+195 -236
View File
@@ -1,281 +1,263 @@
const admin = require("firebase-admin");
const { HttpsError } = require("firebase-functions/https");
const Stripe = require("stripe");
const { URL } = require("url");
const admin = require('firebase-admin')
const { HttpsError } = require('firebase-functions/https')
const Stripe = require('stripe')
const { URL } = require('url')
const {
STRIPE_SECRET_KEY = "",
STRIPE_RETURN_URL = "",
STRIPE_PORTAL_CONFIGURATION = "",
STRIPE_SECRET_KEY = '',
STRIPE_RETURN_URL = '',
STRIPE_PORTAL_CONFIGURATION = '',
STRIPE_MODE: CONFIG_STRIPE_MODE,
} = require("../config/keys");
} = require('../config/keys')
const STRIPE_MODE =
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
typeof CONFIG_STRIPE_MODE === 'string' && CONFIG_STRIPE_MODE.trim()
? CONFIG_STRIPE_MODE.trim()
: "test";
: 'test'
const requireEnv = (key) => {
const value = process.env?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
const value = process.env?.[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
throw new Error(`${key} not configured`);
};
throw new Error(`${key} not configured`)
}
const STRIPE_API_VERSION = "2023-10-16";
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
const STRIPE_API_VERSION = '2023-10-16'
const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081'
const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit']
let cachedStripeClient = null;
let cachedPortalConfigurationId = null;
let cachedStripeClient = null
let cachedPortalConfigurationId = null
const resolveStripeSecretKey = () => {
const inlineKey =
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : ''
if (inlineKey) {
return inlineKey;
return inlineKey
}
const required = requireEnv("STRIPE_SECRET_KEY");
if (typeof required === "string" && required.trim()) {
return required.trim();
const required = requireEnv('STRIPE_SECRET_KEY')
if (typeof required === 'string' && required.trim()) {
return required.trim()
}
throw new Error("STRIPE_SECRET_KEY not configured");
};
throw new Error('STRIPE_SECRET_KEY not configured')
}
const getStripeClient = () => {
if (cachedStripeClient) {
return cachedStripeClient;
return cachedStripeClient
}
let secretKey;
let secretKey
try {
secretKey = resolveStripeSecretKey();
secretKey = resolveStripeSecretKey()
} catch (error) {
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error)
throw new HttpsError(
"failed-precondition",
"Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
);
'failed-precondition',
'Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.'
)
}
cachedStripeClient = new Stripe(secretKey, {
apiVersion: STRIPE_API_VERSION,
});
})
return cachedStripeClient;
};
return cachedStripeClient
}
const getReturnBaseUrl = () => {
const resolveBase = () => {
if (STRIPE_RETURN_URL) {
return STRIPE_RETURN_URL;
return STRIPE_RETURN_URL
}
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
if (STRIPE_MODE !== 'prod') {
return DEFAULT_TEST_RETURN_URL
}
return requireEnv("STRIPE_RETURN_URL");
};
const rawBase = resolveBase();
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
if (!sanitizedBase) {
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
}
throw new Error("STRIPE_RETURN_URL not configured");
return requireEnv('STRIPE_RETURN_URL')
}
return sanitizedBase.endsWith("/")
? sanitizedBase.slice(0, -1)
: sanitizedBase;
};
const rawBase = resolveBase()
const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : ''
if (!sanitizedBase) {
if (STRIPE_MODE !== 'prod') {
return DEFAULT_TEST_RETURN_URL
}
throw new Error('STRIPE_RETURN_URL not configured')
}
return sanitizedBase.endsWith('/') ? sanitizedBase.slice(0, -1) : sanitizedBase
}
const sanitizeReturnUrl = (value) => {
if (typeof value !== "string") {
return null;
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim();
const trimmed = value.trim()
if (!trimmed) {
return null;
return null
}
const validateScheme = (scheme) => {
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
throw new HttpsError(
"invalid-argument",
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
);
'invalid-argument',
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`
)
}
};
}
try {
const parsedUrl = new URL(trimmed);
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
validateScheme(scheme);
return trimmed;
const parsedUrl = new URL(trimmed)
const scheme = parsedUrl.protocol.replace(':', '').toLowerCase()
validateScheme(scheme)
return trimmed
} catch (_error) {
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i)
if (schemeMatch && schemeMatch[1]) {
const scheme = schemeMatch[1].toLowerCase();
validateScheme(scheme);
return trimmed;
const scheme = schemeMatch[1].toLowerCase()
validateScheme(scheme)
return trimmed
}
throw new HttpsError(
"invalid-argument",
`URL de retour Stripe invalide: ${trimmed}`,
);
throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`)
}
};
}
const getReturnUrls = (overrides) => {
if (overrides && typeof overrides === "object") {
const successOverride = sanitizeReturnUrl(overrides.successUrl);
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
if (overrides && typeof overrides === 'object') {
const successOverride = sanitizeReturnUrl(overrides.successUrl)
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl)
if (!successOverride) {
throw new HttpsError(
"invalid-argument",
"successUrl est requis pour configurer les retours Stripe.",
);
'invalid-argument',
'successUrl est requis pour configurer les retours Stripe.'
)
}
return {
successUrl: successOverride,
cancelUrl: cancelOverride || successOverride,
};
}
}
const baseUrl = getReturnBaseUrl();
const baseUrl = getReturnBaseUrl()
const joinPath = (url, path) => {
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
return `${trimmedUrl}/${trimmedPath}`;
};
const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url
const trimmedPath = path.startsWith('/') ? path.slice(1) : path
return `${trimmedUrl}/${trimmedPath}`
}
const appendQuery = (url, query) =>
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`)
const successBase = joinPath(baseUrl, "payment-success");
const cancelBase = joinPath(baseUrl, "payment-error");
const successBase = joinPath(baseUrl, 'payment-success')
const cancelBase = joinPath(baseUrl, 'payment-error')
return {
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
};
};
successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'),
cancelUrl: appendQuery(cancelBase, 'session_id={CHECKOUT_SESSION_ID}'),
}
}
const normalizeBoolean = (value) => value === true;
const normalizeBoolean = (value) => value === true
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
if (!Array.isArray(productList) || productList.length === 0) {
throw new HttpsError(
"invalid-argument",
"Au moins un produit est requis pour créer une session de paiement.",
);
'invalid-argument',
'Au moins un produit est requis pour créer une session de paiement.'
)
}
const lineItems = [];
const summary = [];
let hasSubscription = false;
const lineItems = []
const summary = []
let hasSubscription = false
for (let index = 0; index < productList.length; index += 1) {
const rawItem = productList[index];
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
const rawItem = productList[index]
const item = rawItem && typeof rawItem === 'object' ? rawItem : {}
const isRenewable = normalizeBoolean(item.isRenewable);
const isRenewable = normalizeBoolean(item.isRenewable)
const rawQuantity =
typeof item.quantity === "number" && Number.isFinite(item.quantity)
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? item.quantity
: parseInt(item.quantity, 10);
const quantity =
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
: parseInt(item.quantity, 10)
const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
const priceId = typeof item.priceID === 'string' ? item.priceID.trim() : ''
if (isRenewable && !priceId) {
throw new HttpsError(
"invalid-argument",
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
);
'invalid-argument',
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`
)
}
if (priceId) {
let stripePrice = null;
let stripePrice = null
if (stripe) {
try {
stripePrice = await stripe.prices.retrieve(priceId);
stripePrice = await stripe.prices.retrieve(priceId)
} catch (error) {
console.error(
"[buildCheckoutLineItems] Unable to retrieve price",
priceId,
error,
);
console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error)
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
);
'invalid-argument',
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`
)
}
}
const priceIsRecurring =
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring
if (isRenewable && !priceIsRecurring) {
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
);
'invalid-argument',
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`
)
}
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable
if (resolvedIsRenewable) {
hasSubscription = true;
hasSubscription = true
}
lineItems.push({
price: priceId,
quantity,
});
})
summary.push({
type: "price",
type: 'price',
priceID: priceId,
quantity,
isRenewable: resolvedIsRenewable,
});
continue;
})
continue
}
const rawUnitAmount = Number(item.unitAmount);
const unitAmount = Math.round(rawUnitAmount);
const rawUnitAmount = Number(item.unitAmount)
const unitAmount = Math.round(rawUnitAmount)
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
throw new HttpsError(
"invalid-argument",
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
);
'invalid-argument',
`Le montant indiqué pour l'élément ${index + 1} est invalide.`
)
}
const currency =
typeof item.currency === "string"
? item.currency.trim().toLowerCase()
: "eur";
const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur'
if (!/^[a-z]{3}$/.test(currency)) {
throw new HttpsError(
"invalid-argument",
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
);
'invalid-argument',
`La devise indiquée pour l'élément ${index + 1} est invalide.`
)
}
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: "Paiement ponctuel";
typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel'
lineItems.push({
price_data: {
@@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
unit_amount: unitAmount,
},
quantity,
});
})
summary.push({
type: "custom",
type: 'custom',
currency,
unitAmount,
quantity,
isRenewable: false,
});
})
}
if (hasSubscription) {
const hasNonSubscription = summary.some((item) => !item.isRenewable);
const hasNonSubscription = summary.some((item) => !item.isRenewable)
if (hasNonSubscription) {
throw new HttpsError(
"invalid-argument",
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
);
'invalid-argument',
'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.'
)
}
}
return { lineItems, summary, hasSubscription };
};
return { lineItems, summary, hasSubscription }
}
const ensureStripeCustomer = async ({
uid,
stripe,
refsList,
createIfMissing = true,
}) => {
const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => {
if (!uid) {
return { customerId: null, userData: null };
return { customerId: null, userData: null }
}
const userRef = refsList?.users?.doc(uid);
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() : null;
const userRef = refsList?.users?.doc(uid)
const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() : null
let customerId = userData?.stripeCustomerId;
let customerId = userData?.stripeCustomerId
if (customerId) {
return { customerId, userData };
return { customerId, userData }
}
if (!createIfMissing) {
return { customerId: null, userData };
return { customerId: null, userData }
}
let authRecord = null;
let authRecord = null
try {
authRecord = await admin.auth().getUser(uid);
authRecord = await admin.auth().getUser(uid)
} catch (error) {
console.warn(
"[ensureStripeCustomer] Impossible de récupérer auth user",
error,
);
console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error)
}
const email = userData?.email || authRecord?.email || undefined;
const nameFromProfile = [userData?.firstName, userData?.lastName]
.filter(Boolean)
.join(" ")
.trim();
const name = nameFromProfile || authRecord?.displayName || undefined;
const email = userData?.email || authRecord?.email || undefined
const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim()
const name = nameFromProfile || authRecord?.displayName || undefined
const customer = await stripe.customers.create({
email,
name,
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
appMode: STRIPE_MODE || 'test',
},
});
})
customerId = customer.id;
customerId = customer.id
if (userRef) {
await userRef.set(
{
stripeCustomerId: customerId,
},
{ merge: true },
);
{ merge: true }
)
}
return {
customerId,
userData: { ...userData, stripeCustomerId: customerId },
};
};
}
}
const formatCheckoutSessionResponse = (session) => ({
id: session.id,
@@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({
created: session.created,
expires_at: session.expires_at,
client_secret: session.client_secret || null,
});
})
const getPortalConfigurationId = async (stripe) => {
if (STRIPE_PORTAL_CONFIGURATION) {
return STRIPE_PORTAL_CONFIGURATION;
return STRIPE_PORTAL_CONFIGURATION
}
if (cachedPortalConfigurationId) {
return cachedPortalConfigurationId;
return cachedPortalConfigurationId
}
if (
!stripe ||
typeof stripe.billingPortal?.configurations?.list !== "function"
) {
throw new HttpsError(
"internal",
"Client Stripe indisponible pour la configuration du portail.",
);
if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') {
throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.')
}
try {
const configurations = await stripe.billingPortal.configurations.list({
limit: 100,
});
})
const defaultConfiguration =
configurations.data.find((config) => config.is_default) ||
configurations.data.find((config) => config.active);
configurations.data.find((config) => config.active)
if (defaultConfiguration?.id) {
cachedPortalConfigurationId = defaultConfiguration.id;
return cachedPortalConfigurationId;
cachedPortalConfigurationId = defaultConfiguration.id
return cachedPortalConfigurationId
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
error?.message || error,
);
'[getPortalConfigurationId] Impossible de lister les configurations de portail',
error?.message || error
)
}
try {
const defaultReturnUrl = getReturnBaseUrl();
const createdConfiguration =
await stripe.billingPortal.configurations.create({
default_return_url: defaultReturnUrl,
business_profile: {
headline: "Minuit Starter",
},
});
const defaultReturnUrl = getReturnBaseUrl()
const createdConfiguration = await stripe.billingPortal.configurations.create({
default_return_url: defaultReturnUrl,
business_profile: {
headline: 'Minuit Starter',
},
})
if (createdConfiguration?.id) {
cachedPortalConfigurationId = createdConfiguration.id;
return cachedPortalConfigurationId;
cachedPortalConfigurationId = createdConfiguration.id
return cachedPortalConfigurationId
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
error?.message || error,
);
'[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut',
error?.message || error
)
}
return null;
};
return null
}
const mapStripeErrorToHttps = (error, fallbackMessage) => {
const message =
error?.raw?.message ||
error?.message ||
fallbackMessage ||
"Erreur Stripe.";
const statusCode = error?.statusCode || error?.raw?.statusCode;
const isClientError =
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.'
const statusCode = error?.statusCode || error?.raw?.statusCode
const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500
const code = isClientError ? "failed-precondition" : "internal";
return new HttpsError(code, message);
};
const code = isClientError ? 'failed-precondition' : 'internal'
return new HttpsError(code, message)
}
module.exports = {
getStripeClient,
@@ -479,4 +438,4 @@ module.exports = {
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
};
}
+41 -43
View File
@@ -1,54 +1,52 @@
const admin = require("firebase-admin");
const admin = require('firebase-admin')
// Use default credentials/environment provided by Cloud Functions.
// Avoid bundling a service account key and hardcoding project/bucket.
admin.initializeApp();
admin.initializeApp()
const db = admin.firestore();
const REGION = process.env.FIREBASE_REGION || "europe-west1";
const db = admin.firestore()
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
exports.db = db;
exports.REGION = REGION;
exports.db = db
exports.REGION = REGION
exports.refList = {
projects: db.collection("projects"),
playlists: db.collection("playlists"),
tasks: db.collection("tasks"),
notifications: db.collection("notifications"),
users: db.collection("users"),
projectStreamStats: db.collection("projectStreamStats"),
projectStreamStatsMonthlyTotals: db.collection(
"projectStreamStatsMonthlyTotals",
),
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
monthlyPayouts: db.collection("monthlyPayouts"),
};
exports.refsList = exports.refList;
projects: db.collection('projects'),
playlists: db.collection('playlists'),
tasks: db.collection('tasks'),
notifications: db.collection('notifications'),
users: db.collection('users'),
projectStreamStats: db.collection('projectStreamStats'),
projectStreamStatsMonthlyTotals: db.collection('projectStreamStatsMonthlyTotals'),
monthlyPayoutEntries: db.collection('monthlyPayoutEntries'),
monthlyPayouts: db.collection('monthlyPayouts'),
}
exports.refsList = exports.refList
exports.ALERT_TYPE = {
NEW_LIKE: "NEW_LIKE",
NEW_COMMENT: "NEW_COMMENT",
NEW_FOLLOWER: "NEW_FOLLOWER",
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS",
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
CREDITS_UPDATED: "CREDITS_UPDATED",
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
};
NEW_LIKE: 'NEW_LIKE',
NEW_COMMENT: 'NEW_COMMENT',
NEW_FOLLOWER: 'NEW_FOLLOWER',
MUSIC_GENERATION_SUCCESS: 'MUSIC_GENERATION_SUCCESS',
MUSIC_GENERATION_FAILED: 'MUSIC_GENERATION_FAILED',
COVER_GENERATION_SUCCESS: 'COVER_GENERATION_SUCCESS',
COVER_GENERATION_FAILED: 'COVER_GENERATION_FAILED',
CREDITS_UPDATED: 'CREDITS_UPDATED',
PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE',
}
// Exporter toutes les fonctions
exports.users = require("./src/users");
exports.music = require("./src/music");
exports.lyrics = require("./src/lyrics");
exports.cover = require("./src/cover");
exports.projects = require("./src/project");
exports.thumbnail = require("./src/thumbnail");
exports.upload = require("./src/upload");
exports.algolia = require("./src/algolia");
exports.notifications = require("./src/notifications");
exports.rankings = require("./src/rankings");
exports.payouts = require("./src/payouts");
exports.subscription = require("./src/subscription");
exports.youtube = require("./src/youtube");
exports.orders = require("./src/orders");
exports.users = require('./src/users')
exports.music = require('./src/music')
exports.lyrics = require('./src/lyrics')
exports.cover = require('./src/cover')
exports.projects = require('./src/project')
exports.thumbnail = require('./src/thumbnail')
exports.upload = require('./src/upload')
exports.algolia = require('./src/algolia')
exports.notifications = require('./src/notifications')
exports.rankings = require('./src/rankings')
exports.payouts = require('./src/payouts')
exports.subscription = require('./src/subscription')
exports.youtube = require('./src/youtube')
exports.orders = require('./src/orders')
+20 -23
View File
@@ -1,26 +1,23 @@
const { onRequest } = require("firebase-functions/v2/https");
const { onRequest } = require('firebase-functions/v2/https')
exports.algoliaTransformProjectData = onRequest(
{ region: "europe-west1" },
(req, res) => {
const payload = req.body.data;
const objectID = payload.objectID;
try {
const flat = { ...payload };
delete flat["musicTimestamps"];
exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => {
const payload = req.body.data
const objectID = payload.objectID
try {
const flat = { ...payload }
delete flat['musicTimestamps']
console.log(`Change in ${payload.objectID}`);
console.log(flat);
// Ton object final doit contenir "objectID"
const result = {
objectID,
...flat,
};
res.send({ result });
} catch (e) {
console.log(`Error ${objectID}:`, e.message);
res.status(500).end();
console.log(`Change in ${payload.objectID}`)
console.log(flat)
// Ton object final doit contenir "objectID"
const result = {
objectID,
...flat,
}
},
);
res.send({ result })
} catch (e) {
console.log(`Error ${objectID}:`, e.message)
res.status(500).end()
}
})
+154 -171
View File
@@ -1,98 +1,97 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const logger = require("firebase-functions/logger");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");
const { onDocumentCreated } = require('firebase-functions/v2/firestore')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const logger = require('firebase-functions/logger')
const path = require('path')
const fs = require('fs')
const axios = require('axios')
const sharp = require('sharp')
const crypto = require('crypto')
// Imports internes
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
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 LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png");
const bucket = admin.storage().bucket()
const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png')
// Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude)
let _cachedLogoBuffer = null;
let _cachedLogoBuffer = null
/**
* Récupère le buffer du logo depuis le cache ou le disque
*/
const getLogoBuffer = async () => {
if (_cachedLogoBuffer) return _cachedLogoBuffer;
if (_cachedLogoBuffer) return _cachedLogoBuffer
try {
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH);
return _cachedLogoBuffer;
_cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH)
return _cachedLogoBuffer
} catch (error) {
logger.error("❌ [Cover] Impossible de lire le fichier logo", error);
throw new Error("Asset Logo manquant sur le serveur");
logger.error('❌ [Cover] Impossible de lire le fichier logo', error)
throw new Error('Asset Logo manquant sur le serveur')
}
};
}
/**
* Utilitaires Strings
*/
const pickFirstNonEmpty = (...values) => {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim()
}
}
return "";
};
return ''
}
const combineNames = (...parts) =>
parts
.map((part) => (typeof part === "string" ? part.trim() : ""))
.map((part) => (typeof part === 'string' ? part.trim() : ''))
.filter(Boolean)
.join(" ")
.trim();
.join(' ')
.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 owner = project?.owner || {}
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName,
);
if (direct) return direct;
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 "";
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
if (!userId) return ''
try {
const userSnapshot = await refList.users.doc(userId).get();
if (!userSnapshot?.exists) return "";
const userSnapshot = await refList.users.doc(userId).get()
if (!userSnapshot?.exists) return ''
const userData = userSnapshot.data() || {};
const userData = userSnapshot.data() || {}
return (
pickFirstNonEmpty(
userData.artistName,
userData.userName,
userData.displayName,
combineNames(userData.firstName, userData.lastName),
) || ""
);
combineNames(userData.firstName, userData.lastName)
) || ''
)
} catch (error) {
logger.warn("⚠️ [Cover] Artist name resolution failed", {
logger.warn('⚠️ [Cover] Artist name resolution failed', {
projectId: project?.id,
error: error.message,
});
return "";
})
return ''
}
}
@@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) {
* Ajoute le logo en filigrane sur l'image générée
*/
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Compositing logo...");
logger.info('🖼️ [Cover] Compositing logo...')
try {
// Téléchargement background + Lecture Logo (parallèle)
const [bgResponse, logoBuffer] = await Promise.all([
axios.get(backgroundUrl, { responseType: "arraybuffer" }),
axios.get(backgroundUrl, { responseType: 'arraybuffer' }),
getLogoBuffer(),
]);
])
const baseImage = sharp(bgResponse.data);
const metadata = await baseImage.metadata();
const width = metadata.width || 1024;
const height = metadata.height || 1024;
const baseImage = sharp(bgResponse.data)
const metadata = await baseImage.metadata()
const width = metadata.width || 1024
const height = metadata.height || 1024
// Calcul dynamique de la taille du logo (32% de la largeur)
const desiredWidth = Math.round(width * 0.32);
const margin = Math.round(width * 0.04);
const desiredWidth = Math.round(width * 0.32)
const margin = Math.round(width * 0.04)
// Redimensionnement du logo
const resizedLogo = await sharp(logoBuffer)
.resize({ width: desiredWidth })
.png()
.toBuffer();
const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer()
// Positionnement (Bas Droite)
const logoMetadata = await sharp(resizedLogo).metadata();
const left = Math.max(width - logoMetadata.width - margin, 0);
const top = Math.max(height - logoMetadata.height - margin, 0);
const logoMetadata = await sharp(resizedLogo).metadata()
const left = Math.max(width - logoMetadata.width - margin, 0)
const top = Math.max(height - logoMetadata.height - margin, 0)
// Composition
const stampedBuffer = await baseImage
.ensureAlpha()
.composite([{ input: resizedLogo, left, top, blend: "over" }])
.composite([{ input: resizedLogo, left, top, blend: 'over' }])
.png()
.toBuffer();
.toBuffer()
// Upload vers Storage
const token = crypto.randomUUID();
const file = bucket.file(targetPath);
const token = crypto.randomUUID()
const file = bucket.file(targetPath)
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: "image/png",
cacheControl: "public, max-age=31536000",
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}`;
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
} catch (error) {
logger.error("❌ [Cover] buildCoverWithLogo failed", error);
logger.error('❌ [Cover] buildCoverWithLogo failed', error)
// En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre
return backgroundUrl;
return backgroundUrl
}
}
@@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) {
* Cœur de la logique de génération
*/
async function performCoverGeneration(project) {
const t0 = Date.now();
const artistName = await resolveArtistName(project);
const t0 = Date.now()
const artistName = await resolveArtistName(project)
// Génération du Prompt optimisé
const prompt = generatePicturePrompt({
...project,
artistName,
});
})
logger.info("🎨 [Cover] Prompt generated", {
logger.info('🎨 [Cover] Prompt generated', {
projectId: project.id,
artistName,
promptPreview: prompt.slice(0, 100) + "...",
});
promptPreview: prompt.slice(0, 100) + '...',
})
const baseTimestamp = Date.now();
const GENERATION_COUNT = 2; // Nombre de variantes simultanées
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 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`
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath);
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");
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(
generatedUrl,
stampedPath,
);
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath)
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`);
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
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
}
})
// Attente de la résolution de toutes les générations
const results = await Promise.all(generationPromises);
const results = await Promise.all(generationPromises)
// On garde uniquement les tentatives réussies (non null)
const options = results.filter(Boolean);
const options = results.filter(Boolean)
if (options.length === 0) {
throw new Error("Toutes les tentatives de génération ont échoué.");
throw new Error('Toutes les tentatives de génération ont échoué.')
}
// Sauvegarde dans Firestore
const [firstOption] = options;
const [firstOption] = options
await refList.projects.doc(project.id).set(
{
@@ -238,19 +229,19 @@ async function performCoverGeneration(project) {
// selectedOptionId: firstOption.id, // disable default selection
options, // Sauvegarde de toutes les variantes réussies
},
coverStatus: "GENERATED",
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
logger.info("🏁 [Cover] Process complete", {
logger.info('🏁 [Cover] Process complete', {
projectId: project.id,
successCount: options.length,
duration: Date.now() - t0,
});
})
return firstOption.finalUrl;
return firstOption.finalUrl
}
/**
@@ -260,77 +251,72 @@ async function performCoverGeneration(project) {
exports.onTaskCreateGenerateCover = onDocumentCreated(
{
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent)
memory: "1GiB",
document: "tasks/{taskId}",
memory: '1GiB',
document: 'tasks/{taskId}',
},
async (event) => {
const data = event.data?.data() || {};
const { type, projectId } = data;
const taskId = event.params.taskId;
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) return // Ignorer les tâches mal formées
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId });
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId })
try {
// 1. Validation & Setup
if (type === "combine") {
if (type === 'combine') {
// Feature désactivée pour le moment
await event.data.ref.update({
status: "CANCELLED",
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
});
return;
})
return
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
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 projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) throw new Error('Projet introuvable')
const project = { id: projectId, ...projectSnap.data() };
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" });
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;
status: 'DONE',
info: 'Already generated',
})
return
}
// 3. Exécution Génération
const coverUrl = await performCoverGeneration(project);
const coverUrl = await performCoverGeneration(project)
// 4. Finalisation Tâche
await event.data.ref.update({
status: "DONE",
status: 'DONE',
coverUrl,
updatedAt: FieldValue.serverTimestamp(),
});
})
// 5. Notification
if (project.userId) {
const projectTitle = project.title || "ton projet";
const projectTitle = project.title || 'ton projet'
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: project.userId,
receiverCollection: "users",
title: "Pochette prête !",
receiverCollection: 'users',
title: 'Pochette prête !',
message: `La pochette pour "${projectTitle}" a été générée avec succès.`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
@@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectTitle,
coverUrl,
},
}).catch((err) => logger.warn("Notification failed", err));
}).catch((err) => logger.warn('Notification failed', err))
}
} catch (error) {
logger.error(`🔥 [Task ${taskId}] Failed`, error);
logger.error(`🔥 [Task ${taskId}] Failed`, error)
// Mise à jour erreur Tâche
await event.data.ref.set(
{ status: "ERROR", error: error.message },
{ merge: true },
);
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true })
// Mise à jour erreur Projet
await refList.projects.doc(projectId).update({
coverStatus: "ERROR",
coverStatus: 'ERROR',
updatedAt: FieldValue.serverTimestamp(),
});
})
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data();
const projectData = (await refList.projects.doc(projectId).get()).data()
if (projectData?.userId) {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: projectData.userId,
receiverCollection: "users",
title: "Échec pochette",
message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`,
receiverCollection: 'users',
title: 'Échec pochette',
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`,
data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId,
error: error.message,
},
}).catch(() => { });
}).catch(() => {})
}
}
},
);
}
)
+38 -38
View File
@@ -1,60 +1,60 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const ORDER_TYPES = {
GIFT: "GIFT",
SONG: "SONG",
COINS: "COINS",
SUBSCRIPTION: "SUBSCRIPTION",
};
GIFT: 'GIFT',
SONG: 'SONG',
COINS: 'COINS',
SUBSCRIPTION: 'SUBSCRIPTION',
}
const ORDER_STATUS = {
PENDING: "PENDING",
APPLIED: "APPLIED",
REJECTED: "REJECTED",
};
PENDING: 'PENDING',
APPLIED: 'APPLIED',
REJECTED: 'REJECTED',
}
const ORDERS_COLLECTION = "orders";
const ORDERS_COLLECTION = 'orders'
const isFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return true;
if (typeof value === 'number' && Number.isFinite(value)) {
return true
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed);
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed)
}
return false;
};
return false
}
const normalizeAmount = (amount) => {
if (!isFiniteNumber(amount)) {
return null;
return null
}
return Number(amount);
};
return Number(amount)
}
const createOrderDocument = async ({
userId,
type,
amount,
songId = null,
createdBy = "system",
createdBy = 'system',
metadata = {},
orderId = null,
}) => {
if (!userId || typeof userId !== "string") {
throw new Error("[orders] Missing userId when creating order");
if (!userId || typeof userId !== 'string') {
throw new Error('[orders] Missing userId when creating order')
}
if (!Object.values(ORDER_TYPES).includes(type)) {
throw new Error(`[orders] Invalid order type "${type}"`);
throw new Error(`[orders] Invalid order type "${type}"`)
}
const normalizedAmount = normalizeAmount(amount);
const normalizedAmount = normalizeAmount(amount)
if (normalizedAmount === null || normalizedAmount === 0) {
throw new Error("[orders] Invalid order amount");
throw new Error('[orders] Invalid order amount')
}
const payload = {
@@ -63,25 +63,25 @@ const createOrderDocument = async ({
amount: normalizedAmount,
songId: type === ORDER_TYPES.SONG ? songId || null : null,
createdAt: FieldValue.serverTimestamp(),
createdBy: createdBy || "system",
createdBy: createdBy || 'system',
status: ORDER_STATUS.PENDING,
metadata: metadata || {},
};
}
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION)
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc()
if (orderId) {
const existingSnapshot = await orderRef.get();
const existingSnapshot = await orderRef.get()
if (existingSnapshot.exists) {
return { orderRef, orderId: orderRef.id };
return { orderRef, orderId: orderRef.id }
}
}
await orderRef.set(payload);
await orderRef.set(payload)
return { orderRef, orderId: orderRef.id };
};
return { orderRef, orderId: orderRef.id }
}
module.exports = {
ORDER_TYPES,
@@ -89,4 +89,4 @@ module.exports = {
ORDERS_COLLECTION,
createOrderDocument,
normalizeAmount,
};
}
+191 -220
View File
@@ -1,93 +1,86 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const { z } = require("genkit");
const { generateAI, analyseLyrics } = require("../helpers/gemini");
const {
SUNO_API_BASE,
SUNO_TIMESTAMPED_LYRICS_PATH,
} = require("../config/suno");
const { SUNO_API_KEY } = require("../config/keys");
const axios = require("axios");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { z } = require('genkit')
const { generateAI, analyseLyrics } = require('../helpers/gemini')
const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno')
const { SUNO_API_KEY } = require('../config/keys')
const axios = require('axios')
const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require('../index')
// --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
const STRUCTURE_PROMPT_LABELS = {
couplet: "couplet",
refrain: "refrain",
short_intro: "introduction instrumentale courte",
long_intro: "introduction instrumentale longue",
pre_refrain_instrumental: "pré-refrain instrumental",
pont: "pont",
solo_de_guitare: "solo de guitare",
solo_de_guitare_electrique: "solo de guitare électrique",
solo_de_batterie: "solo de batterie",
solo_de_saxophone: "solo de saxophone",
solo_de_violon: "solo de violon",
break: "break",
interlude: "interlude",
interlude_melodique: "interlude mélodique",
final_apogee: "final apogée",
arret_net: "arrêt net",
fade_out: "fade out",
transition_douce: "transition douce vers le silence",
};
couplet: 'couplet',
refrain: 'refrain',
short_intro: 'introduction instrumentale courte',
long_intro: 'introduction instrumentale longue',
pre_refrain_instrumental: 'pré-refrain instrumental',
pont: 'pont',
solo_de_guitare: 'solo de guitare',
solo_de_guitare_electrique: 'solo de guitare électrique',
solo_de_batterie: 'solo de batterie',
solo_de_saxophone: 'solo de saxophone',
solo_de_violon: 'solo de violon',
break: 'break',
interlude: 'interlude',
interlude_melodique: 'interlude mélodique',
final_apogee: 'final apogée',
arret_net: 'arrêt net',
fade_out: 'fade out',
transition_douce: 'transition douce vers le silence',
}
const STRUCTURE_ALIASES = {
"short intro": "short_intro",
"introduction instrumentale courte": "short_intro",
"intro instrumentale courte": "short_intro",
"long intro": "long_intro",
"introduction instrumentale longue": "long_intro",
"intro instrumentale longue": "long_intro",
"pré-refrain": "pre_refrain_instrumental",
"pre-refrain": "pre_refrain_instrumental",
pre_refrain: "pre_refrain_instrumental",
"pre chorus": "pre_refrain_instrumental",
"pre-chorus": "pre_refrain_instrumental",
prechorus: "pre_refrain_instrumental",
"pré-refrain instrumental": "pre_refrain_instrumental",
"pre-refrain instrumental": "pre_refrain_instrumental",
"instrumental pre-chorus": "pre_refrain_instrumental",
"instrumental pre chorus": "pre_refrain_instrumental",
bridge: "pont",
guitar_solo: "solo_de_guitare",
electric_guitar_solo: "solo_de_guitare_electrique",
drum_solo: "solo_de_batterie",
sax_solo: "solo_de_saxophone",
violin_solo: "solo_de_violon",
melodic_interlude: "interlude_melodique",
grand_finale: "final_apogee",
sudden_stop: "arret_net",
soft_transition: "transition_douce",
};
'short intro': 'short_intro',
'introduction instrumentale courte': 'short_intro',
'intro instrumentale courte': 'short_intro',
'long intro': 'long_intro',
'introduction instrumentale longue': 'long_intro',
'intro instrumentale longue': 'long_intro',
'pré-refrain': 'pre_refrain_instrumental',
'pre-refrain': 'pre_refrain_instrumental',
pre_refrain: 'pre_refrain_instrumental',
'pre chorus': 'pre_refrain_instrumental',
'pre-chorus': 'pre_refrain_instrumental',
prechorus: 'pre_refrain_instrumental',
'pré-refrain instrumental': 'pre_refrain_instrumental',
'pre-refrain instrumental': 'pre_refrain_instrumental',
'instrumental pre-chorus': 'pre_refrain_instrumental',
'instrumental pre chorus': 'pre_refrain_instrumental',
bridge: 'pont',
guitar_solo: 'solo_de_guitare',
electric_guitar_solo: 'solo_de_guitare_electrique',
drum_solo: 'solo_de_batterie',
sax_solo: 'solo_de_saxophone',
violin_solo: 'solo_de_violon',
melodic_interlude: 'interlude_melodique',
grand_finale: 'final_apogee',
sudden_stop: 'arret_net',
soft_transition: 'transition_douce',
}
// --- UTILITAIRES ---
const normalizeStructureValue = (value) => {
const raw = String(value || "")
const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return "";
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
return STRUCTURE_PROMPT_LABELS[sanitized]
? sanitized
: STRUCTURE_ALIASES[sanitized] || sanitized;
};
.toLowerCase()
if (!raw) return ''
if (STRUCTURE_PROMPT_LABELS[raw]) return raw
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]
const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized
}
// Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement.
// On s'assure juste que les clés sont propres.
const sanitizeStructureEntries = (structure = []) => {
if (!Array.isArray(structure)) return [];
return structure.map(normalizeStructureValue).filter(Boolean);
};
if (!Array.isArray(structure)) return []
return structure.map(normalizeStructureValue).filter(Boolean)
}
const mapStructureToPrompt = (structure = []) =>
sanitizeStructureEntries(structure).map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
);
sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
// --- MODERATION ---
@@ -101,40 +94,37 @@ const buildModerationBrief = ({
rhymes,
}) => {
const sections = [
`<OBJECTIF>${objective || ""}</OBJECTIF>`,
`<CONTEXTE>${context || ""}</CONTEXTE>`,
`<EMOTION>${emotion || ""}</EMOTION>`,
`<STYLE>${style || ""}</STYLE>`,
`<AUDIENCE>${audience || ""}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}</RIMES>`,
];
const promptStructure = mapStructureToPrompt(structure);
if (promptStructure.length)
sections.push(`<STRUCTURE>${promptStructure.join(" | ")}</STRUCTURE>`);
return `<BRIEF_UTILISATEUR>\n${sections.join("\n")}\n</BRIEF_UTILISATEUR>`;
};
`<OBJECTIF>${objective || ''}</OBJECTIF>`,
`<CONTEXTE>${context || ''}</CONTEXTE>`,
`<EMOTION>${emotion || ''}</EMOTION>`,
`<STYLE>${style || ''}</STYLE>`,
`<AUDIENCE>${audience || ''}</AUDIENCE>`,
`<RIMES>${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}</RIMES>`,
]
const promptStructure = mapStructureToPrompt(structure)
if (promptStructure.length) sections.push(`<STRUCTURE>${promptStructure.join(' | ')}</STRUCTURE>`)
return `<BRIEF_UTILISATEUR>\n${sections.join('\n')}\n</BRIEF_UTILISATEUR>`
}
// --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
const {
objective = "",
context = "",
style = "",
audience = "",
emotion = "",
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"],
rhymes = "",
} = data;
objective = '',
context = '',
style = '',
audience = '',
emotion = '',
structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'],
rhymes = '',
} = data
// 1. Préparation Structure
const sanitizedStructure = sanitizeStructureEntries(rawStructure);
const sanitizedStructure = sanitizeStructureEntries(rawStructure)
const promptStructure = sanitizedStructure.length
? sanitizedStructure.map(
(entry) => STRUCTURE_PROMPT_LABELS[entry] || entry,
)
: [];
? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry)
: []
// 2. Modération "Pro" (Via Gemini 1.5 Pro)
// On supprime les regex manuelles obsolètes.
@@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
emotion,
structure: promptStructure,
rhymes,
};
const moderationInput = buildModerationBrief(moderationPayload);
}
const moderationInput = buildModerationBrief(moderationPayload)
try {
const moderation = await analyseLyrics({
title: "Brief utilisateur (Pré-génération)",
title: 'Brief utilisateur (Pré-génération)',
lyrics: moderationInput,
});
})
// Si Gemini dit "Blocked", on bloque. C'est la seule autorité.
if (moderation?.blocked === true) {
console.warn("⛔ generateLyrics blocked by Gemini Pro", {
console.warn('⛔ generateLyrics blocked by Gemini Pro', {
reasons: moderation.reasons,
});
})
const summary = Array.isArray(moderation.reasons)
? moderation.reasons.slice(0, 3).join(", ")
: "Contenu non conforme";
throw new HttpsError(
"invalid-argument",
`Demande refusée par la modération : ${summary}.`,
);
? moderation.reasons.slice(0, 3).join(', ')
: 'Contenu non conforme'
throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`)
}
} catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError;
console.error("⚠️ Moderation check error (fail open)", moderationError);
if (moderationError instanceof HttpsError) throw moderationError
console.error('⚠️ Moderation check error (fail open)', moderationError)
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
// Ici je fail closed par sécurité pour une app publique.
throw new HttpsError(
"internal",
"Vérification de sécurité indisponible.",
);
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
}
console.log("🎵 generateLyrics: Start generation", { style, emotion });
console.log('🎵 generateLyrics: Start generation', { style, emotion })
// 3. Le Prompt "Hit Maker"
const system = `
@@ -197,27 +181,25 @@ TES RÈGLES D'OR :
5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop).
Retourne UNIQUEMENT un JSON valide.
`.trim();
`.trim()
const structureTags = (
Array.isArray(promptStructure) ? promptStructure : []
)
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
.map((part, index) => ` <SECTION ordre="${index + 1}">${part}</SECTION>`)
.join("\n");
.join('\n')
const fallbackStructureTags = [
' <SECTION ordre="1">couplet</SECTION>',
' <SECTION ordre="2">refrain</SECTION>',
].join("\n");
].join('\n')
const prompt = `
<BRIEF_CREATIF>
<OBJECTIF>${objective || "Créer une chanson mémorable"}</OBJECTIF>
<CONTEXTE>${context || "Libre interprétation"}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || "Intense"}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || "Pop Moderne"}</STYLE_MUSICAL>
<CIBLE>${audience || "Tout public"}</CIBLE>
<TYPE_DE_RIMES>${rhymes || "Rimes croisées et riches"}</TYPE_DE_RIMES>
<OBJECTIF>${objective || 'Créer une chanson mémorable'}</OBJECTIF>
<CONTEXTE>${context || 'Libre interprétation'}</CONTEXTE>
<EMOTION_DOMINANTE>${emotion || 'Intense'}</EMOTION_DOMINANTE>
<STYLE_MUSICAL>${style || 'Pop Moderne'}</STYLE_MUSICAL>
<CIBLE>${audience || 'Tout public'}</CIBLE>
<TYPE_DE_RIMES>${rhymes || 'Rimes croisées et riches'}</TYPE_DE_RIMES>
</BRIEF_CREATIF>
<STRUCTURE_IMPOSEE>
@@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags}
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION>
`.trim();
`.trim()
const lyricsSchema = z.object({
title: z.string().describe("Titre de la chanson, court et accrocheur"),
title: z.string().describe('Titre de la chanson, court et accrocheur'),
lyrics: z
.array(
z.object({
type: z
.string()
.describe(
"Type de section (copier exactement la demande structure)",
),
type: z.string().describe('Type de section (copier exactement la demande structure)'),
lyrics: z
.string()
.describe(
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.",
"Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance."
),
}),
})
)
.describe("La structure complète de la chanson"),
.describe('La structure complète de la chanson'),
lyricsDescription: z
.string()
.describe(
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.",
"Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno."
),
success: z.boolean(),
});
})
// Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini
return await generateAI({
system,
prompt,
schema: lyricsSchema,
});
})
} catch (e) {
console.error("❌ generateLyrics Error:", e);
console.error('❌ generateLyrics Error:', e)
// Remontée d'erreur propre
if (e instanceof HttpsError) throw e;
throw new HttpsError(
"internal",
"Erreur lors de la génération des paroles.",
);
if (e instanceof HttpsError) throw e
throw new HttpsError('internal', 'Erreur lors de la génération des paroles.')
}
});
})
// --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) ---
// Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI
const severityScore = (severity = "") => {
const normalized = String(severity || "").toLowerCase();
if (normalized === "critical") return 4;
if (normalized === "high") return 3;
if (normalized === "medium") return 2;
if (normalized === "low") return 1;
return 0;
};
const severityScore = (severity = '') => {
const normalized = String(severity || '').toLowerCase()
if (normalized === 'critical') return 4
if (normalized === 'high') return 3
if (normalized === 'medium') return 2
if (normalized === 'low') return 1
return 0
}
const softenModerationDecision = (rawResult = {}) => {
const result = { ...rawResult };
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [];
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
const result = { ...rawResult }
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []
const reasons = Array.isArray(result.reasons) ? result.reasons : []
const highestSeverity = excerpts.reduce(
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
0,
);
0
)
const score =
typeof result.score === "number" && !Number.isNaN(result.score)
typeof result.score === 'number' && !Number.isNaN(result.score)
? Math.min(Math.max(result.score, 0), 1)
: 0;
: 0
// Détection de contexte narratif pour être plus indulgent
const narrativeHint = reasons.some((reason) =>
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
reason || "",
),
);
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '')
)
const adjustments = [];
const adjustments = []
// Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique).
// Ici on reste prudent sur le block, mais on adoucit le flag.
if (result.blocked) {
// Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur)
if (highestSeverity <= 1 && score < 0.65) {
result.blocked = false;
adjustments.push("auto-unblock-low-severity");
result.blocked = false
adjustments.push('auto-unblock-low-severity')
}
}
if (!result.blocked && result.flagged) {
const lowSignal = highestSeverity <= 1 && score < 0.4;
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
const lowSignal = highestSeverity <= 1 && score < 0.4
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55
if (lowSignal) {
result.flagged = false;
adjustments.push("drop-flag-low-signal");
result.flagged = false
adjustments.push('drop-flag-low-signal')
} else if (contextual) {
result.flagged = false;
adjustments.push("drop-flag-contextual");
result.flagged = false
adjustments.push('drop-flag-contextual')
}
}
@@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => {
...result,
moderationAdjustments: adjustments,
moderationCalibration: { highestSeverity, score },
};
};
}
}
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
try {
@@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
z.object({
type: z.string().optional(),
lyrics: z.string().optional(),
}),
})
),
])
.describe("Paroles à analyser"),
});
.describe('Paroles à analyser'),
})
const parsed = requestSchema.parse(data || {});
const parsed = requestSchema.parse(data || {})
const aiResult = await analyseLyrics({
title: parsed.title || "",
title: parsed.title || '',
lyrics: parsed.lyrics,
});
const result = softenModerationDecision(aiResult);
})
const result = softenModerationDecision(aiResult)
const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
.filter(([, v]) => (typeof v === 'number' ? v : 0) >= 0.5)
.map(([k]) => k)
.slice(0, 5);
.slice(0, 5)
let errorCode = "OK";
let message = "Analyse effectuée: aucun blocage.";
let errorCode = 'OK'
let message = 'Analyse effectuée: aucun blocage.'
if (result.blocked) {
errorCode = "TOXIC_CONTENT_BLOCKED";
message = `Contenu bloqué par sécurité.`;
errorCode = 'TOXIC_CONTENT_BLOCKED'
message = `Contenu bloqué par sécurité.`
} else if (result.flagged) {
errorCode = "TOXIC_CONTENT_FLAGGED";
message = `Attention: contenu sensible détecté.`;
errorCode = 'TOXIC_CONTENT_FLAGGED'
message = `Attention: contenu sensible détecté.`
}
return { success: !result.blocked, errorCode, message, result };
return { success: !result.blocked, errorCode, message, result }
} catch (err) {
console.error("analyseLyricsToxicity failed", err?.message || err);
console.error('analyseLyricsToxicity failed', err?.message || err)
return {
success: false,
errorCode: "ANALYSE_FAILED",
message: "Erreur analyse toxicité.",
};
errorCode: 'ANALYSE_FAILED',
message: 'Erreur analyse toxicité.',
}
}
});
})
// --- SUNO TIMESTAMPS (EXISTANT) ---
async function getSunoTimestamps(projectId) {
try {
if (!projectId || typeof projectId !== "string")
throw new Error("projectId invalide");
if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide')
const docSnap = await refList.projects.doc(projectId).get();
if (!docSnap.exists) throw new Error("Projet introuvable");
const docSnap = await refList.projects.doc(projectId).get()
if (!docSnap.exists) throw new Error('Projet introuvable')
const { sunoTaskId, songIndex } = docSnap.data();
if (!sunoTaskId) throw new Error("TaskId manquant");
if (songIndex === undefined || songIndex < 0)
throw new Error("musicIndex invalide");
const { sunoTaskId, songIndex } = docSnap.data()
if (!sunoTaskId) throw new Error('TaskId manquant')
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex });
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex })
const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex },
{
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY}`,
},
timeout: 30000,
},
);
}
)
const dataToReturn = response.data?.data || response.data || {};
const dataToReturn = response.data?.data || response.data || {}
await refList.projects.doc(projectId).set(
{
musicTimestamps: { [songIndex]: dataToReturn },
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
return { success: true };
return { success: true }
} catch (error) {
console.error("❌ getSunoTimestamps Error:", error.message);
console.error('❌ getSunoTimestamps Error:', error.message)
return {
success: false,
error: { message: error.message, type: "INTERNAL_ERROR" },
};
error: { message: error.message, type: 'INTERNAL_ERROR' },
}
}
}
exports.getSunoTimestamps = getSunoTimestamps;
exports.getSunoTimestamps = getSunoTimestamps
+340 -387
View File
File diff suppressed because it is too large Load Diff
+239 -284
View File
@@ -1,100 +1,88 @@
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/keys");
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/keys')
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null;
const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null
// Initialisation de Expo SDK
let expo = new Expo();
const EMAIL_FROM = "MusicLand <musicland@musicland.ai>";
const DEFAULT_EMAIL_TITLE = "MusicLand";
let expo = new Expo()
const EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
const DEFAULT_EMAIL_TITLE = 'MusicLand'
function getCollectionRef(collectionName = "") {
const ref = refList?.[collectionName];
function getCollectionRef(collectionName = '') {
const ref = refList?.[collectionName]
if (!ref) {
throw new Error(`Unknown collection "${collectionName}"`);
throw new Error(`Unknown collection "${collectionName}"`)
}
return ref;
return ref
}
function cleanString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
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 : {};
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;
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);
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,
};
cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined,
}
}
}
const templatePayload = { title: emailTitle, content };
const templatePayload = { title: emailTitle, content }
if (button) {
templatePayload.button = button;
templatePayload.button = button
}
return {
subject,
html: basicTemplate(templatePayload),
};
}
}
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
{ region: "europe-west1", document: "notifications/{notificationId}" },
{ region: 'europe-west1', document: 'notifications/{notificationId}' },
async (event) => {
try {
const {
receiver = null,
title = "MusicLand",
receiverCollection = "users",
message = "",
title = 'MusicLand',
receiverCollection = 'users',
message = '',
data: notifData = {},
mailOnly = false,
} = event.data.data();
} = event.data.data()
if (!receiver || !message) {
throw new Error("Receiver and message are required");
throw new Error('Receiver and message are required')
}
const receiverSnap = await getCollectionRef(receiverCollection)
.doc(receiver)
.get();
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get()
const receiverData = receiverSnap.exists ? receiverSnap.data() : {}
const {
pushToken = null,
pushTokens = [],
email: receiverEmail = "",
email: receiverEmail = '',
emailNotifications = false,
} = receiverData;
} = receiverData
if (!mailOnly) {
try {
@@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
[]
.concat(Array.isArray(pushTokens) ? pushTokens : [])
.concat(pushToken ? [pushToken] : [])
.filter(Boolean),
);
const tokens = Array.from(tokensSet);
.filter(Boolean)
)
const tokens = Array.from(tokensSet)
if (!tokens?.length) {
await sendExpoNotification({
tokens,
receiverId: receiver,
receiverCollection,
title: title || "MusicLand",
title: title || 'MusicLand',
message: message,
data: notifData || {},
});
})
} else {
console.log("User push token not found");
console.log('User push token not found')
}
} catch (e) {
console.log("Error sending notif:", e);
console.log('Error sending notif:', e)
}
}
if ((emailNotifications || mailOnly) && !!receiverEmail) {
if (!resendInstance) {
console.warn(
"Resend client not configured; unable to send notification email.",
);
console.warn('Resend client not configured; unable to send notification email.')
} else {
try {
const { subject, html } = buildNotificationEmailPayload({
title,
message,
template: notifData?.email,
});
})
await resendInstance.emails.send({
from: EMAIL_FROM,
to: [receiverEmail],
subject,
html,
});
})
} catch (e) {
console.log("Error sending email:", 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"}`,
);
`[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}`
)
}
} catch (e) {
console.log(e);
return e;
console.log(e)
return e
}
},
);
}
)
// Fonction pour envoyer la notification via Expo SDK
async function sendExpoNotification({
tokens = [],
title = "",
message = "",
title = '',
message = '',
data = {},
receiverId = null,
receiverCollection = "users",
receiverCollection = 'users',
}) {
try {
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens];
const validTokens = [];
const invalidTokens = [];
const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]
const validTokens = []
const invalidTokens = []
candidateTokens.forEach((token) => {
if (Expo.isExpoPushToken(token)) {
validTokens.push(token);
validTokens.push(token)
} else if (token) {
invalidTokens.push(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 };
console.warn('No valid Expo push tokens to send notification')
return { sent: false }
}
const messages = validTokens.map((token) => ({
to: token,
sound: "default",
sound: 'default',
title: title,
body: message,
data: data || {},
priority: "high",
priority: 'high',
badge: 1,
channelId: "default",
}));
channelId: 'default',
}))
const chunks = expo.chunkPushNotifications(messages);
const receipts = [];
const tokensToPrune = new Set();
const chunks = expo.chunkPushNotifications(messages)
const receipts = []
const tokensToPrune = new Set()
for (const chunk of chunks) {
const chunkReceipts = await expo.sendPushNotificationsAsync(chunk);
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 (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);
tokensToPrune.add(token)
}
}
}
});
receipts.push(...chunkReceipts);
})
receipts.push(...chunkReceipts)
}
if (tokensToPrune.size && receiverId) {
@@ -232,68 +215,64 @@ async function sendExpoNotification({
tokens: Array.from(tokensToPrune),
receiverId,
receiverCollection,
});
})
}
console.log("Sent push notifications:", receipts);
console.log('Sent push notifications:', receipts)
return { sent: true };
return { sent: true }
} catch (e) {
console.log("Error sending notification:", e);
throw e;
console.log('Error sending notification:', e)
throw e
}
}
async function removeInvalidTokens({
tokens = [],
receiverId,
receiverCollection,
}) {
async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) {
try {
if (!receiverId || !tokens.length) {
return;
return
}
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)));
const uniqueTokens = Array.from(new Set(tokens.filter(Boolean)))
if (!uniqueTokens.length) {
return;
return
}
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
const userSnap = await docRef.get();
const userData = userSnap?.data() || {};
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 });
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),
);
'Pruned invalid push tokens',
JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2)
)
} catch (error) {
console.log("Failed to prune invalid push tokens:", 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",
sender = 'SYSTEM',
receiver = null,
receiverCollection = "users",
title = "",
receiverCollection = 'users',
title = '',
message = null,
mailOnly = false,
data = {},
}) => {
try {
if (!receiver || !message) {
throw new Error("Receiver and message are required");
throw new Error('Receiver and message are required')
}
const payload = {
sender,
@@ -306,81 +285,78 @@ const sendNotification = async ({
readAt: null,
mailOnly,
data,
};
const { id } = await refList.notifications.add(payload);
}
const { id } = await refList.notifications.add(payload)
console.log(
"[sendNotification] Notification created",
JSON.stringify({ id, receiver, receiverCollection }, null, 2),
);
'[sendNotification] Notification created',
JSON.stringify({ id, receiver, receiverCollection }, null, 2)
)
return id;
return id
} catch (e) {
console.log("[sendNotification] Error creating notification:", e);
console.log('[sendNotification] Error creating notification:', e)
}
};
}
exports.sendNotification = sendNotification;
exports.sendNotification = sendNotification
exports.createProjectCommentNotification = onDocumentCreated(
{
region: "europe-west1",
document: "projects/{projectId}/comments/{commentId}",
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();
'[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] Missing project/comment data', {
hasProjectId: !!projectId,
hasComment: !!comment,
})
return null
}
console.log(
"[createProjectCommentNotification] Comment payload",
JSON.stringify(comment, null, 2),
);
'[createProjectCommentNotification] Comment payload',
JSON.stringify(comment, null, 2)
)
const projectSnap = await refList.projects.doc(projectId).get();
const projectSnap = await refList.projects.doc(projectId).get()
if (!projectSnap.exists) {
console.log(
"[createProjectCommentNotification] Project not found",
projectId,
);
return null;
console.log('[createProjectCommentNotification] Project not found', projectId)
return null
}
const project = projectSnap.data() || {};
const receiver = project.userId || 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;
'[createProjectCommentNotification] Invalid receiver',
JSON.stringify({ receiver, commentUserId: comment.userId })
)
return null
}
const commenterName =
typeof comment?.userName === "string" && comment.userName.trim()
typeof comment?.userName === 'string' && comment.userName.trim()
? comment.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const projectTitle =
typeof project.title === "string" && project.title.trim()
typeof project.title === 'string' && project.title.trim()
? project.title.trim()
: "ton projet";
const message = `${commenterName} a commenté ton projet "${projectTitle}"`;
: 'ton projet'
const message = `${commenterName} a commenté ton projet "${projectTitle}"`
console.log(
"[createProjectCommentNotification] Creating notification",
'[createProjectCommentNotification] Creating notification',
JSON.stringify(
{
receiver,
@@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated(
projectId,
},
null,
2,
),
);
2
)
)
await sendNotification({
sender: comment.userId || "SYSTEM",
sender: comment.userId || 'SYSTEM',
receiver,
receiverCollection: "users",
title: "Nouveau commentaire",
receiverCollection: 'users',
title: 'Nouveau commentaire',
message,
data: {
type: ALERT_TYPE?.NEW_COMMENT,
@@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated(
commentId,
commenterId: comment.userId || null,
commenterName: commenterName,
commenterProfilePicture: comment?.profilePicture || "",
text:
typeof comment?.text === "string" && comment.text.trim()
? comment.text.trim()
: "",
commenterProfilePicture: comment?.profilePicture || '',
text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '',
},
});
})
console.log(
"[createProjectCommentNotification] Notification creation complete",
);
console.log('[createProjectCommentNotification] Notification creation complete')
return null;
return null
} catch (error) {
console.log("createProjectCommentNotification error:", error);
return error;
console.log('createProjectCommentNotification error:', error)
return error
}
},
);
}
)
exports.createProjectLikeNotification = onDocumentWritten(
{
region: "europe-west1",
document: "projects/{projectId}",
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() || {};
const { projectId } = event.params || {}
const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}
if (!projectId || !after) {
return null;
return null
}
const ownerId = after.userId || null;
const ownerId = after.userId || null
if (!ownerId) {
return null;
return null
}
const beforeSongLikes = Array.isArray(before?.likes?.song)
? before.likes.song
: [];
const afterSongLikes = Array.isArray(after?.likes?.song)
? after.likes.song
: [];
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 afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : []
const beforeSongSet = new Set(beforeSongLikes);
const beforePlaybackSet = new Set(beforePlaybackLikes);
const beforeSongSet = new Set(beforeSongLikes)
const beforePlaybackSet = new Set(beforePlaybackLikes)
const newSongLikers = afterSongLikes.filter(
(uid) => uid && !beforeSongSet.has(uid),
);
const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid))
const newPlaybackLikers = afterPlaybackLikes.filter(
(uid) => uid && !beforePlaybackSet.has(uid),
);
(uid) => uid && !beforePlaybackSet.has(uid)
)
const newLikers = [];
const newLikers = []
newSongLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "song" });
});
newLikers.push({ likerId: uid, likeType: 'song' })
})
newPlaybackLikers.forEach((uid) => {
newLikers.push({ likerId: uid, likeType: "playback" });
});
newLikers.push({ likerId: uid, likeType: 'playback' })
})
if (!newLikers.length) {
return null;
return null
}
const projectTitle =
typeof after.title === "string" && after.title.trim()
? after.title.trim()
: "ton projet";
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;
return null
}
const likerSnap = await refList.users.doc(likerId).get();
const liker = likerSnap?.data() || {};
const likerSnap = await refList.users.doc(likerId).get()
const liker = likerSnap?.data() || {}
const likerName =
typeof liker?.userName === "string" && liker.userName.trim()
typeof liker?.userName === 'string' && liker.userName.trim()
? liker.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const isPlaybackLike = likeType === "playback";
const assetLabel = isPlaybackLike ? "ton playback" : "ta musique";
const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`;
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",
receiverCollection: 'users',
title: 'Nouveau like',
message,
data: {
type: ALERT_TYPE?.NEW_LIKE,
@@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten(
likerName,
likeType,
},
});
})
return null;
}),
);
return null
})
)
return null;
return null
} catch (error) {
console.log("[createProjectLikeNotification] error:", error);
return error;
console.log('[createProjectLikeNotification] error:', error)
return error
}
},
);
}
)
exports.createNewFollowerNotification = onDocumentWritten(
{
region: "europe-west1",
document: "users/{userId}",
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() || {};
const { userId } = event.params || {}
const before = event?.data?.before?.data() || {}
const after = event?.data?.after?.data() || {}
if (!userId || !after) {
return null;
return null
}
const beforeFollowers = Array.isArray(before?.followedBy)
? before.followedBy
: [];
const afterFollowers = Array.isArray(after?.followedBy)
? after.followedBy
: [];
const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : []
const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : []
if (afterFollowers.length <= beforeFollowers.length) {
return null;
return null
}
const previousSet = new Set(beforeFollowers);
const newFollowers = afterFollowers.filter(
(uid) => !previousSet.has(uid),
);
const previousSet = new Set(beforeFollowers)
const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid))
if (!newFollowers.length) {
return null;
return null
}
await Promise.all(
newFollowers.map(async (followerId) => {
if (!followerId || followerId === userId) {
return null;
return null
}
const followerSnap = await refList.users.doc(followerId).get();
const follower = followerSnap?.data() || {};
const followerSnap = await refList.users.doc(followerId).get()
const follower = followerSnap?.data() || {}
const followerName =
typeof follower?.userName === "string" && follower.userName.trim()
typeof follower?.userName === 'string' && follower.userName.trim()
? follower.userName.trim()
: "Un utilisateur";
: 'Un utilisateur'
const message = `${followerName} te suit maintenant`;
const message = `${followerName} te suit maintenant`
await sendNotification({
sender: followerId,
receiver: userId,
receiverCollection: "users",
title: "Nouvel abonné",
receiverCollection: 'users',
title: 'Nouvel abonné',
message,
data: {
type: ALERT_TYPE?.NEW_FOLLOWER,
followerId,
followerName,
followerProfilePicture: follower?.profilePicture || "",
followerProfilePicture: follower?.profilePicture || '',
},
});
})
return null;
}),
);
return null
})
)
return null;
return null
} catch (error) {
console.log("[createNewFollowerNotification] error:", error);
return error;
console.log('[createNewFollowerNotification] error:', error)
return error
}
},
);
}
)
+174 -205
View File
@@ -1,86 +1,81 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentCreated } = require("firebase-functions/firestore");
const { HttpsError, onCall } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentCreated } = require('firebase-functions/firestore')
const { HttpsError, onCall } = require('firebase-functions/https')
const { REGION, ALERT_TYPE } = require("../index");
const { sendNotification } = require("./notifications");
const { REGION, ALERT_TYPE } = require('../index')
const { sendNotification } = require('./notifications')
const {
ORDER_TYPES,
ORDER_STATUS,
ORDERS_COLLECTION,
createOrderDocument,
normalizeAmount,
} = require("./helpers/orders");
} = require('./helpers/orders')
const USERS_COLLECTION = "users";
const USERS_COLLECTION = 'users'
const formatCoinsText = (value) => {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
if (typeof value !== 'number' || Number.isNaN(value)) {
return null
}
const absoluteValue = Math.abs(value);
const absoluteValue = Math.abs(value)
if (!Number.isFinite(absoluteValue)) {
return null;
return null
}
const formatted = Number.isInteger(absoluteValue)
? `${absoluteValue}`
: absoluteValue.toFixed(2);
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2)
const suffix = absoluteValue === 1 ? 'crédit' : 'crédits'
return `${formatted} ${suffix}`;
};
return `${formatted} ${suffix}`
}
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) {
return null;
if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) {
return null
}
const coinsText = formatCoinsText(amount);
const coinsText = formatCoinsText(amount)
if (!coinsText) {
return null;
return null
}
const balanceText = formatCoinsText(balanceAfter);
const balanceSentence = balanceText
? ` Ton solde est maintenant de ${balanceText}.`
: "";
const balanceText = formatCoinsText(balanceAfter)
const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : ''
if (amount > 0) {
if (orderType === ORDER_TYPES.COINS) {
return {
title: "Crédits achetés",
title: 'Crédits achetés',
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
action: "PURCHASED",
};
action: 'PURCHASED',
}
}
if (orderType === ORDER_TYPES.GIFT) {
return {
title: "Crédits reçus",
title: 'Crédits reçus',
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
action: "EARNED",
};
action: 'EARNED',
}
}
return {
title: "Crédits ajoutés",
title: 'Crédits ajoutés',
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
action: "CREDITED",
};
action: 'CREDITED',
}
}
const reason =
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : ''
return {
title: "Crédits dépensés",
title: 'Crédits dépensés',
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
action: "SPENT",
};
};
action: 'SPENT',
}
}
const notifyOrderApplied = async ({
userId,
@@ -95,226 +90,200 @@ const notifyOrderApplied = async ({
amount,
orderType,
balanceAfter,
});
})
if (!content || !userId) {
return;
return
}
try {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: userId,
receiverCollection: USERS_COLLECTION,
title: content.title,
message: content.message,
data: {
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED",
type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED',
orderId,
orderType: orderType || null,
amount,
balanceBefore,
balanceAfter,
action: content.action,
source:
typeof metadata?.source === "string" ? metadata.source : null,
source: typeof metadata?.source === 'string' ? metadata.source : null,
metadata: metadata || {},
},
});
})
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to send notification",
orderId,
error,
);
console.error('[orders-onOrderCreated] Failed to send notification', orderId, error)
}
};
}
const onOrderCreated = onDocumentCreated(
`${ORDERS_COLLECTION}/{orderId}`,
async (event) => {
const orderRef = event?.data?.ref;
const orderData = event?.data?.data();
const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => {
const orderRef = event?.data?.ref
const orderData = event?.data?.data()
if (!orderRef || !orderData) {
return;
}
if (!orderRef || !orderData) {
return
}
if (orderData?.processedAt) {
return;
}
if (orderData?.processedAt) {
return
}
const userId =
typeof orderData.userId === "string" ? orderData.userId.trim() : "";
const amount = normalizeAmount(orderData.amount);
const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : ''
const amount = normalizeAmount(orderData.amount)
if (!userId) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "USER_NOT_FOUND",
},
{ merge: true },
);
return;
}
if (!userId) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'USER_NOT_FOUND',
},
{ merge: true }
)
return
}
if (amount === null || amount === 0) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INVALID_AMOUNT",
},
{ merge: true },
);
return;
}
if (amount === null || amount === 0) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'INVALID_AMOUNT',
},
{ merge: true }
)
return
}
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId)
let notificationContext = null;
let notificationContext = null
try {
await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef);
const userData = userSnapshot?.data() || {};
const currentBalanceValue = normalizeAmount(userData?.coins);
const currentBalance =
currentBalanceValue !== null ? currentBalanceValue : 0;
try {
await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef)
const userData = userSnapshot?.data() || {}
const currentBalanceValue = normalizeAmount(userData?.coins)
const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0
const nextBalance = currentBalance + amount;
if (
amount < 0 &&
nextBalance < 0 &&
orderData?.type === ORDER_TYPES.SONG
) {
transaction.set(
orderRef,
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INSUFFICIENT_FUNDS",
balanceBefore: currentBalance,
balanceAfter: currentBalance,
},
{ merge: true },
);
return;
}
if (userSnapshot?.exists) {
transaction.update(userRef, {
coins: nextBalance,
updatedAt: FieldValue.serverTimestamp(),
});
} else {
transaction.set(
userRef,
{
coins: nextBalance,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
}
const nextBalance = currentBalance + amount
if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) {
transaction.set(
orderRef,
{
status: ORDER_STATUS.APPLIED,
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'INSUFFICIENT_FUNDS',
balanceBefore: currentBalance,
balanceAfter: nextBalance,
balanceAfter: currentBalance,
},
{ merge: true },
);
{ merge: true }
)
return
}
notificationContext = {
if (userSnapshot?.exists) {
transaction.update(userRef, {
coins: nextBalance,
updatedAt: FieldValue.serverTimestamp(),
})
} else {
transaction.set(
userRef,
{
coins: nextBalance,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
}
transaction.set(
orderRef,
{
status: ORDER_STATUS.APPLIED,
processedAt: FieldValue.serverTimestamp(),
balanceBefore: currentBalance,
balanceAfter: nextBalance,
};
});
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to process order",
orderRef.id,
error,
);
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "PROCESSING_ERROR",
errorMessage: error?.message || String(error),
},
{ merge: true },
);
return;
}
{ merge: true }
)
if (notificationContext) {
await notifyOrderApplied({
userId,
orderId: orderRef.id,
amount,
orderType:
typeof orderData?.type === "string" ? orderData.type : null,
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
});
}
},
);
notificationContext = {
balanceBefore: currentBalance,
balanceAfter: nextBalance,
}
})
} catch (error) {
console.error('[orders-onOrderCreated] Failed to process order', orderRef.id, error)
const createSongOrder = onCall({ region: REGION }, async (request) => {
const { auth, data } = request || {};
if (!auth?.uid) {
throw new HttpsError("unauthenticated", "Authentification requise.");
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: 'PROCESSING_ERROR',
errorMessage: error?.message || String(error),
},
{ merge: true }
)
return
}
const amount = normalizeAmount(data?.amount);
if (notificationContext) {
await notifyOrderApplied({
userId,
orderId: orderRef.id,
amount,
orderType: typeof orderData?.type === 'string' ? orderData.type : null,
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
})
}
})
const createSongOrder = onCall({ region: REGION }, async (request) => {
const { auth, data } = request || {}
if (!auth?.uid) {
throw new HttpsError('unauthenticated', 'Authentification requise.')
}
const amount = normalizeAmount(data?.amount)
if (amount === null || amount >= 0) {
throw new HttpsError(
"invalid-argument",
"Le montant doit être négatif pour un achat de musique.",
);
'invalid-argument',
'Le montant doit être négatif pour un achat de musique.'
)
}
const songId =
typeof data?.songId === "string" && data.songId.trim()
? data.songId.trim()
: null;
const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
const userSnapshot = await userRef.get();
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
const currentCoins =
currentCoinsValue !== null ? currentCoinsValue : 0;
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid)
const userSnapshot = await userRef.get()
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins)
const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0
if (currentCoins + amount < 0) {
throw new HttpsError(
"failed-precondition",
"Crédits insuffisants pour finaliser l'opération.",
);
throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.")
}
const metadata = {
source:
typeof data?.source === "string" && data.source.trim()
typeof data?.source === 'string' && data.source.trim()
? data.source.trim()
: "music_generation",
};
: 'music_generation',
}
if (typeof data?.requestId === "string" && data.requestId.trim()) {
metadata.requestId = data.requestId.trim();
if (typeof data?.requestId === 'string' && data.requestId.trim()) {
metadata.requestId = data.requestId.trim()
}
const { orderId } = await createOrderDocument({
@@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => {
songId,
createdBy: auth.uid,
metadata,
});
})
return { orderId };
});
return { orderId }
})
module.exports = {
onOrderCreated,
createSongOrder,
};
}
+119 -145
View File
@@ -1,151 +1,134 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const _ = require("lodash");
const { refList, db, ALERT_TYPE } = require("../index");
const { batchFirestore } = require("../helpers/firebase");
const { BATCH_TYPE } = require("../config/types");
const {
buildMonthKey,
buildPreviousMonthContext,
} = require("../helpers/stats");
const { sendNotification } = require("./notifications");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require('firebase-functions/v2/scheduler')
const _ = require('lodash')
const { refList, db, ALERT_TYPE } = require('../index')
const { batchFirestore } = require('../helpers/firebase')
const { BATCH_TYPE } = require('../config/types')
const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats')
const { sendNotification } = require('./notifications')
const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3;
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]);
const DISTRIBUTION_REVENUE_BASELINE = 1000
const DISTRIBUTION_RATIO = 0.3
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active'])
const hasActiveSubscription = (userData) => {
if (!userData || typeof userData !== "object") {
return false;
if (!userData || typeof userData !== 'object') {
return false
}
const isPremium = userData.isPremium === true;
const isPremium = userData.isPremium === true
if (!isPremium) {
return false;
return false
}
const status =
typeof userData.stripeSubscriptionStatus === "string"
typeof userData.stripeSubscriptionStatus === 'string'
? userData.stripeSubscriptionStatus.trim().toLowerCase()
: null;
: null
if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
return true;
return true
}
const billingPeriod =
typeof userData.premiumBillingPeriod === "string"
typeof userData.premiumBillingPeriod === 'string'
? userData.premiumBillingPeriod.trim().toLowerCase()
: null;
if (billingPeriod === "monthly" || billingPeriod === "annual") {
: null
if (billingPeriod === 'monthly' || billingPeriod === 'annual') {
// Fallback: billing period is set only for active subscribers.
return true;
return true
}
return false;
};
return false
}
exports.distributeMonthlyPayouts = onSchedule(
{
schedule: "0 1 1 * *",
timeZone: "Europe/Paris",
schedule: '0 1 1 * *',
timeZone: 'Europe/Paris',
},
async (event) => {
const { scheduleTime } = event;
const context = buildPreviousMonthContext(
scheduleTime ? new Date(scheduleTime) : new Date(),
);
const monthKey = buildMonthKey(context.rangeStart);
const now = admin.firestore.Timestamp.now();
const { scheduleTime } = event
const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const monthKey = buildMonthKey(context.rangeStart)
const now = admin.firestore.Timestamp.now()
const statsSnapshot = await db
.collectionGroup("monthlyListens")
.where("monthKey", "==", monthKey)
.orderBy("streams", "desc")
.get();
.collectionGroup('monthlyListens')
.where('monthKey', '==', monthKey)
.orderBy('streams', 'desc')
.get()
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
const totalsSnapshot = await totalsDocRef.get();
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
const totalsSnapshot = await totalsDocRef.get()
const entries = _.chain(statsSnapshot.docs)
.map((doc) => {
const data = doc.data() || {};
const data = doc.data() || {}
return {
projectId:
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
userId: _.get(data, "userId", null),
streams: _.toFinite(_.get(data, "streams", 0)),
projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null,
userId: _.get(data, 'userId', null),
streams: _.toFinite(_.get(data, 'streams', 0)),
statsDocPath: doc.ref.path,
};
}
})
.filter((entry) => entry.projectId && entry.streams > 0)
.orderBy(["streams"], ["desc"])
.value();
.orderBy(['streams'], ['desc'])
.value()
const userEligibilityMap = {};
const userIds = _.uniq(
entries.map((entry) => entry.userId).filter((userId) => !!userId),
);
const userEligibilityMap = {}
const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId))
if (userIds.length) {
const chunkSize = 300;
const chunkSize = 300
for (let index = 0; index < userIds.length; index += chunkSize) {
const chunk = userIds.slice(index, index + chunkSize);
const chunk = userIds.slice(index, index + chunkSize)
const snapshots = await Promise.all(
chunk.map(async (userId) => {
try {
return await refList.users.doc(userId).get();
return await refList.users.doc(userId).get()
} catch (error) {
console.warn(
"[distributeMonthlyPayouts] Unable to load user profile",
{
userId,
error: error?.message || String(error),
},
);
return null;
console.warn('[distributeMonthlyPayouts] Unable to load user profile', {
userId,
error: error?.message || String(error),
})
return null
}
}),
);
})
)
snapshots.forEach((snapshot, snapshotIndex) => {
const userId = chunk[snapshotIndex];
const userId = chunk[snapshotIndex]
if (snapshot?.exists) {
userEligibilityMap[userId] = hasActiveSubscription(
snapshot.data(),
);
userEligibilityMap[userId] = hasActiveSubscription(snapshot.data())
} else {
userEligibilityMap[userId] = false;
userEligibilityMap[userId] = false
}
});
})
}
}
const eligibleEntries = entries.filter(
(entry) =>
!!entry.userId && userEligibilityMap[entry.userId] === true,
);
const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams");
(entry) => !!entry.userId && userEligibilityMap[entry.userId] === true
)
const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams')
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams')
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null
let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0))
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
totalStreams = payoutsTotalStreamsFromDocs;
totalStreams = payoutsTotalStreamsFromDocs
}
const payoutPool = _.round(
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
2,
);
const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2)
let allocations = _.map(eligibleEntries, (entry) => {
if (!eligibleTotalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams;
return _.round(rawAmount, 2);
});
if (!eligibleTotalStreams) return 0
const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams
return _.round(rawAmount, 2)
})
if (allocations.length > 0) {
const allocatedTotal = _.round(_.sum(allocations), 2);
const remainder = _.round(payoutPool - allocatedTotal, 2);
const allocatedTotal = _.round(_.sum(allocations), 2)
const remainder = _.round(payoutPool - allocatedTotal, 2)
if (remainder !== 0) {
allocations[0] = _.round(allocations[0] + remainder, 2);
allocations[0] = _.round(allocations[0] + remainder, 2)
}
}
@@ -154,40 +137,36 @@ exports.distributeMonthlyPayouts = onSchedule(
projectId: entry.projectId,
userId: entry.userId,
streams: entry.streams,
share: eligibleTotalStreams
? _.round(entry.streams / eligibleTotalStreams, 6)
: 0,
share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0,
amount: allocations[idx],
statsDocPath: entry.statsDocPath,
}));
}))
const userDocs = _(payouts)
.filter((payout) => !!payout.userId)
.groupBy("userId")
.groupBy('userId')
.map((projects, userId) => {
const sortedProjects = _.orderBy(projects, ["amount"], ["desc"]).map(
(project) => ({
projectId: project.projectId,
rank: project.rank,
amount: project.amount,
streams: project.streams,
share: project.share,
statsDocPath: project.statsDocPath,
}),
);
const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({
projectId: project.projectId,
rank: project.rank,
amount: project.amount,
streams: project.streams,
share: project.share,
statsDocPath: project.statsDocPath,
}))
return {
userId,
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
totalStreams: _.sumBy(projects, "streams"),
totalAmount: _.round(_.sumBy(projects, 'amount'), 2),
totalStreams: _.sumBy(projects, 'streams'),
projects: sortedProjects,
};
}
})
.value();
.value()
if (userDocs.length) {
const docs = userDocs.map((userData) => {
const docId = `${monthKey}_${userData.userId}`;
const docId = `${monthKey}_${userData.userId}`
return {
ref: refList.monthlyPayoutEntries.doc(docId),
data: {
@@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule(
computedAt: now,
updatedAt: FieldValue.serverTimestamp(),
},
};
});
}
})
await batchFirestore({
docs,
type: BATCH_TYPE.UPDATE,
});
})
const payoutLabel = `${String(context.month).padStart(2, "0")}/${
context.year
}`;
const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}`
await Promise.all(
userDocs.map(async (userData) => {
const receiverId =
typeof userData.userId === "string" && userData.userId.trim()
typeof userData.userId === 'string' && userData.userId.trim()
? userData.userId.trim()
: null;
const amount = Number(userData.totalAmount) || 0;
: null
const amount = Number(userData.totalAmount) || 0
if (!receiverId || amount <= 0) {
return null;
return null
}
const amountLabel = amount.toFixed(2);
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
const amountLabel = amount.toFixed(2)
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`
try {
await sendNotification({
sender: "SYSTEM",
sender: 'SYSTEM',
receiver: receiverId,
receiverCollection: "users",
title: "Revenus disponibles",
receiverCollection: 'users',
title: 'Revenus disponibles',
message,
data: {
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
@@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule(
totalStreams: userData.totalStreams,
projects: userData.projects,
},
});
})
} catch (notifError) {
console.log(
"[distributeMonthlyPayouts] Failed to send payout notification:",
{
userId: receiverId,
error: notifError?.message || String(notifError),
},
);
console.log('[distributeMonthlyPayouts] Failed to send payout notification:', {
userId: receiverId,
error: notifError?.message || String(notifError),
})
}
return null;
}),
);
return null
})
)
}
const summary = {
@@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule(
totalRecipients: payouts.length,
totalEntries: entries.length,
eligibleEntries: eligibleEntries.length,
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2),
payouts,
status: payouts.length ? "computed" : "no-data",
status: payouts.length ? 'computed' : 'no-data',
updatedAt: FieldValue.serverTimestamp(),
computedAt: now,
};
}
const docRef = refList.monthlyPayouts.doc(monthKey);
await docRef.set(summary, { merge: true });
},
);
const docRef = refList.monthlyPayouts.doc(monthKey)
await docRef.set(summary, { merge: true })
}
)
+96 -107
View File
@@ -1,115 +1,104 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentWritten } = require("firebase-functions/firestore");
const _ = require("lodash");
const { refList, db } = require("../index");
const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase");
const { buildMonthKey } = require("../helpers/stats");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentWritten } = require('firebase-functions/firestore')
const _ = require('lodash')
const { refList, db } = require('../index')
const { getSunoTimestamps } = require('./lyrics')
const { deleteFolder } = require('../helpers/firebase')
const { buildMonthKey } = require('../helpers/stats')
exports.onProjectWritten = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
try {
const { projectId } = projectSnap.params;
const beforeData = projectSnap?.data?.before?.data() || null;
const afterData = projectSnap?.data?.after?.data() || null;
exports.onProjectWritten = onDocumentWritten('projects/{projectId}', async (projectSnap) => {
try {
const { projectId } = projectSnap.params
const beforeData = projectSnap?.data?.before?.data() || null
const afterData = projectSnap?.data?.after?.data() || null
if (!afterData) {
const { userId } = beforeData || {};
if (!afterData) {
const { userId } = beforeData || {}
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`);
}
const snapshot = await refList.tasks
.where("projectId", "==", projectId)
.get();
if (!snapshot.empty) {
const batch = db.batch();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
}
return null;
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId);
await refList.projects.doc(projectId).update({ hasSong: true });
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true });
}
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`)
}
const beforeViews = _.toFinite(_.get(beforeData, "views", 0));
const afterViews = _.toFinite(_.get(afterData, "views", 0));
const delta = afterViews - beforeViews;
if (delta > 0) {
const userIdRaw = _.get(afterData, "userId", null);
const userId =
_.isString(userIdRaw) && _.trim(userIdRaw).length
? _.trim(userIdRaw)
: null;
const now = admin.firestore.Timestamp.now();
const monthKey = buildMonthKey(now);
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection("monthlyListens")
.doc(monthKey);
const totalsDocRef =
refList.projectStreamStatsMonthlyTotals.doc(monthKey);
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef);
const totalsSnapshot = await transaction.get(totalsDocRef);
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: FieldValue.increment(delta),
};
const hasFirstStream =
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
if (!hasFirstStream) {
updatePayload.firstStreamAt = now;
}
transaction.set(statsDocRef, updatePayload, { merge: true });
const totalsUpdatePayload = {
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
totalStreams: FieldValue.increment(delta),
};
const totalsHasFirstStream =
totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt;
if (!totalsHasFirstStream) {
totalsUpdatePayload.firstStreamAt = now;
}
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true });
});
const snapshot = await refList.tasks.where('projectId', '==', projectId).get()
if (!snapshot.empty) {
const batch = db.batch()
snapshot.forEach((doc) => {
batch.delete(doc.ref)
})
await batch.commit()
}
return null;
} catch (error) {
console.log("onProjectViewsIncrement error", {
message: error?.message || String(error || ""),
});
return null;
return null
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId)
await refList.projects.doc(projectId).update({ hasSong: true })
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true })
}
}
},
);
const beforeViews = _.toFinite(_.get(beforeData, 'views', 0))
const afterViews = _.toFinite(_.get(afterData, 'views', 0))
const delta = afterViews - beforeViews
if (delta > 0) {
const userIdRaw = _.get(afterData, 'userId', null)
const userId = _.isString(userIdRaw) && _.trim(userIdRaw).length ? _.trim(userIdRaw) : null
const now = admin.firestore.Timestamp.now()
const monthKey = buildMonthKey(now)
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection('monthlyListens')
.doc(monthKey)
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey)
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef)
const totalsSnapshot = await transaction.get(totalsDocRef)
const updatePayload = {
projectId,
userId,
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
streams: FieldValue.increment(delta),
}
const hasFirstStream = statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt
if (!hasFirstStream) {
updatePayload.firstStreamAt = now
}
transaction.set(statsDocRef, updatePayload, { merge: true })
const totalsUpdatePayload = {
monthKey,
updatedAt: now,
lastStreamAt: now,
lastDelta: delta,
totalStreams: FieldValue.increment(delta),
}
const totalsHasFirstStream = totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt
if (!totalsHasFirstStream) {
totalsUpdatePayload.firstStreamAt = now
}
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true })
})
}
return null
} catch (error) {
console.log('onProjectViewsIncrement error', {
message: error?.message || String(error || ''),
})
return null
}
})
+32 -39
View File
@@ -1,22 +1,22 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { refList } = require("../index");
const firestore = refList.projects.firestore;
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onSchedule } = require('firebase-functions/v2/scheduler')
const { refList } = require('../index')
const firestore = refList.projects.firestore
function buildMonthContext(referenceDate) {
const current = referenceDate ? new Date(referenceDate) : new Date();
current.setHours(0, 0, 0, 0);
current.setDate(1);
const current = referenceDate ? new Date(referenceDate) : new Date()
current.setHours(0, 0, 0, 0)
current.setDate(1)
const target = new Date(current);
target.setMonth(target.getMonth() - 1);
const target = new Date(current)
target.setMonth(target.getMonth() - 1)
const month = target.getMonth();
const year = target.getFullYear();
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
const month = target.getMonth()
const year = target.getFullYear()
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0)
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
return {
year,
@@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) {
monthKey,
rangeStart,
rangeEnd,
};
}
}
exports.snapshotMonthlyTopSongs = onSchedule(
{
schedule: "5 0 1 * *",
timeZone: "Europe/Paris",
schedule: '5 0 1 * *',
timeZone: 'Europe/Paris',
},
async (event) => {
const { scheduleTime } = event;
const context = buildMonthContext(
scheduleTime ? new Date(scheduleTime) : new Date()
);
const { scheduleTime } = event
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const topProjectsSnap = await refList.projects
.orderBy("views", "desc")
.limit(3)
.get();
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get()
const topProjects = topProjectsSnap.docs.map((doc, index) => {
const data = doc.data() || {};
const data = doc.data() || {}
return {
rank: index + 1,
projectId: doc.id,
@@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
coverUrl: data.coverUrl || null,
songUrl: data.songUrl || null,
views: data.views || 0,
};
});
}
})
const docRef = firestore
.collection("monthlyTopSongs")
.doc(context.monthKey);
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
const existingSnapshot = await docRef.get();
const existingSnapshot = await docRef.get()
const payload = {
monthKey: context.monthKey,
month: context.month,
@@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
topProjects,
totalProjects: topProjects.length,
updatedAt: FieldValue.serverTimestamp(),
};
if (!existingSnapshot.exists) {
payload.createdAt = FieldValue.serverTimestamp();
}
await docRef.set(payload, { merge: true });
if (!existingSnapshot.exists) {
payload.createdAt = FieldValue.serverTimestamp()
}
await docRef.set(payload, { merge: true })
}
);
)
+246 -323
View File
@@ -1,15 +1,15 @@
const admin = require("firebase-admin");
const { HttpsError, onCall } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { HttpsError, onCall } = require('firebase-functions/https')
let FieldValue = null;
let FieldValue = null
try {
({ FieldValue } = require("firebase-admin/firestore"));
;({ FieldValue } = require('firebase-admin/firestore'))
} catch (error) {
console.warn("[stripe] FieldValue import failed", error?.message);
console.warn('[stripe] FieldValue import failed', error?.message)
}
const { REGION, refsList } = require("../index");
const STRIPE_MODE = "test";
const { REGION, refsList } = require('../index')
const STRIPE_MODE = 'test'
const {
getStripeClient,
getReturnUrls,
@@ -19,23 +19,20 @@ const {
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
} = require("../helpers/stripe");
} = require('../helpers/stripe')
const paymentsCollection = admin.firestore().collection("payments");
const paymentsCollection = admin.firestore().collection('payments')
const getServerTimestamp = () => {
if (FieldValue?.serverTimestamp) {
return FieldValue.serverTimestamp();
return FieldValue.serverTimestamp()
}
const fallback = admin.firestore?.FieldValue;
const fallback = admin.firestore?.FieldValue
if (fallback?.serverTimestamp) {
return fallback.serverTimestamp();
return fallback.serverTimestamp()
}
throw new HttpsError(
"failed-precondition",
"Firestore FieldValue.serverTimestamp indisponible.",
);
};
throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
}
const normalizePaymentIntent = (paymentIntent) => ({
id: paymentIntent.id,
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
created: paymentIntent.created,
latest_charge: paymentIntent.latest_charge,
metadata: paymentIntent.metadata,
});
})
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour créer une session Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
}
const requestedUserId =
typeof request?.data?.userID === "string"
? request.data.userID.trim()
: null;
typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null
if (requestedUserId && requestedUserId !== uid) {
throw new HttpsError(
"permission-denied",
"Tu ne peux créer une session que pour ton propre compte.",
);
'permission-denied',
'Tu ne peux créer une session que pour ton propre compte.'
)
}
const productList = Array.isArray(request?.data?.productList)
? request.data.productList
: [];
const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
const stripe = getStripeClient();
const { lineItems, summary, hasSubscription } =
await buildCheckoutLineItems(productList, { stripe });
const stripe = getStripeClient()
const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
stripe,
})
const mode = hasSubscription ? "subscription" : "payment";
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
const mode = hasSubscription ? 'subscription' : 'payment'
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls)
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
})
if (!customerId) {
throw new HttpsError(
"internal",
"Impossible de retrouver le client Stripe associé.",
);
throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
}
const session = await stripe.checkout.sessions.create({
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
metadata: {
firebaseUID: uid,
},
});
})
await paymentsCollection.doc(session.id).set({
userId: uid,
customerId,
status: session.status || "created",
status: session.status || 'created',
mode,
createdAt: getServerTimestamp(),
updatedAt: getServerTimestamp(),
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
amountTotal: session.amount_total,
currency: session.currency,
paymentStatus: session.payment_status,
});
})
return formatCheckoutSessionResponse(session);
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error("[createCheckoutSession] error", error);
console.error('[createCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Création de la session Stripe impossible.",
);
throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
}
});
})
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour consulter ton statut Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
});
})
if (!customerId) {
return {
customerId: null,
subscriptions: [],
invoices: [],
};
}
}
const [subscriptions, invoices] = await Promise.all([
stripe.subscriptions.list({
customer: customerId,
status: "all",
expand: ["data.items.data.price"],
status: 'all',
expand: ['data.items.data.price'],
limit: 20,
}),
stripe.invoices.list({
customer: customerId,
limit: 20,
}),
]);
])
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
id: subscription.id,
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
currency: item.price?.currency,
recurring: item.price?.recurring,
})),
}));
}))
const formattedInvoices = invoices.data.map((invoice) => ({
id: invoice.id,
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
hosted_invoice_url: invoice.hosted_invoice_url,
invoice_pdf: invoice.invoice_pdf,
created: invoice.created,
}));
}))
return {
customerId,
subscriptions: formattedSubscriptions,
invoices: formattedInvoices,
};
}
} catch (error) {
console.error("[getPremiumStatus] error", error);
console.error('[getPremiumStatus] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer le statut Stripe.",
);
throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.')
}
});
})
const createStripeCustomerPortalSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour ouvrir le portail client Stripe.",
);
}
const stripe = getStripeClient();
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Aucun client Stripe associé à cet utilisateur.",
);
}
const portalConfigurationId = await getPortalConfigurationId(stripe);
if (!portalConfigurationId) {
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
throw new HttpsError(
"failed-precondition",
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`,
);
}
const { successUrl } = getReturnUrls();
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: successUrl,
configuration: portalConfigurationId,
});
return {
id: portalSession.id,
url: portalSession.url,
created: portalSession.created,
};
} catch (error) {
console.error("[createStripeCustomerPortalSession] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Ouverture du portail Stripe impossible.",
);
const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.')
}
},
);
const stripe = getStripeClient()
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: false,
})
if (!customerId) {
throw new HttpsError('failed-precondition', 'Aucun client Stripe associé à cet utilisateur.')
}
const portalConfigurationId = await getPortalConfigurationId(stripe)
if (!portalConfigurationId) {
const modeLabel = STRIPE_MODE === 'prod' ? 'production' : 'test'
const envKeySuffix = STRIPE_MODE === 'prod' ? 'PROD' : 'TEST'
throw new HttpsError(
'failed-precondition',
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`
)
}
const { successUrl } = getReturnUrls()
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: successUrl,
configuration: portalConfigurationId,
})
return {
id: portalSession.id,
url: portalSession.url,
created: portalSession.created,
}
} catch (error) {
console.error('[createStripeCustomerPortalSession] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, 'Ouverture du portail Stripe impossible.')
}
})
const resolveStripeConnectAccountId = (userData) => {
if (!userData || typeof userData !== "object") {
return null;
if (!userData || typeof userData !== 'object') {
return null
}
const candidatePaths = [
["stripeConnectAccountId"],
["stripeConnectId"],
["stripeAccountId"],
["stripeConnectAccount"],
["stripeAccount"],
["stripe", "connectAccountId"],
["stripe", "accountId"],
["providers", "stripeConnect", "accountId"],
];
['stripeConnectAccountId'],
['stripeConnectId'],
['stripeAccountId'],
['stripeConnectAccount'],
['stripeAccount'],
['stripe', 'connectAccountId'],
['stripe', 'accountId'],
['providers', 'stripeConnect', 'accountId'],
]
for (const path of candidatePaths) {
let current = userData;
let current = userData
for (const key of path) {
if (!current || typeof current !== "object") {
current = null;
break;
if (!current || typeof current !== 'object') {
current = null
break
}
current = current[key];
current = current[key]
}
if (typeof current === "string" && current.trim()) {
return current.trim();
if (typeof current === 'string' && current.trim()) {
return current.trim()
}
}
return null;
};
return null
}
const ensureStripeConnectAccount = async ({
uid,
stripe,
userRef,
userData,
}) => {
const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
if (!uid || !stripe) {
return { connectAccountId: null, userData, createdAccount: false };
return { connectAccountId: null, userData, createdAccount: false }
}
let connectAccountId = resolveStripeConnectAccountId(userData);
let connectAccountId = resolveStripeConnectAccountId(userData)
if (connectAccountId) {
return { connectAccountId, userData, createdAccount: false };
return { connectAccountId, userData, createdAccount: false }
}
let authRecord = null;
let authRecord = null
try {
authRecord = await admin.auth().getUser(uid);
authRecord = await admin.auth().getUser(uid)
} catch (error) {
console.warn(
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
error,
);
console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
}
const email = userData?.email || authRecord?.email || undefined;
const email = userData?.email || authRecord?.email || undefined
const accountParams = {
type: "express",
type: 'express',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
appMode: STRIPE_MODE || 'test',
},
};
if (email) {
accountParams.email = email;
}
const account = await stripe.accounts.create(accountParams);
connectAccountId = account?.id;
if (email) {
accountParams.email = email
}
const account = await stripe.accounts.create(accountParams)
connectAccountId = account?.id
if (!connectAccountId) {
throw new HttpsError(
"internal",
"Stripe na pas renvoyé didentifiant de compte Connect.",
);
throw new HttpsError('internal', 'Stripe na pas renvoyé didentifiant de compte Connect.')
}
const providersConnect = {
...(userData?.providers?.stripeConnect || {}),
accountId: connectAccountId,
};
}
if (userRef) {
await userRef.set(
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
},
},
},
{ merge: true },
);
{ merge: true }
)
}
return {
@@ -406,170 +362,140 @@ const ensureStripeConnectAccount = async ({
},
},
createdAccount: true,
};
};
}
}
const createStripeConnectLoginLink = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour ouvrir Stripe Connect.",
);
}
const userRef = refsList?.users?.doc(uid);
const snapshot = userRef ? await userRef.get() : null;
let userData = snapshot?.exists ? snapshot.data() : null;
const stripe = getStripeClient();
const ensureResult = await ensureStripeConnectAccount({
uid,
stripe,
userRef,
userData,
});
const connectAccountId = ensureResult.connectAccountId;
userData = ensureResult.userData;
if (!connectAccountId) {
throw new HttpsError(
"failed-precondition",
"Impossible de créer un compte Stripe Connect pour cet utilisateur.",
);
}
const redirectUrl = getReturnBaseUrl();
const loginLink = await stripe.accounts.createLoginLink(
connectAccountId,
redirectUrl
? {
redirect_url: redirectUrl,
}
: undefined,
);
if (!loginLink?.url) {
throw new HttpsError(
"internal",
"Stripe Connect na pas renvoyé de lien de connexion.",
);
}
return {
id: loginLink.id,
url: loginLink.url,
created: loginLink.created,
connectAccountId,
createdAccount: ensureResult.createdAccount,
};
} catch (error) {
console.error("[createStripeConnectLoginLink] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Ouverture de Stripe Connect impossible.",
);
const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.')
}
},
);
const userRef = refsList?.users?.doc(uid)
const snapshot = userRef ? await userRef.get() : null
let userData = snapshot?.exists ? snapshot.data() : null
const stripe = getStripeClient()
const ensureResult = await ensureStripeConnectAccount({
uid,
stripe,
userRef,
userData,
})
const connectAccountId = ensureResult.connectAccountId
userData = ensureResult.userData
if (!connectAccountId) {
throw new HttpsError(
'failed-precondition',
'Impossible de créer un compte Stripe Connect pour cet utilisateur.'
)
}
const redirectUrl = getReturnBaseUrl()
const loginLink = await stripe.accounts.createLoginLink(
connectAccountId,
redirectUrl
? {
redirect_url: redirectUrl,
}
: undefined
)
if (!loginLink?.url) {
throw new HttpsError('internal', 'Stripe Connect na pas renvoyé de lien de connexion.')
}
return {
id: loginLink.id,
url: loginLink.url,
created: loginLink.created,
connectAccountId,
createdAccount: ensureResult.createdAccount,
}
} catch (error) {
console.error('[createStripeConnectLoginLink] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, 'Ouverture de Stripe Connect impossible.')
}
})
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour vérifier un paiement Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
}
const rawPaymentId =
typeof request?.data?.paymentId === "string"
? request.data.paymentId.trim()
: "";
typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
if (!rawPaymentId) {
throw new HttpsError(
"invalid-argument",
"Fournis un identifiant de paiement Stripe.",
);
throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const fetchPaymentIntent = async (paymentIntentId) =>
stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ["latest_charge"],
});
expand: ['latest_charge'],
})
const fetchCheckoutSession = async (sessionId) =>
stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent"],
});
expand: ['payment_intent'],
})
let paymentIntent = null;
let checkoutSession = null;
let paymentType = null;
let paymentIntent = null
let checkoutSession = null
let paymentType = null
if (rawPaymentId.startsWith("pi_")) {
paymentIntent = await fetchPaymentIntent(rawPaymentId);
paymentType = "payment_intent";
} else if (rawPaymentId.startsWith("cs_")) {
checkoutSession = await fetchCheckoutSession(rawPaymentId);
if (rawPaymentId.startsWith('pi_')) {
paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = 'payment_intent'
} else if (rawPaymentId.startsWith('cs_')) {
checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentIntent =
checkoutSession?.payment_intent &&
typeof checkoutSession.payment_intent === "object"
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
paymentType = "checkout_session";
: null
paymentType = 'checkout_session'
} else {
try {
paymentIntent = await fetchPaymentIntent(rawPaymentId);
paymentType = "payment_intent";
paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = 'payment_intent'
} catch (intentError) {
try {
checkoutSession = await fetchCheckoutSession(rawPaymentId);
paymentType = "checkout_session";
checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentType = 'checkout_session'
paymentIntent =
checkoutSession?.payment_intent &&
typeof checkoutSession.payment_intent === "object"
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
: null
} catch (sessionError) {
console.error("[verifyStripePayment] lookup failure", {
console.error('[verifyStripePayment] lookup failure', {
intentError,
sessionError,
});
throw new HttpsError(
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
})
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
}
}
}
if (!paymentIntent && !checkoutSession) {
throw new HttpsError(
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
}
const normalizedIntent = paymentIntent
? normalizePaymentIntent(paymentIntent)
: null;
const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
const response = {
type: paymentType,
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
checkoutSession?.status ??
null,
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
};
}
const paymentDocId =
paymentType === "checkout_session"
? checkoutSession?.id
: normalizedIntent?.id;
paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
if (paymentDocId) {
const paymentDocRef = paymentsCollection.doc(paymentDocId);
const existing = await paymentDocRef.get();
const paymentDocRef = paymentsCollection.doc(paymentDocId)
const existing = await paymentDocRef.get()
if (existing.exists) {
await paymentDocRef.set(
{
status: response.status,
paymentStatus: checkoutSession?.payment_status,
amountTotal:
checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountReceived: normalizedIntent?.amount_received,
currency: response.currency,
updatedAt: getServerTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
}
}
return response;
return response
} catch (error) {
console.error("[verifyStripePayment] error", error);
console.error('[verifyStripePayment] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.')
}
});
})
module.exports = {
createCheckoutSession,
@@ -636,4 +559,4 @@ module.exports = {
createStripeCustomerPortalSession,
createStripeConnectLoginLink,
verifyStripePayment,
};
}
+50 -62
View File
@@ -1,29 +1,26 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
SUBSCRIPTION_PRICE_IDS,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCTS,
} = require("./constants");
const { parseCoinsPerMonth, formatCoinPack } = require("./shared");
} = require('./constants')
const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
const formatPlan = (price, priceId) => {
if (!price || typeof price !== "object") {
return null;
if (!price || typeof price !== 'object') {
return null
}
const product =
typeof price.product === "object" && price.product !== null
? price.product
: {};
const product = typeof price.product === 'object' && price.product !== null ? price.product : {}
return {
id: price.id || priceId,
priceId: price.id || priceId,
active: price.active !== false,
currency: price.currency || "eur",
currency: price.currency || 'eur',
unitAmount: price.unit_amount,
unitAmountDecimal: price.unit_amount_decimal,
transformQuantity: price.transform_quantity || null,
@@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => {
metadata: price.metadata || {},
product: {
id: product.id || null,
name: product.name || "",
description: product.description || "",
name: product.name || '',
description: product.description || '',
metadata: product.metadata || {},
},
};
};
}
}
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const stripe = getStripeClient()
const entries = await Promise.all(
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
@@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => {
priceIds.map(async (priceId) => {
try {
const price = await stripe.prices.retrieve(priceId, {
expand: ["product"],
});
return formatPlan(price, priceId);
expand: ['product'],
})
return formatPlan(price, priceId)
} catch (error) {
console.error(
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
error?.message || error,
);
return null;
error?.message || error
)
return null
}
}),
);
})
)
return [period, periodPlans.filter(Boolean)];
}),
);
return [period, periodPlans.filter(Boolean)]
})
)
return {
plans: Object.fromEntries(entries),
};
}
} catch (error) {
console.error("[subscription-listSubscriptionPlans] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les abonnements Stripe.",
);
console.error('[subscription-listSubscriptionPlans] error', error)
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les abonnements Stripe.')
}
});
})
const listCoinPacks = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const stripe = getStripeClient()
const packs = await Promise.all(
COIN_PACK_PRODUCTS.map(async (pack) => {
try {
const product = await stripe.products.retrieve(pack.productId, {
expand: ["default_price"],
});
expand: ['default_price'],
})
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
let resolvedPrice = null
if (typeof product?.default_price === 'string') {
resolvedPrice = await stripe.prices.retrieve(product.default_price)
} else if (product?.default_price && typeof product.default_price === 'object') {
resolvedPrice = product.default_price
}
const formatted = formatCoinPack({
product,
price: resolvedPrice,
});
})
return {
...formatted,
coinPackKey: pack.key || null,
};
}
} catch (error) {
console.error(
"[subscription-listCoinPacks] Unable to retrieve product",
'[subscription-listCoinPacks] Unable to retrieve product',
pack.productId,
error?.message || error,
);
return null;
error?.message || error
)
return null
}
}),
);
})
)
return {
packs: packs.filter(Boolean),
};
}
} catch (error) {
console.error("[subscription-listCoinPacks] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les packs de pièces.",
);
console.error('[subscription-listCoinPacks] error', error)
throw mapStripeErrorToHttps(error, 'Impossible de récupérer les packs de pièces.')
}
});
})
module.exports = {
listSubscriptionPlans,
listCoinPacks,
};
}
+221 -262
View File
@@ -1,4 +1,4 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const {
getStripeClient,
@@ -7,312 +7,271 @@ const {
getReturnUrls,
formatCheckoutSessionResponse,
mapStripeErrorToHttps,
} = require("../../helpers/stripe");
const { REGION } = require("./config");
} = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
ALL_SUBSCRIPTION_PRICE_IDS,
COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
const {
refsList,
formatCoinPack,
getSubscriptionMetaFromPrice,
} = require("./shared");
} = require('./constants')
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
const sanitizePriceId = (value) => {
if (typeof value !== "string") {
return "";
if (typeof value !== 'string') {
return ''
}
return value.trim();
};
return value.trim()
}
const resolveCheckoutUiMode = (request) => {
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
return "hosted";
if (!request || !request.data || typeof request.data.uiMode === 'undefined') {
return 'hosted'
}
if (typeof request.data.uiMode !== "string") {
if (typeof request.data.uiMode !== 'string') {
throw new HttpsError(
"invalid-argument",
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
);
'invalid-argument',
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").'
)
}
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
const normalizedUiMode = request.data.uiMode.trim().toLowerCase()
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
throw new HttpsError(
"invalid-argument",
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
);
'invalid-argument',
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`
)
}
return normalizedUiMode;
};
return normalizedUiMode
}
const withCheckoutNavigationParams = (
baseParams,
{ uiMode, successUrl, cancelUrl },
) => {
if (uiMode === "embedded") {
const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => {
if (uiMode === 'embedded') {
return {
...baseParams,
ui_mode: "embedded",
ui_mode: 'embedded',
return_url: undefined,
redirect_on_completion: "never",
};
redirect_on_completion: 'never',
}
}
return {
...baseParams,
success_url: successUrl,
cancel_url: cancelUrl,
};
};
}
}
const createSubscriptionCheckoutSession = onCall(
{ region: REGION },
async (request) => {
const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour souscrire un abonnement.')
}
const rawPriceId = request?.data?.priceId
const priceId = sanitizePriceId(rawPriceId)
if (!priceId) {
throw new HttpsError('invalid-argument', 'Un identifiant de prix Stripe est requis.')
}
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
throw new HttpsError(
'invalid-argument',
`L'identifiant de prix ${priceId} n'est pas pris en charge.`
)
}
const stripe = getStripeClient()
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const { lineItems, summary } = await buildCheckoutLineItems(
[
{
priceID: priceId,
quantity: 1,
isRenewable: true,
},
],
{ stripe }
)
const uiMode = resolveCheckoutUiMode(request)
const shouldProvideReturnUrls = uiMode !== 'embedded'
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null }
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
})
if (!customerId) {
throw new HttpsError(
'failed-precondition',
'Impossible de retrouver le client Stripe associé.'
)
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: 'subscription',
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: 'SUBSCRIPTION',
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
}
)
)
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error('[subscription-createSubscriptionCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'abonnement Stripe.")
}
})
const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Connecte-toi pour acheter un pack de pièces.')
}
const rawProductId = request?.data?.productId
const productId = typeof rawProductId === 'string' ? rawProductId.trim() : ''
if (!productId) {
throw new HttpsError('invalid-argument', 'Un identifiant de produit Stripe est requis.')
}
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
throw new HttpsError(
'invalid-argument',
`Le produit ${productId} n'est pas un pack de pièces autorisé`
)
}
const stripe = getStripeClient()
const product = await stripe.products.retrieve(productId, {
expand: ['default_price'],
})
let resolvedPrice = null
if (typeof product?.default_price === 'string') {
resolvedPrice = await stripe.prices.retrieve(product.default_price)
} else if (product?.default_price && typeof product.default_price === 'object') {
resolvedPrice = product.default_price
}
let coinPack = null
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour souscrire un abonnement.",
);
}
const rawPriceId = request?.data?.priceId;
const priceId = sanitizePriceId(rawPriceId);
if (!priceId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de prix Stripe est requis.",
);
}
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
throw new HttpsError(
"invalid-argument",
`L'identifiant de prix ${priceId} n'est pas pris en charge.`,
);
}
const stripe = getStripeClient();
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const { lineItems, summary } = await buildCheckoutLineItems(
[
{
priceID: priceId,
quantity: 1,
isRenewable: true,
},
],
{ stripe },
);
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "subscription",
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: "SUBSCRIPTION",
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
return formatCheckoutSessionResponse(session);
coinPack = formatCoinPack({
product,
price: resolvedPrice,
})
} catch (error) {
console.error(
"[subscription-createSubscriptionCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'abonnement Stripe.",
);
'[subscription-createCoinPackCheckoutSession] invalid coin pack metadata',
productId,
error?.message || error
)
throw new HttpsError(
'failed-precondition',
'Le pack Stripe est mal configuré (metadata.coins manquant).'
)
}
},
);
const createCoinPackCheckoutSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour acheter un pack de pièces.",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
'failed-precondition',
'Impossible de déterminer le prix Stripe pour ce pack.'
)
}
const rawProductId = request?.data?.productId;
const productId =
typeof rawProductId === "string" ? rawProductId.trim() : "";
const uiMode = resolveCheckoutUiMode(request)
const shouldProvideReturnUrls = uiMode !== 'embedded'
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null }
if (!productId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de produit Stripe est requis.",
);
}
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
})
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
throw new HttpsError(
"invalid-argument",
`Le produit ${productId} n'est pas un pack de pièces autorisé`,
);
}
if (!customerId) {
throw new HttpsError(
'failed-precondition',
'Impossible de retrouver le client Stripe associé.'
)
}
const stripe = getStripeClient();
const product = await stripe.products.retrieve(productId, {
expand: ["default_price"],
});
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
}
let coinPack = null;
try {
coinPack = formatCoinPack({
product,
price: resolvedPrice,
});
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
productId,
error?.message || error,
);
throw new HttpsError(
"failed-precondition",
"Le pack Stripe est mal configuré (metadata.coins manquant).",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
"failed-precondition",
"Impossible de déterminer le prix Stripe pour ce pack.",
);
}
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "payment",
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
allow_promotion_codes: false,
metadata: {
firebaseUID: uid,
purchaseType: "COIN_PACK",
coinPackProductId: coinPack.productId,
coinPackPriceId: coinPack.priceId,
coinAmount: coinPack.coinAmount,
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: 'payment',
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
allow_promotion_codes: false,
metadata: {
firebaseUID: uid,
purchaseType: 'COIN_PACK',
coinPackProductId: coinPack.productId,
coinPackPriceId: coinPack.priceId,
coinAmount: coinPack.coinAmount,
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
},
{
uiMode,
successUrl,
cancelUrl,
}
)
)
return formatCheckoutSessionResponse(session);
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'achat de pièces.",
);
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error('[subscription-createCoinPackCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error
}
},
);
throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat de pièces.")
}
})
module.exports = {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
resolveCheckoutUiMode,
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
const REGION = process.env.FIREBASE_REGION || "europe-west1";
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
module.exports = {
REGION,
};
}
+33 -43
View File
@@ -1,86 +1,76 @@
const SUBSCRIPTION_PRICE_IDS = {
monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f",
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
'price_1SPgitCzf2o5bDRdbnhLFx6f',
'price_1SPgjCCzf2o5bDRdr08Xzp8u',
'price_1SPgjaCzf2o5bDRdd9Xo2u26',
],
annual: [
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
"price_1SPgkXCzf2o5bDRdejBVxEBY",
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
'price_1SPgkDCzf2o5bDRdNGLVNeQ3',
'price_1SPgkXCzf2o5bDRdejBVxEBY',
'price_1SPgkqCzf2o5bDRdIcUwTDrm',
],
};
}
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat()
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
starter: 10,
pro: 40,
premium: 60,
};
}
const SUBSCRIPTION_PRICE_METADATA = {
price_1SPgitCzf2o5bDRdbnhLFx6f: {
level: "starter",
billingPeriod: "monthly",
level: 'starter',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
level: "pro",
billingPeriod: "monthly",
level: 'pro',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
level: "premium",
billingPeriod: "monthly",
level: 'premium',
billingPeriod: 'monthly',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
level: "starter",
billingPeriod: "annual",
level: 'starter',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgkXCzf2o5bDRdejBVxEBY: {
level: "pro",
billingPeriod: "annual",
level: 'pro',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
level: "premium",
billingPeriod: "annual",
level: 'premium',
billingPeriod: 'annual',
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
};
}
const COIN_PACK_PRODUCTS = [
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" },
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" },
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" },
];
{ productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' },
{ productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' },
{ productId: 'prod_TMPRpA0qQSHXj5', key: 'premium' },
]
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId)
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
(acc, pack) => ({
...acc,
[pack.productId]: pack,
}),
{},
);
{}
)
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing'])
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
module.exports = {
SUBSCRIPTION_PRICE_IDS,
@@ -93,4 +83,4 @@ module.exports = {
PREMIUM_SUBSCRIPTION_STATUSES,
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
};
}
+6 -12
View File
@@ -1,14 +1,8 @@
const { listSubscriptionPlans, listCoinPacks } = require("./catalog");
const {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
} = require("./checkout");
const {
cancelActiveSubscription,
getActiveSubscription,
} = require("./management");
const { handleStripeWebhook } = require("./webhooks");
const { processAnnualSubscriptionAllowances } = require("./schedule");
const { listSubscriptionPlans, listCoinPacks } = require('./catalog')
const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout')
const { cancelActiveSubscription, getActiveSubscription } = require('./management')
const { handleStripeWebhook } = require('./webhooks')
const { processAnnualSubscriptionAllowances } = require('./schedule')
module.exports = {
listSubscriptionPlans,
@@ -19,4 +13,4 @@ module.exports = {
createCoinPackCheckoutSession,
handleStripeWebhook,
processAnnualSubscriptionAllowances,
};
}
+66 -97
View File
@@ -1,91 +1,74 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { HttpsError, onCall } = require('firebase-functions/https')
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
} = require("./constants");
const {
refsList,
formatSubscriptionForClient,
resolveUserContext,
} = require("./shared");
const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe')
const { REGION } = require('./config')
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour gérer ton abonnement.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const userRef = refsList?.users?.doc(uid) || null;
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() || {} : {};
const userRef = refsList?.users?.doc(uid) || null
const snapshot = userRef ? await userRef.get() : null
const userData = snapshot?.exists ? snapshot.data() || {} : {}
const inputSubscriptionId =
typeof request?.data?.subscriptionId === "string"
? request.data.subscriptionId.trim()
: "";
typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : ''
let subscriptionId =
inputSubscriptionId ||
userData?.stripeSubscription?.id ||
userData?.stripeSubscription?.subscriptionId ||
null;
null
const customerId = userData?.stripeCustomerId || null;
const customerId = userData?.stripeCustomerId || null
if (!subscriptionId && customerId) {
try {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
status: 'all',
limit: 5,
});
const { data: subscriptionList = [] } = response || {};
})
const { data: subscriptionList = [] } = response || {}
const activeSubscription = subscriptionList.find(
(candidate) =>
candidate?.status &&
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
);
(candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status)
)
if (activeSubscription?.id) {
subscriptionId = activeSubscription.id;
subscriptionId = activeSubscription.id
}
} catch (error) {
console.warn(
"[subscription-cancelActiveSubscription] Unable to list subscriptions",
'[subscription-cancelActiveSubscription] Unable to list subscriptions',
customerId,
error?.message || error,
);
error?.message || error
)
}
}
if (!subscriptionId) {
throw new HttpsError(
"failed-precondition",
"Aucun abonnement actif à annuler.",
);
throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.')
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
if (!subscription) {
throw new HttpsError("not-found", "Abonnement introuvable côté Stripe.");
throw new HttpsError('not-found', 'Abonnement introuvable côté Stripe.')
}
if (subscription.status === "canceled") {
if (subscription.status === 'canceled') {
return {
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: true,
};
}
}
if (subscription.cancel_at_period_end === true) {
@@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: false,
};
}
}
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
cancel_at_period_end: true,
},
);
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
return {
subscriptionId: updatedSubscription.id,
@@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
currentPeriodEnd: updatedSubscription.current_period_end || null,
alreadyCanceled: false,
};
} catch (error) {
console.error("[subscription-cancelActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible d'annuler l'abonnement Stripe.",
);
} catch (error) {
console.error('[subscription-cancelActiveSubscription] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.")
}
});
})
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour récupérer ton abonnement.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const {
uid: resolvedUid,
@@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => {
} = await resolveUserContext({
metadata: request?.data?.metadata || {},
customerId: null,
});
})
const lookupUid = resolvedUid || uid;
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null;
const snapshot = lookupRef ? await lookupRef.get() : null;
const data = snapshot?.exists ? snapshot.data() || {} : userData || {};
const lookupUid = resolvedUid || uid
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null
const snapshot = lookupRef ? await lookupRef.get() : null
const data = snapshot?.exists ? snapshot.data() || {} : userData || {}
const subscriptionId =
data?.stripeSubscription?.id ||
data?.stripeSubscription?.subscriptionId ||
null;
const customerId = data?.stripeCustomerId || null;
data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null
const customerId = data?.stripeCustomerId || null
if (!subscriptionId && !customerId) {
return {
subscription: null,
customerId: null,
};
}
}
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price.product"],
});
expand: ['items.data.price.product'],
})
if (subscription) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId: subscription.customer || customerId || null,
};
}
}
}
if (customerId) {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
status: 'all',
limit: 5,
expand: ["data.items.data.price.product"],
});
const [subscription] = response?.data || [];
expand: ['data.items.data.price.product'],
})
const [subscription] = response?.data || []
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId,
};
}
}
}
return {
subscription: null,
customerId,
};
} catch (error) {
console.error("[subscription-getActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer l'abonnement Stripe.",
);
} catch (error) {
console.error('[subscription-getActiveSubscription] error', error)
if (error instanceof HttpsError) {
throw error
}
throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.")
}
});
})
module.exports = {
cancelActiveSubscription,
getActiveSubscription,
};
}
+74 -89
View File
@@ -1,106 +1,97 @@
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { onSchedule } = require('firebase-functions/v2/scheduler')
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { batchFirestore } = require("../../helpers/firebase");
const { BATCH_TYPE } = require("../../config/types");
const {
admin,
refsList,
computeNextGrantTimestamp,
getServerTimestamp,
} = require("./shared");
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { batchFirestore } = require('../../helpers/firebase')
const { BATCH_TYPE } = require('../../config/types')
const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared')
const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
// Toggle to stop monthly grants for annual subscriptions while keeping logic handy.
const ENABLE_ANNUAL_GRANT_SCHEDULER = true;
const ENABLE_ANNUAL_GRANT_SCHEDULER = true
const processAnnualSubscriptionAllowances = onSchedule(
{
schedule: "30 3 * * *",
timeZone: "Europe/Paris",
schedule: '30 3 * * *',
timeZone: 'Europe/Paris',
},
async () => {
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
console.log(
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)",
);
return;
console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)')
return
}
const nowTimestamp = admin.firestore.Timestamp.now();
const pageSize = 200;
let lastDoc = null;
let processedUsers = 0;
let grantsCreated = 0;
let docsToUpdate = [];
const nowTimestamp = admin.firestore.Timestamp.now()
const pageSize = 200
let lastDoc = null
let processedUsers = 0
let grantsCreated = 0
let docsToUpdate = []
const flushUpdates = async () => {
if (!docsToUpdate.length) {
return;
return
}
await batchFirestore({
docs: docsToUpdate,
type: BATCH_TYPE.UPDATE,
});
docsToUpdate = [];
};
})
docsToUpdate = []
}
try {
while (true) {
let query = refsList.users
.where("premiumBillingPeriod", "==", "annual")
.where("subscriptionGrantInterval", "==", "monthly")
.where("subscriptionNextGrantAt", "<=", nowTimestamp)
.orderBy("subscriptionNextGrantAt")
.limit(pageSize);
.where('premiumBillingPeriod', '==', 'annual')
.where('subscriptionGrantInterval', '==', 'monthly')
.where('subscriptionNextGrantAt', '<=', nowTimestamp)
.orderBy('subscriptionNextGrantAt')
.limit(pageSize)
if (lastDoc) {
query = query.startAfter(lastDoc);
query = query.startAfter(lastDoc)
}
const snapshot = await query.get();
const snapshot = await query.get()
if (snapshot.empty) {
break;
break
}
for (const doc of snapshot.docs) {
processedUsers += 1;
const data = doc.data() || {};
processedUsers += 1
const data = doc.data() || {}
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0);
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0)
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
continue;
continue
}
const status =
typeof data.stripeSubscriptionStatus === "string"
typeof data.stripeSubscriptionStatus === 'string'
? data.stripeSubscriptionStatus.toLowerCase()
: null;
: null
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
continue;
continue
}
const nextGrantAt = data.subscriptionNextGrantAt;
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") {
continue;
const nextGrantAt = data.subscriptionNextGrantAt
if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') {
continue
}
const nextGrantDate = nextGrantAt.toDate();
const nextGrantDate = nextGrantAt.toDate()
if (!nextGrantDate || nextGrantDate > new Date()) {
continue;
continue
}
const subscriptionInfo =
data.stripeSubscription &&
typeof data.stripeSubscription === "object"
data.stripeSubscription && typeof data.stripeSubscription === 'object'
? data.stripeSubscription
: {};
const subscriptionId =
subscriptionInfo.id || data.stripeSubscriptionId || null;
: {}
const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null
const orderId = subscriptionId
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`;
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`
try {
const { orderId: processedOrderId } = await createOrderDocument({
@@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule(
type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsPerMonth,
metadata: {
source: "STRIPE_SUBSCRIPTION",
schedule: "annual_scheduler",
source: 'STRIPE_SUBSCRIPTION',
schedule: 'annual_scheduler',
subscriptionId,
scheduledGrantAt: nextGrantDate.toISOString(),
},
orderId,
});
})
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1);
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd;
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1)
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd
if (
nextGrantTimestamp &&
currentPeriodEnd &&
typeof currentPeriodEnd.toDate === "function"
typeof currentPeriodEnd.toDate === 'function'
) {
const periodEndDate = currentPeriodEnd.toDate();
const nextGrantFutureDate = nextGrantTimestamp.toDate();
const periodEndDate = currentPeriodEnd.toDate()
const nextGrantFutureDate = nextGrantTimestamp.toDate()
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
nextGrantTimestamp = null;
nextGrantTimestamp = null
}
}
@@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule(
subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsPerMonth,
subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "annual_scheduler",
subscriptionLastGrantSource: 'annual_scheduler',
subscriptionNextGrantAt: nextGrantTimestamp || null,
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null,
subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null,
},
});
grantsCreated += 1;
})
grantsCreated += 1
if (docsToUpdate.length >= 450) {
await flushUpdates();
await flushUpdates()
}
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] Unable to create order",
'[subscription-processAnnualSubscriptionAllowances] Unable to create order',
{
userId: doc.id,
subscriptionId,
error: error?.message || error,
},
);
}
)
}
}
lastDoc = snapshot.docs[snapshot.docs.length - 1];
lastDoc = snapshot.docs[snapshot.docs.length - 1]
if (snapshot.size < pageSize) {
break;
break
}
}
await flushUpdates();
await flushUpdates()
console.log(
"[subscription-processAnnualSubscriptionAllowances] completed",
{
processedUsers,
grantsCreated,
},
);
console.log('[subscription-processAnnualSubscriptionAllowances] completed', {
processedUsers,
grantsCreated,
})
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] error",
error,
);
throw error;
console.error('[subscription-processAnnualSubscriptionAllowances] error', error)
throw error
}
},
);
}
)
module.exports = {
processAnnualSubscriptionAllowances,
};
}
+148 -171
View File
@@ -1,242 +1,226 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require("../../index");
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
const { refList } = require('../../index')
const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
const {
SUBSCRIPTION_LEVEL_ALLOWANCES,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
} = require('./constants')
const refsList = refList;
const paymentsCollection = admin.firestore().collection("payments");
let cachedStripeWebhookSecret = null;
const refsList = refList
const paymentsCollection = admin.firestore().collection('payments')
let cachedStripeWebhookSecret = null
const toFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const normalized = value.trim().replace(",", ".");
if (typeof value === 'string') {
const normalized = value.trim().replace(',', '.')
if (!normalized) {
return null;
return null
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
return null;
};
return null
}
const parseCoinsPerMonth = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
return null;
if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
return null
}
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
if (candidateValue === null || candidateValue <= 0) {
return null;
return null
}
return Math.round(candidateValue);
};
return Math.round(candidateValue)
}
const parseCoinAmount = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
return null;
if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
return null
}
const candidateValue = toFiniteNumber(metadata.coins);
const candidateValue = toFiniteNumber(metadata.coins)
if (candidateValue === null || candidateValue <= 0) {
return null;
return null
}
return Math.round(candidateValue);
};
return Math.round(candidateValue)
}
const toDateSafe = (value) => {
if (!value) {
return null;
return null
}
if (value instanceof Date) {
return value;
return value
}
if (typeof value?.toDate === "function") {
if (typeof value?.toDate === 'function') {
try {
return value.toDate();
return value.toDate()
} catch (_error) {
return null;
return null
}
}
if (typeof value === "number" && Number.isFinite(value)) {
if (typeof value === 'number' && Number.isFinite(value)) {
if (value > 1e12) {
return new Date(value);
return new Date(value)
}
return new Date(value * 1000);
return new Date(value * 1000)
}
return null;
};
return null
}
const addMonths = (date, months = 1) => {
if (!(date instanceof Date) || !Number.isFinite(months)) {
return null;
return null
}
const result = new Date(date.getTime());
const initialDay = result.getDate();
result.setMonth(result.getMonth() + months);
const result = new Date(date.getTime())
const initialDay = result.getDate()
result.setMonth(result.getMonth() + months)
if (result.getDate() !== initialDay) {
result.setDate(0);
result.setDate(0)
}
return result;
};
return result
}
const computeNextGrantTimestamp = (base, months = 1) => {
const baseDate = toDateSafe(base);
const baseDate = toDateSafe(base)
if (!baseDate) {
return null;
return null
}
const nextDate = addMonths(baseDate, months);
const nextDate = addMonths(baseDate, months)
if (!nextDate) {
return null;
return null
}
return admin.firestore.Timestamp.fromDate(nextDate);
};
return admin.firestore.Timestamp.fromDate(nextDate)
}
const getServerTimestamp = () => {
if (typeof FieldValue?.serverTimestamp === "function") {
return FieldValue.serverTimestamp();
if (typeof FieldValue?.serverTimestamp === 'function') {
return FieldValue.serverTimestamp()
}
const fallback = admin.firestore?.FieldValue;
if (typeof fallback?.serverTimestamp === "function") {
return fallback.serverTimestamp();
const fallback = admin.firestore?.FieldValue
if (typeof fallback?.serverTimestamp === 'function') {
return fallback.serverTimestamp()
}
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
};
throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
}
const resolveStripeWebhookSecret = () => {
if (cachedStripeWebhookSecret) {
return cachedStripeWebhookSecret;
return cachedStripeWebhookSecret
}
const envSecret =
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
? process.env.STRIPE_WEBHOOK_SECRET.trim()
: "";
const inlineSecret =
typeof STRIPE_WEBHOOK_SECRET === "string"
? STRIPE_WEBHOOK_SECRET.trim()
: "";
: ''
const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
const secret = envSecret || inlineSecret;
const secret = envSecret || inlineSecret
if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
throw new Error('STRIPE_WEBHOOK_SECRET not configured')
}
cachedStripeWebhookSecret = secret;
return cachedStripeWebhookSecret;
};
cachedStripeWebhookSecret = secret
return cachedStripeWebhookSecret
}
const toFirestoreTimestamp = (unixSeconds) => {
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
return null;
if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
return null
}
try {
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
} catch (error) {
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
return null;
console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
return null
}
};
}
const extractFirebaseUid = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
if (!metadata || typeof metadata !== 'object') {
return null
}
const candidates = [
metadata.firebaseUID,
metadata.firebaseUid,
metadata.uid,
metadata.userId,
];
const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
const candidate = candidates[index]
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim()
}
}
return null;
};
return null
}
const formatCoinPack = ({ product, price }) => {
if (!product || typeof product !== "object") {
return null;
if (!product || typeof product !== 'object') {
return null
}
const resolvedPrice =
price ||
(typeof product.default_price === "object" && product.default_price) ||
null;
price || (typeof product.default_price === 'object' && product.default_price) || null
const priceId =
(resolvedPrice && resolvedPrice.id) ||
(typeof product.default_price === "string" ? product.default_price : null);
(typeof product.default_price === 'string' ? product.default_price : null)
const coinAmount = parseCoinAmount(product?.metadata || {});
const coinAmount = parseCoinAmount(product?.metadata || {})
if (coinAmount === null) {
throw new Error(
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
);
throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
}
return {
productId: product.id,
priceId,
name: product.name || "",
description: product.description || "",
name: product.name || '',
description: product.description || '',
coinAmount,
currency:
resolvedPrice?.currency ||
(typeof resolvedPrice?.currency === "string"
? resolvedPrice.currency.toLowerCase()
: "eur"),
(typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
unitAmount: resolvedPrice?.unit_amount ?? null,
metadata: product.metadata || {},
};
};
}
}
const getSubscriptionMetaFromPrice = (priceId) => {
if (typeof priceId !== "string") {
return null;
if (typeof priceId !== 'string') {
return null
}
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
};
return SUBSCRIPTION_PRICE_METADATA[priceId] || null
}
const buildEventSnapshot = (eventType, entityId) => {
const now = admin.firestore.Timestamp.now();
const now = admin.firestore.Timestamp.now()
return {
eventType: eventType || null,
entityId: entityId || null,
syncedAt: now,
};
};
}
}
const formatSubscriptionForClient = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
if (!subscription || typeof subscription !== 'object') {
return null
}
return {
id: subscription.id,
status: subscription.status,
customer: subscription.customer,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
created: toFirestoreTimestamp(subscription.created),
@@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => {
}))
: [],
metadata: subscription.metadata || {},
};
};
}
}
const buildSubscriptionPayload = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
if (!subscription || typeof subscription !== 'object') {
return null
}
const items = Array.isArray(subscription.items?.data)
@@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => {
quantity: item.quantity || 0,
price: item.price || null,
}))
: [];
: []
const primaryItem = items[0] || null;
const productId = primaryItem?.productId || null;
const priceId = primaryItem?.priceId || null;
const primaryItem = items[0] || null
const productId = primaryItem?.productId || null
const priceId = primaryItem?.priceId || null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const metadataLevel = subscription.metadata?.subscriptionLevel || null
const resolvedLevel = metadataLevel || priceMeta?.level || null
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const customerId =
typeof subscription.customer === "string" ? subscription.customer : null;
const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
return {
id: subscription.id,
@@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => {
billingPeriod: resolvedPeriod,
status: subscription.status || null,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
};
};
}
}
const resolveUserContext = async ({ metadata, customerId }) => {
const firebaseUid = extractFirebaseUid(metadata);
const firebaseUid = extractFirebaseUid(metadata)
if (firebaseUid) {
const userRef = refsList?.users?.doc(firebaseUid) || null;
const userRef = refsList?.users?.doc(firebaseUid) || null
if (userRef) {
try {
const snapshot = await userRef.get();
const snapshot = await userRef.get()
if (snapshot.exists) {
return {
uid: firebaseUid,
userRef,
userData: snapshot.data() || null,
};
}
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to read user",
'[subscription-resolveUserContext] Unable to read user',
firebaseUid,
error?.message || error,
);
error?.message || error
)
}
}
}
@@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => {
if (customerId) {
try {
const snapshot = await refsList.users
.where("stripeCustomerId", "==", customerId)
.where('stripeCustomerId', '==', customerId)
.limit(1)
.get();
.get()
if (!snapshot.empty) {
const doc = snapshot.docs[0];
const doc = snapshot.docs[0]
return {
uid: doc.id,
userRef: doc.ref,
userData: doc.data() || null,
};
}
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to query by customer",
'[subscription-resolveUserContext] Unable to query by customer',
customerId,
error?.message || error,
);
error?.message || error
)
}
}
@@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => {
uid: firebaseUid || null,
userRef: null,
userData: null,
};
};
}
}
const upsertPaymentDocument = async (docId, data = {}) => {
if (!docId) {
return null;
return null
}
const docRef = paymentsCollection.doc(docId);
const docRef = paymentsCollection.doc(docId)
try {
await docRef.set(
{
@@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => {
updatedAt: getServerTimestamp(),
createdAt: getServerTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
} catch (error) {
console.error(
"[subscription-upsertPaymentDocument] Failed to persist payment",
docId,
error,
);
console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
}
return docRef;
};
return docRef
}
module.exports = {
admin,
@@ -398,4 +375,4 @@ module.exports = {
resolveUserContext,
upsertPaymentDocument,
COIN_PACK_PRODUCT_MAP,
};
}
+221 -280
View File
@@ -1,9 +1,9 @@
const { onRequest } = require("firebase-functions/v2/https");
const { HttpsError } = require("firebase-functions/https");
const { onRequest } = require('firebase-functions/v2/https')
const { HttpsError } = require('firebase-functions/https')
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { getStripeClient } = require("../../helpers/stripe");
const { REGION } = require("./config");
const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders')
const { getStripeClient } = require('../../helpers/stripe')
const { REGION } = require('./config')
const {
paymentsCollection,
resolveStripeWebhookSecret,
@@ -17,25 +17,21 @@ const {
buildSubscriptionPayload,
resolveUserContext,
upsertPaymentDocument,
} = require("./shared");
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants");
} = require('./shared')
const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants')
const handleCheckoutSessionCompleted = async (
session,
event,
{ stripe } = {},
) => {
if (!session || typeof session !== "object") {
return;
const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => {
if (!session || typeof session !== 'object') {
return
}
const firebaseUid = extractFirebaseUid(session.metadata);
const firebaseUid = extractFirebaseUid(session.metadata)
const paymentDocRef = await upsertPaymentDocument(session.id, {
userId: firebaseUid || null,
customerId: session.customer || null,
subscriptionId: session.subscription || null,
invoiceId: session.invoice || null,
status: session.status || "completed",
status: session.status || 'completed',
paymentStatus: session.payment_status || null,
mode: session.mode || null,
amountSubtotal: session.amount_subtotal ?? null,
@@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async (
metadata: session.metadata || {},
completedAt: toFirestoreTimestamp(session.created),
expiresAt: toFirestoreTimestamp(session.expires_at),
paymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: null,
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null,
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
})
const { uid, userRef } = await resolveUserContext({
metadata: session.metadata,
customerId: session.customer,
});
})
if (userRef) {
const lastEvent = buildEventSnapshot(event?.type, session.id);
const lastEvent = buildEventSnapshot(event?.type, session.id)
if (firebaseUid) {
lastEvent.uid = firebaseUid;
lastEvent.uid = firebaseUid
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
}
if (session.customer) {
userUpdate.stripeCustomerId = session.customer;
userUpdate.stripeCustomerId = session.customer
}
if (session.metadata?.subscriptionLevel) {
userUpdate.premiumLevel = session.metadata.subscriptionLevel;
userUpdate.premiumLevel = session.metadata.subscriptionLevel
}
if (session.metadata?.subscriptionBillingPeriod) {
userUpdate.premiumBillingPeriod =
session.metadata.subscriptionBillingPeriod;
userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod
}
await userRef.set(userUpdate, { merge: true });
await userRef.set(userUpdate, { merge: true })
}
if (
stripe &&
session.mode === "subscription" &&
typeof session.subscription === "string" &&
session.mode === 'subscription' &&
typeof session.subscription === 'string' &&
session.subscription
) {
try {
const subscription = await stripe.subscriptions.retrieve(
session.subscription,
{ expand: ["items.data.price.product"] },
);
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
expand: ['items.data.price.product'],
})
if (subscription) {
await handleCustomerSubscriptionEvent(subscription, event, { stripe });
await handleCustomerSubscriptionEvent(subscription, event, { stripe })
}
} catch (error) {
console.error(
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription",
'[subscription-handleCheckoutSessionCompleted] Unable to sync subscription',
session.subscription,
error,
);
error
)
}
}
if (
session.mode === "payment" &&
(session.payment_status === "paid" ||
session.payment_status === "no_payment_required") &&
session.metadata?.purchaseType === "COIN_PACK" &&
session.mode === 'payment' &&
(session.payment_status === 'paid' || session.payment_status === 'no_payment_required') &&
session.metadata?.purchaseType === 'COIN_PACK' &&
userRef
) {
const coinAmountRaw = Number(session.metadata?.coinAmount || 0);
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0;
const coinAmountRaw = Number(session.metadata?.coinAmount || 0)
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0
if (coinAmount > 0 && paymentDocRef) {
let paymentSnapshot = null;
let paymentSnapshot = null
try {
paymentSnapshot = await paymentDocRef.get();
paymentSnapshot = await paymentDocRef.get()
} catch (error) {
console.warn(
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc",
'[subscription-handleCheckoutSessionCompleted] Unable to read payment doc',
session.id,
error?.message || error,
);
error?.message || error
)
}
const alreadyGranted = Boolean(
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
);
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt
)
if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id;
const targetUserId = uid || firebaseUid || userRef.id
await createOrderDocument({
userId: targetUserId,
type: ORDER_TYPES.COINS,
amount: coinAmount,
metadata: {
source: "STRIPE_CHECKOUT",
source: 'STRIPE_CHECKOUT',
paymentId: session.id || null,
coinPackKey: session.metadata?.coinPackKey || null,
},
orderId: `stripe_${session.id}`,
});
})
await paymentDocRef.set(
{
@@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async (
coinPackGrantedAmount: coinAmount,
coinPackGrantedKey: session.metadata?.coinPackKey || null,
},
{ merge: true },
);
{ merge: true }
)
}
}
}
};
}
const handleCustomerSubscriptionEvent = async (
subscription,
event,
{ stripe } = {},
) => {
if (!subscription || typeof subscription !== "object") {
return;
const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => {
if (!subscription || typeof subscription !== 'object') {
return
}
const subscriptionPayload = buildSubscriptionPayload(subscription);
const subscriptionPayload = buildSubscriptionPayload(subscription)
if (!subscriptionPayload) {
return;
return
}
const {
@@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async (
} = await resolveUserContext({
metadata: subscription.metadata,
customerId: subscription.customer,
});
})
let userData = resolvedUserData || null;
let userData = resolvedUserData || null
if (!userData && userRef) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to read user",
'[subscription-handleCustomerSubscriptionEvent] Unable to read user',
subscription.customer,
error?.message || error,
);
error?.message || error
)
}
}
let resolvedCustomerId = subscriptionPayload?.customerId || null;
let resolvedCustomerId = subscriptionPayload?.customerId || null
if (!resolvedCustomerId && subscription.customer) {
resolvedCustomerId = subscription.customer;
resolvedCustomerId = subscription.customer
}
const fallbackUid = extractFirebaseUid(subscription.metadata);
const resolvedUid = uid || fallbackUid || null;
const fallbackUid = extractFirebaseUid(subscription.metadata)
const resolvedUid = uid || fallbackUid || null
await upsertPaymentDocument(subscription.id, {
userId: resolvedUid,
customerId: resolvedCustomerId,
subscriptionId: subscription.id || null,
status: subscription.status || null,
mode: "subscription",
mode: 'subscription',
priceId: subscriptionPayload?.priceId || null,
productId: subscriptionPayload?.productId || null,
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
@@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async (
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
})
if (!userRef) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
{
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
},
);
return;
console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', {
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
})
return
}
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
const lastEvent = buildEventSnapshot(event?.type, subscription.id)
if (resolvedUid) {
lastEvent.uid = resolvedUid;
lastEvent.uid = resolvedUid
}
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const metadataPeriod =
subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId)
const metadataLevel = subscription.metadata?.subscriptionLevel || null
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
const resolvedLevel = metadataLevel || priceMeta?.level || null
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
const isPremium = subscription.status
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
: false;
: false
const primaryItem = Array.isArray(subscriptionPayload?.items)
? subscriptionPayload.items[0]
: null;
: null
let coinsPerMonth = null;
let coinsPerMonth = null
const productMetadata =
primaryItem?.price &&
typeof primaryItem.price === "object" &&
typeof primaryItem.price === 'object' &&
primaryItem.price.product &&
typeof primaryItem.price.product === "object"
typeof primaryItem.price.product === 'object'
? primaryItem.price.product.metadata
: null;
: null
coinsPerMonth = parseCoinsPerMonth(productMetadata || {});
coinsPerMonth = parseCoinsPerMonth(productMetadata || {})
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
try {
const priceWithProduct = await stripe.prices.retrieve(
primaryItem.price.id,
{ expand: ["product"] },
);
coinsPerMonth = parseCoinsPerMonth(
priceWithProduct?.product?.metadata || {},
);
const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, {
expand: ['product'],
})
coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {})
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata",
'[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata',
primaryItem.price.id,
error?.message || error,
);
error?.message || error
)
}
}
let subscriptionNextGrantAt = null;
let subscriptionNextGrantAt = null
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
subscriptionNextGrantAt = computeNextGrantTimestamp(
subscriptionPayload.currentPeriodEnd,
1,
);
subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1)
}
const userUpdate = {
@@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async (
premiumLevel: isPremium ? resolvedLevel : null,
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
subscriptionNextGrantAt,
};
}
if (!isPremium) {
userUpdate.subscriptionNextGrantAt = null;
userUpdate.subscriptionNextGrantAt = null
}
await userRef.set(userUpdate, { merge: true });
};
await userRef.set(userUpdate, { merge: true })
}
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (!invoice || typeof invoice !== "object") {
return;
if (!invoice || typeof invoice !== 'object') {
return
}
const firebaseUid = extractFirebaseUid(invoice.metadata);
const eventType = event?.type || null;
const paymentDocRef = paymentsCollection.doc(invoice.id);
const firebaseUid = extractFirebaseUid(invoice.metadata)
const eventType = event?.type || null
const paymentDocRef = paymentsCollection.doc(invoice.id)
let resolvedSubscriptionId =
typeof invoice.subscription === "string" && invoice.subscription
? invoice.subscription
: null;
typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null
if (!resolvedSubscriptionId) {
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
? invoice.lines.data
.map((line) =>
typeof line?.subscription === "string" && line.subscription
? line.subscription
: null,
typeof line?.subscription === 'string' && line.subscription ? line.subscription : null
)
.find((value) => value)
: null;
: null
if (lineSubscriptionId) {
resolvedSubscriptionId = lineSubscriptionId;
console.log(
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line",
{
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
},
);
resolvedSubscriptionId = lineSubscriptionId
console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', {
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
})
}
}
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
console.log('[subscription-handleInvoiceEvent] Received invoice webhook', {
eventType,
invoiceId: invoice?.id || null,
subscriptionId: invoice?.subscription || null,
@@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
status: invoice?.status || null,
billingReason: invoice?.billing_reason || null,
attemptCount: invoice?.attempt_count ?? null,
});
})
await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null,
customerId: invoice.customer || null,
subscriptionId: resolvedSubscriptionId,
status: invoice.status || null,
paymentStatus:
eventType === "invoice.payment_failed"
? "failed"
: invoice.status || null,
paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null,
amountDue: invoice.amount_due ?? null,
amountPaid: invoice.amount_paid ?? null,
amountRemaining: invoice.amount_remaining ?? null,
@@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
lastEventType: eventType,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
mode: "invoice",
});
mode: 'invoice',
})
const {
uid,
@@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
} = await resolveUserContext({
metadata: invoice.metadata,
customerId: invoice.customer,
});
})
if (!userRef) {
console.warn(
"[subscription-handleInvoiceEvent] User context not resolved",
{
invoiceId: invoice?.id || null,
customerId: invoice?.customer || null,
firebaseUid: firebaseUid || null,
metadataKeys: Object.keys(invoice?.metadata || {}),
},
);
return;
console.warn('[subscription-handleInvoiceEvent] User context not resolved', {
invoiceId: invoice?.id || null,
customerId: invoice?.customer || null,
firebaseUid: firebaseUid || null,
metadataKeys: Object.keys(invoice?.metadata || {}),
})
return
}
let userData = resolvedUserData || null;
let userData = resolvedUserData || null
if (!userData) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
const snapshot = await userRef.get()
userData = snapshot.exists ? snapshot.data() || null : null
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to read user",
'[subscription-handleInvoiceEvent] Unable to read user',
invoice.customer,
error?.message || error,
);
error?.message || error
)
}
}
const lastEvent = buildEventSnapshot(eventType, invoice.id);
const lastEvent = buildEventSnapshot(eventType, invoice.id)
if (firebaseUid) {
lastEvent.uid = firebaseUid;
lastEvent.uid = firebaseUid
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
}
const billingReason = invoice.billing_reason || null;
const isInvoicePaid = invoice.status === "paid";
if (
billingReason === "subscription_create" ||
billingReason === "subscription_cycle"
) {
const billingReason = invoice.billing_reason || null
const isInvoicePaid = invoice.status === 'paid'
if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') {
if (isInvoicePaid && invoice.subscription) {
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp();
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp()
}
}
await userRef.set(userUpdate, { merge: true });
await userRef.set(userUpdate, { merge: true })
const subscriptionLine = Array.isArray(invoice?.lines?.data)
? invoice.lines.data.find(
(line) =>
line &&
typeof line === "object" &&
(line.type === "subscription" || line.price),
(line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price)
)
: null;
: null
const priceId =
typeof subscriptionLine?.price?.id === "string"
typeof subscriptionLine?.price?.id === 'string'
? subscriptionLine.price.id
: typeof subscriptionLine?.price === "string"
: typeof subscriptionLine?.price === 'string'
? subscriptionLine.price
: null;
: null
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
const billingPeriod =
priceMeta.billingPeriod ||
(subscriptionLine?.price?.recurring?.interval === "year"
? "annual"
: subscriptionLine?.price?.recurring?.interval === "month"
? "monthly"
: null);
(subscriptionLine?.price?.recurring?.interval === 'year'
? 'annual'
: subscriptionLine?.price?.recurring?.interval === 'month'
? 'monthly'
: null)
const coinsPerMonth =
priceMeta.coinsPerMonth ??
parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ??
null;
null
const coinsToGrant =
billingPeriod === "annual" && typeof coinsPerMonth === "number"
? coinsPerMonth * 12
: null;
billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null
const targetSubscriptionId =
resolvedSubscriptionId ||
(typeof invoice.subscription === "string" ? invoice.subscription : null) ||
(typeof subscriptionLine?.subscription === "string"
? subscriptionLine.subscription
: null);
(typeof invoice.subscription === 'string' ? invoice.subscription : null) ||
(typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null)
const shouldGrantUpfront =
isInvoicePaid &&
coinsToGrant &&
(billingReason === "subscription_create" ||
billingReason === "subscription_cycle");
(billingReason === 'subscription_create' || billingReason === 'subscription_cycle')
if (shouldGrantUpfront && targetSubscriptionId) {
let grantSnapshot = null;
let grantSnapshot = null
try {
grantSnapshot = await paymentDocRef.get();
grantSnapshot = await paymentDocRef.get()
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to read payment doc before grant",
'[subscription-handleInvoiceEvent] Unable to read payment doc before grant',
invoice?.id || null,
error?.message || error,
);
error?.message || error
)
}
const alreadyGranted =
grantSnapshot?.exists &&
Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt);
grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt)
if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id;
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`;
const targetUserId = uid || firebaseUid || userRef.id
const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`
try {
const { orderId: processedOrderId } = await createOrderDocument({
@@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsToGrant,
metadata: {
source: "STRIPE_INVOICE",
source: 'STRIPE_INVOICE',
billingPeriod,
coinsPerMonth,
invoiceId: invoice.id || null,
subscriptionId: targetSubscriptionId,
grantStrategy: "upfront",
grantStrategy: 'upfront',
},
orderId,
});
})
await paymentDocRef.set(
{
subscriptionCoinsGrantedAt: getServerTimestamp(),
subscriptionCoinsGrantAmount: coinsToGrant,
subscriptionCoinsGrantOrderId: processedOrderId,
subscriptionCoinsGrantSource: "invoice_upfront",
subscriptionCoinsGrantSource: 'invoice_upfront',
},
{ merge: true },
);
{ merge: true }
)
await userRef.set(
{
subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsToGrant,
subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "invoice_upfront",
subscriptionLastGrantSource: 'invoice_upfront',
subscriptionNextGrantAt: null,
subscriptionGrantInterval: null,
subscriptionGrantStrategy: "upfront",
subscriptionGrantStrategy: 'upfront',
subscriptionCoinsPerMonth: coinsPerMonth,
},
{ merge: true },
);
{ merge: true }
)
} catch (error) {
console.error(
"[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins",
'[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins',
{
invoiceId: invoice?.id || null,
subscriptionId: targetSubscriptionId,
error: error?.message || error,
},
);
}
)
}
}
}
};
}
const handleStripeWebhookEvent = async ({ event, stripe }) => {
if (!event || typeof event !== "object") {
return;
if (!event || typeof event !== 'object') {
return
}
const eventType = event.type;
const eventType = event.type
switch (eventType) {
case "checkout.session.completed":
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data?.object, event, {
stripe,
});
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
})
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleCustomerSubscriptionEvent(event.data?.object, event, {
stripe,
});
break;
case "invoice.payment_succeeded":
case "invoice.payment_failed":
case "invoice.finalized":
await handleInvoiceEvent(event.data?.object, event, { stripe });
break;
})
break
case 'invoice.payment_succeeded':
case 'invoice.payment_failed':
case 'invoice.finalized':
await handleInvoiceEvent(event.data?.object, event, { stripe })
break
default:
console.log(
"[subscription-handleStripeWebhookEvent] Unhandled event type",
eventType,
);
console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType)
}
};
}
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
if (req.method !== "POST") {
res.status(405).send("Method Not Allowed");
return;
if (req.method !== 'POST') {
res.status(405).send('Method Not Allowed')
return
}
const signature = req.headers["stripe-signature"];
const signature = req.headers['stripe-signature']
if (!signature) {
res.status(400).send("Missing Stripe signature");
return;
res.status(400).send('Missing Stripe signature')
return
}
let rawBody = req.rawBody;
let rawBody = req.rawBody
if (!rawBody && req.body) {
rawBody = Buffer.from(JSON.stringify(req.body));
rawBody = Buffer.from(JSON.stringify(req.body))
}
if (!rawBody) {
res.status(400).send("Missing request body");
return;
res.status(400).send('Missing request body')
return
}
let stripe = null;
let stripe = null
try {
stripe = getStripeClient();
stripe = getStripeClient()
} catch (error) {
console.error(
"[subscription-handleStripeWebhook] Stripe client error",
error,
);
res.status(500).send("Client Stripe indisponible");
return;
console.error('[subscription-handleStripeWebhook] Stripe client error', error)
res.status(500).send('Client Stripe indisponible')
return
}
try {
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
resolveStripeWebhookSecret(),
);
const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret())
await handleStripeWebhookEvent({ event, stripe });
await handleStripeWebhookEvent({ event, stripe })
res.status(200).send({ received: true });
res.status(200).send({ received: true })
} catch (error) {
console.error("[subscription-handleStripeWebhook] error", error);
console.error('[subscription-handleStripeWebhook] error', error)
if (error instanceof HttpsError) {
res.status(400).send(error.message);
return;
res.status(400).send(error.message)
return
}
res.status(500).send("Erreur lors du traitement du webhook");
res.status(500).send('Erreur lors du traitement du webhook')
}
});
})
module.exports = {
handleStripeWebhook,
};
}
+72 -83
View File
@@ -1,151 +1,140 @@
const { onObjectFinalized } = require("firebase-functions/v2/storage");
const logger = require("firebase-functions/logger");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { refList } = require("../index");
const { onObjectFinalized } = require('firebase-functions/v2/storage')
const logger = require('firebase-functions/logger')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const crypto = require('node:crypto')
const { refList } = require('../index')
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
exports.generateVideoThumbnail = onObjectFinalized(
{
region: "europe-west1",
region: 'europe-west1',
timeoutSeconds: 180,
memory: "1GiB",
memory: '1GiB',
cpu: 1,
},
async (event) => {
const file = event.data || {};
const bucketName = file.bucket;
const objectName = file.name || "";
const contentType = file.contentType || "";
const file = event.data || {}
const bucketName = file.bucket
const objectName = file.name || ''
const contentType = file.contentType || ''
// Basic guards + helpful logs for debugging why events might be ignored
if (!bucketName || !objectName) {
logger.info("[Thumbnail] Ignored: missing bucket or object name", {
logger.info('[Thumbnail] Ignored: missing bucket or object name', {
bucketName,
objectName,
contentType,
});
return;
})
return
}
// Avoid processing our own generated thumbnails
if (/_thumb9x16\.jpg$/i.test(objectName)) {
logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName });
return;
logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName })
return
}
// Accept if contentType says it's a video OR fallback to extension-based check
const isVideoContentType =
typeof contentType === "string" && contentType.startsWith("video/");
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(
objectName.toLowerCase()
);
const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/')
const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase())
if (!isVideoContentType && !isVideoLikeName) {
logger.info("[Thumbnail] Ignored: not a video", {
logger.info('[Thumbnail] Ignored: not a video', {
objectName,
contentType,
});
return;
})
return
}
logger.info("[Thumbnail] Event accepted", {
logger.info('[Thumbnail] Event accepted', {
bucketName,
objectName,
contentType,
});
})
const bucket = admin.storage().bucket(bucketName);
const playbackMatch = objectName.match(
/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i
);
const projectId = playbackMatch ? playbackMatch[1] : null;
const bucket = admin.storage().bucket(bucketName)
const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i)
const projectId = playbackMatch ? playbackMatch[1] : null
// Use unique folder under /tmp to avoid name collisions
const tmpDir = path.join(
os.tmpdir(),
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
);
const baseName = path.basename(objectName);
const dirName = path.posix.dirname(objectName);
const localVideoPath = path.join(tmpDir, baseName);
const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`)
const baseName = path.basename(objectName)
const dirName = path.posix.dirname(objectName)
const localVideoPath = path.join(tmpDir, baseName)
const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg";
const localThumbPath = path.join(tmpDir, thumbBase);
const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg'
const localThumbPath = path.join(tmpDir, thumbBase)
const remoteThumbPath =
dirName && dirName !== "."
? path.posix.join(dirName, thumbBase)
: thumbBase;
dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase
try {
await fs.mkdir(tmpDir, { recursive: true });
await fs.mkdir(tmpDir, { recursive: true })
// Télécharger la vidéo depuis le bucket
await bucket.file(objectName).download({ destination: localVideoPath });
await bucket.file(objectName).download({ destination: localVideoPath })
// Extraire 1 frame en 9:16 (1080x1920) de manière robuste
const vfCoverCrop =
"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920";
const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920'
const vfPad =
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2'
const extractFrame = (ssSeconds, vf) =>
new Promise((resolve, reject) => {
ffmpeg(localVideoPath)
.inputOptions([`-ss ${ssSeconds}`])
.frames(1)
.outputOptions(["-vf", vf, "-q:v", "2"])
.outputOptions(['-vf', vf, '-q:v', '2'])
.output(localThumbPath)
.on("end", resolve)
.on("error", reject)
.run();
});
.on('end', resolve)
.on('error', reject)
.run()
})
try {
// 1) Essai principal: 1s + cover/crop
await extractFrame(1, vfCoverCrop);
await extractFrame(1, vfCoverCrop)
} catch (e1) {
logger.warn("[Thumbnail] First attempt failed, retrying at 0s", {
logger.warn('[Thumbnail] First attempt failed, retrying at 0s', {
objectName,
error: e1?.message || String(e1),
});
})
try {
// 2) Deuxième essai: 0s + cover/crop (si vidéo très courte)
await extractFrame(0, vfCoverCrop);
await extractFrame(0, vfCoverCrop)
} catch (e2) {
logger.warn("[Thumbnail] Second attempt failed, fallback to pad", {
logger.warn('[Thumbnail] Second attempt failed, fallback to pad', {
objectName,
error: e2?.message || String(e2),
});
})
// 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin)
await extractFrame(0, vfPad);
await extractFrame(0, vfPad)
}
}
// Upload du thumbnail avec un token de téléchargement public Firebase
const downloadToken = crypto.randomUUID();
const downloadToken = crypto.randomUUID()
await bucket.upload(localThumbPath, {
destination: remoteThumbPath,
metadata: {
contentType: "image/jpeg",
cacheControl: "public, max-age=86400",
contentType: 'image/jpeg',
cacheControl: 'public, max-age=86400',
metadata: {
original: objectName,
aspect: "9:16",
t: "1s",
aspect: '9:16',
t: '1s',
firebaseStorageDownloadTokens: downloadToken,
},
},
});
})
const encodedPath = encodeURIComponent(remoteThumbPath);
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
const encodedPath = encodeURIComponent(remoteThumbPath)
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`
if (projectId) {
await refList.projects.doc(projectId).set(
@@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
}
logger.info("✅ [Thumbnail] Uploaded", {
logger.info('✅ [Thumbnail] Uploaded', {
objectName,
remoteThumbPath,
projectId,
thumbnailUrl,
});
})
} catch (error) {
logger.error("❌ [Thumbnail] Failed", {
logger.error('❌ [Thumbnail] Failed', {
objectName,
error: error?.message || String(error),
});
throw error;
})
throw error
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(tmpDir, { recursive: true, force: true })
}
}
);
)
+115 -129
View File
@@ -1,35 +1,35 @@
// functions/mergeVideoAndAudio.js (ou dans index.js)
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const axios = require("axios");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const ffmpeg = require("fluent-ffmpeg");
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
const { Buffer } = require("node:buffer");
const { FieldValue } = require("firebase-admin/firestore");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const admin = require('firebase-admin')
const logger = require('firebase-functions/logger')
const axios = require('axios')
const fs = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const crypto = require('node:crypto')
const ffmpeg = require('fluent-ffmpeg')
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg')
const { Buffer } = require('node:buffer')
const { FieldValue } = require('firebase-admin/firestore')
if (!admin.apps.length) admin.initializeApp();
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
if (!admin.apps.length) admin.initializeApp()
ffmpeg.setFfmpegPath(ffmpegInstaller.path)
const db = admin.firestore();
const PLAYBACK_CODEC_TAG = "h264-v1";
const db = admin.firestore()
const PLAYBACK_CODEC_TAG = 'h264-v1'
async function downloadToFile(url, destPath) {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
}
const res = await axios.get(url, { responseType: "arraybuffer" });
await fs.writeFile(destPath, Buffer.from(res.data));
return res.headers?.["content-type"] || "";
const res = await axios.get(url, { responseType: 'arraybuffer' })
await fs.writeFile(destPath, Buffer.from(res.data))
return res.headers?.['content-type'] || ''
}
const SCALE_FILTER =
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
return new Promise((resolve, reject) => {
@@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
.input(videoPath) // 0:v
.input(audioPath) // 1:a
.outputOptions([
"-map",
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
"-map",
"1:a:0", // prendre la 1re piste audio de l'entrée 1
'-map',
'0:v:0', // garder la 1re piste vidéo de l'entrée 0
'-map',
'1:a:0', // prendre la 1re piste audio de l'entrée 1
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
"-vf",
'-vf',
SCALE_FILTER,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"22",
"-pix_fmt",
"yuv420p",
"-profile:v",
"high",
"-level:v",
"4.1",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
"-shortest", // couper à la plus courte des 2 sources
"-tag:v",
"avc1",
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'22',
'-pix_fmt',
'yuv420p',
'-profile:v',
'high',
'-level:v',
'4.1',
'-c:a',
'aac',
'-b:a',
'192k',
'-movflags',
'+faststart',
'-shortest', // couper à la plus courte des 2 sources
'-tag:v',
'avc1',
])
.on("error", reject)
.on("end", resolve)
.save(outPath);
});
.on('error', reject)
.on('end', resolve)
.save(outPath)
})
}
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
const videoPath = path.join(tmpDir, "video.mp4");
const audioPath = path.join(tmpDir, "audio.mp3");
const outPath = path.join(tmpDir, "output.mp4");
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-'))
const videoPath = path.join(tmpDir, 'video.mp4')
const audioPath = path.join(tmpDir, 'audio.mp3')
const outPath = path.join(tmpDir, 'output.mp4')
try {
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
await downloadToFile(videoUrl, videoPath);
await downloadToFile(audioUrl, audioPath);
await downloadToFile(videoUrl, videoPath)
await downloadToFile(audioUrl, audioPath)
logger.info("[merge] transcodage/mux ffmpeg");
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
logger.info('[merge] transcodage/mux ffmpeg')
await muxAudioIntoVideo({ videoPath, audioPath, outPath })
const bucket = admin.storage().bucket();
const downloadToken = crypto.randomUUID();
const bucket = admin.storage().bucket()
const downloadToken = crypto.randomUUID()
await bucket.upload(outPath, {
destination: storagePath,
metadata: {
contentType: "video/mp4",
cacheControl: "public,max-age=86400",
contentType: 'video/mp4',
cacheControl: 'public,max-age=86400',
metadata: { firebaseStorageDownloadTokens: downloadToken },
},
});
})
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
storagePath
)}?alt=media&token=${downloadToken}`;
)}?alt=media&token=${downloadToken}`
logger.info("[merge] upload terminé", { storagePath });
logger.info('[merge] upload terminé', { storagePath })
return {
success: true,
url: fileUrl,
contentType: "video/mp4",
contentType: 'video/mp4',
storagePath,
};
}
} catch (err) {
logger.error("[merge] échec", { error: err?.message || String(err) });
if (err instanceof HttpsError) throw err;
throw new HttpsError("internal", err?.message || "Fusion échouée");
logger.error('[merge] échec', { error: err?.message || String(err) })
if (err instanceof HttpsError) throw err
throw new HttpsError('internal', err?.message || 'Fusion échouée')
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(tmpDir, { recursive: true, force: true })
}
}
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
if (!projectId) return;
const docRef = db.collection("projects").doc(projectId);
if (!projectId) return
const docRef = db.collection('projects').doc(projectId)
await docRef.set(
{
playbackCompatibility: {
@@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
},
},
{ merge: true }
);
)
}
exports.mergeVideoAndAudio = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
{ timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const uid = auth?.uid
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
const { videoUrl, audioUrl, storagePath, projectId } = data || {}
if (!videoUrl || !audioUrl || !storagePath) {
throw new HttpsError(
"invalid-argument",
"Requis: { videoUrl, audioUrl, storagePath }"
);
throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }')
}
const expectedPrefix = `users/${uid}/`;
const expectedPrefix = `users/${uid}/`
if (!storagePath.startsWith(expectedPrefix)) {
throw new HttpsError(
"permission-denied",
`storagePath doit commencer par ${expectedPrefix}`
);
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
}
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
if (projectId) {
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
}
return result;
return result
}
);
)
exports.reencodePlayback = onCall(
{ timeoutSeconds: 540, memory: "1GiB" },
{ timeoutSeconds: 540, memory: '1GiB' },
async ({ data = {}, auth }) => {
const uid = auth?.uid;
if (!uid)
throw new HttpsError("unauthenticated", "Authentification requise");
const uid = auth?.uid
if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise')
const projectId = data?.projectId;
const projectId = data?.projectId
if (!projectId) {
throw new HttpsError(
"invalid-argument",
"Requis: { projectId } pour relancer le transcodage"
);
throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage')
}
logger.info("[reencodePlayback] request received", {
logger.info('[reencodePlayback] request received', {
projectId,
initiator: uid,
});
const projectRef = db.collection("projects").doc(projectId);
const projectSnap = await projectRef.get();
})
const projectRef = db.collection('projects').doc(projectId)
const projectSnap = await projectRef.get()
if (!projectSnap.exists) {
logger.warn("[reencodePlayback] project not found", {
logger.warn('[reencodePlayback] project not found', {
projectId,
initiator: uid,
});
throw new HttpsError("not-found", "Projet introuvable");
})
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data() || {};
const videoUrl = project.playbackUrl;
const audioUrl = project.songUrl;
const ownerId = project.userId;
const project = projectSnap.data() || {}
const videoUrl = project.playbackUrl
const audioUrl = project.songUrl
const ownerId = project.userId
if (!videoUrl || !audioUrl || !ownerId) {
logger.warn("[reencodePlayback] missing fields", {
logger.warn('[reencodePlayback] missing fields', {
projectId,
hasPlaybackUrl: !!videoUrl,
hasSongUrl: !!audioUrl,
ownerId,
});
throw new HttpsError(
"failed-precondition",
"playbackUrl, songUrl ou userId manquant"
);
})
throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant')
}
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath })
await projectRef.set(
{
@@ -222,13 +208,13 @@ exports.reencodePlayback = onCall(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
logger.info("[reencodePlayback] success", {
)
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
logger.info('[reencodePlayback] success', {
projectId,
initiator: uid,
storagePath,
});
return result;
})
return result
}
);
)
+64 -78
View File
@@ -1,137 +1,123 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const {
onDocumentDeleted,
onDocumentCreated,
} = require("firebase-functions/firestore");
const { refList } = require("../index");
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
const { deleteFolder } = require("../helpers/firebase");
const { Resend } = require("resend");
const { welcomeTemplate } = require("../helpers/email");
const { RESEND_API_KEY } = require("../config/keys");
const { onRequest } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore')
const { refList } = require('../index')
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
const { deleteFolder } = require('../helpers/firebase')
const { Resend } = require('resend')
const { welcomeTemplate } = require('../helpers/email')
const { RESEND_API_KEY } = require('../config/keys')
const { onRequest } = require('firebase-functions/https')
const resendClient = new Resend(RESEND_API_KEY);
const WELCOME_EMAIL_FROM =
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@musicland.ai>";
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
const resendClient = new Resend(RESEND_API_KEY)
const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || 'MusicLand <musicland@musicland.ai>'
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
exports.testWelcomMail = onRequest(async (req, res) => {
if (req.method !== "GET") {
res.set("Allow", "GET");
return res
.status(405)
.json({ success: false, error: "Method not allowed" });
if (req.method !== 'GET') {
res.set('Allow', 'GET')
return res.status(405).json({ success: false, error: 'Method not allowed' })
}
try {
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
const firstName = req.query.firstName || "Toto";
const lastName = req.query.lastName || "Test";
const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
const firstName = req.query.firstName || 'Toto'
const lastName = req.query.lastName || 'Test'
const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM,
to: [targetEmail],
subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }),
});
})
if (error) {
console.log("Failed to send welcome email:", error);
return res
.status(500)
.json({ success: false, error: error.message || error.toString() });
console.log('Failed to send welcome email:', error)
return res.status(500).json({ success: false, error: error.message || error.toString() })
}
console.log("Welcome email sent:", data);
return res.status(200).json({ success: true, data });
console.log('Welcome email sent:', data)
return res.status(200).json({ success: true, data })
} catch (e) {
console.log(e);
return res
.status(500)
.json({ success: false, error: e.message || e.toString() });
console.log(e)
return res.status(500).json({ success: false, error: e.message || e.toString() })
}
});
})
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
exports.onUserCreated = onDocumentCreated('users/{userID}', async (event) => {
try {
const {
email = "",
firstName = "",
lastName = "",
} = event?.data?.data() || {};
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
const userId = event?.params?.userID;
const userId = event?.params?.userID
try {
await createOrderDocument({
userId,
type: ORDER_TYPES.GIFT,
amount: 10,
metadata: { reason: "WELCOME_BONUS" },
metadata: { reason: 'WELCOME_BONUS' },
orderId: `welcome_${userId}`,
});
})
} catch (coinError) {
console.warn(
"[users-onUserCreated] Unable to grant welcome coins",
'[users-onUserCreated] Unable to grant welcome coins',
event?.params?.userID,
coinError?.message || coinError,
);
coinError?.message || coinError
)
}
if (email) {
if (!resendClient) {
console.warn("Resend API key not configured; skipping welcome email.");
return;
console.warn('Resend API key not configured; skipping welcome email.')
return
}
try {
console.log(`Sending welcome email to ${email}`);
console.log(`Sending welcome email to ${email}`)
const { data, error } = await resendClient.emails.send({
from: WELCOME_EMAIL_FROM,
to: [email],
subject: WELCOME_EMAIL_SUBJECT,
html: welcomeTemplate({ firstName, lastName }),
});
})
if (error) {
console.log("Failed to send welcome email:", error);
return;
console.log('Failed to send welcome email:', error)
return
}
console.log("Welcome email sent:", data);
console.log('Welcome email sent:', data)
} catch (error) {
console.log("Failed to send welcome email:", error);
console.log('Failed to send welcome email:', error)
}
} else {
throw new Error("User created with empty email");
throw new Error('User created with empty email')
}
} catch (e) {
console.log(e);
console.log(e)
}
});
})
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => {
try {
const userID = event?.params?.userID;
await clearAllUserData(userID);
const userID = event?.params?.userID
await clearAllUserData(userID)
await deleteFolder(`users/${userID}/`);
await deleteFolder(`users/${userID}/`)
await admin.auth().deleteUser(userID);
console.log(`User ${userID} deleted successfully`);
await admin.auth().deleteUser(userID)
console.log(`User ${userID} deleted successfully`)
} catch (e) {
console.log(e);
console.log(e)
}
});
})
async function clearAllUserData(userID) {
const deleteAll = async (ref, key, operator = "==") => {
const snapshot = await ref.where(key, operator, userID).get();
snapshot.forEach((item) => item.ref.delete());
};
const deleteAll = async (ref, key, operator = '==') => {
const snapshot = await ref.where(key, operator, userID).get()
snapshot.forEach((item) => item.ref.delete())
}
const removeFromArray = async (ref, arrayName) => {
const snapshot = await ref.where(arrayName, "array-contains", userID).get();
const snapshot = await ref.where(arrayName, 'array-contains', userID).get()
snapshot.forEach((item) =>
item.ref.update({
[arrayName]: FieldValue.arrayRemove(userID),
}),
);
};
})
)
}
await deleteAll(refList.projects, "userId");
await deleteAll(refList.playlists, "createdBy");
await deleteAll(refList.projects, 'userId')
await deleteAll(refList.playlists, 'createdBy')
}
+120 -146
View File
@@ -1,34 +1,29 @@
const { onCall, HttpsError } = require("firebase-functions/v2/https");
const { defineSecret } = require("firebase-functions/params");
const logger = require("firebase-functions/logger");
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const axios = require("axios");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { google } = require("googleapis");
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { defineSecret } = require('firebase-functions/params')
const logger = require('firebase-functions/logger')
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const axios = require('axios')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { google } = require('googleapis')
// ---- Secrets déclarés (gen2 + Secret Manager)
const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID");
const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET");
const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN");
const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI");
const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS");
const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID");
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
// Firestore
const firestore = admin.firestore();
const projectsRef = firestore.collection("projects");
const firestore = admin.firestore()
const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = [
"PUBLISHING",
"UPLOADING",
"PROCESSING",
"QUEUED",
];
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
// Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () =>
@@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () =>
redirect_uri: S_YT_REDIRECT_URI.value(),
privacy_status: S_YT_PRIVACY_STATUS.value(),
category_id: S_YT_CATEGORY_ID.value(),
}).filter(([, value]) => value !== undefined && value !== "")
);
}).filter(([, value]) => value !== undefined && value !== '')
)
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
const getLegacyYoutubeConfig = () => {
if (typeof functions.config !== "function") {
return {};
if (typeof functions.config !== 'function') {
return {}
}
try {
return functions.config()?.youtube || {};
return functions.config()?.youtube || {}
} catch (error) {
if (
typeof error?.message === "string" &&
error.message.includes("functions.config() is no longer available")
typeof error?.message === 'string' &&
error.message.includes('functions.config() is no longer available')
) {
logger.warn(
"[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)"
);
return {};
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
)
return {}
}
throw error;
throw error
}
};
}
const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig();
const secretConfig = getSecretsYoutubeConfig();
const cfg = { ...firebaseConfig, ...secretConfig };
const firebaseConfig = getLegacyYoutubeConfig()
const secretConfig = getSecretsYoutubeConfig()
const cfg = { ...firebaseConfig, ...secretConfig }
const requiredKeys = ["client_id", "client_secret", "refresh_token"];
const missing = requiredKeys.filter((key) => !cfg[key]);
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
const missing = requiredKeys.filter((key) => !cfg[key])
if (missing.length) {
throw new HttpsError(
"failed-precondition",
`Configuration YouTube manquante: ${missing.join(", ")}`
);
'failed-precondition',
`Configuration YouTube manquante: ${missing.join(', ')}`
)
}
return {
@@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => {
redirectUri: cfg.redirect_uri,
defaultPrivacyStatus: cfg.privacy_status,
defaultCategoryId: cfg.category_id,
};
};
}
}
const createYoutubeClient = ({
clientId,
clientSecret,
refreshToken,
redirectUri,
}) => {
const oauth2Client = new google.auth.OAuth2(
clientId,
clientSecret,
redirectUri
);
oauth2Client.setCredentials({ refresh_token: refreshToken });
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
oauth2Client.setCredentials({ refresh_token: refreshToken })
const youtube = google.youtube({
version: "v3",
version: 'v3',
auth: oauth2Client,
});
return { youtube, oauth2Client };
};
})
return { youtube, oauth2Client }
}
const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || "")) {
throw new HttpsError("invalid-argument", `URL non valide: ${url}`);
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
}
await fsp.mkdir(path.dirname(destinationPath), { recursive: true });
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
const response = await axios.get(url, { responseType: "stream" });
const response = await axios.get(url, { responseType: 'stream' })
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath);
response.data.pipe(writer);
writer.on("finish", resolve);
writer.on("error", reject);
});
const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
return destinationPath;
};
return destinationPath
}
const buildVideoMetadata = (project, defaults) => {
const baseTitle = project?.title || "Création MusicLand";
const youtubeTitle = `${baseTitle} | MusicLand`;
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`;
const baseTitle = project?.title || 'Création MusicLand'
const youtubeTitle = `${baseTitle} | MusicLand`
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
const tags = Array.isArray(project?.youtubeTags)
? project.youtubeTags.filter(Boolean).slice(0, 500)
: undefined;
: undefined
const snippet = {
title: youtubeTitle,
description,
categoryId: defaults.defaultCategoryId,
};
}
if (tags && tags.length) {
snippet.tags = tags;
snippet.tags = tags
}
return {
@@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => {
embeddable: true,
selfDeclaredMadeForKids: false,
},
};
};
}
}
exports.publishPlaybackToYoutube = onCall(
{
timeoutSeconds: 540,
memory: "1GiB",
memory: '1GiB',
cors: [
"http://localhost:8081",
"https://musicland-one.vercel.app/",
"https://musicland-d33f9.firebaseapp.com",
'http://localhost:8081',
'https://musicland-one.vercel.app/',
'https://musicland-d33f9.firebaseapp.com',
],
// Secrets requis pour lexécution (v2)
secrets: [
@@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall(
],
},
async ({ data = {}, auth }) => {
const uid = auth?.uid;
const uid = auth?.uid
if (!uid) {
throw new HttpsError("unauthenticated", "Authentification requise");
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = data?.projectId;
if (!projectId || typeof projectId !== "string") {
throw new HttpsError("invalid-argument", "Paramètre projectId requis");
const projectId = data?.projectId
if (!projectId || typeof projectId !== 'string') {
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
}
const projectSnap = await projectsRef.doc(projectId).get();
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) {
throw new HttpsError("not-found", "Projet introuvable");
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data();
const project = projectSnap.data()
if (!project || project.userId !== uid) {
throw new HttpsError(
"permission-denied",
"Vous n'avez pas les droits sur ce projet"
);
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
}
if (!project.playbackUrl) {
throw new HttpsError(
"failed-precondition",
"Aucun playback disponible pour la publication"
);
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
}
if (
project.youtubeStatus &&
YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)
) {
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
throw new HttpsError(
"failed-precondition",
"Une publication est déjà en cours pour ce projet"
);
'failed-precondition',
'Une publication est déjà en cours pour ce projet'
)
}
let youtubeDefaults;
let youtubeClient;
let youtubeDefaults
let youtubeClient
try {
youtubeDefaults = ensureYoutubeConfig();
youtubeClient = createYoutubeClient(youtubeDefaults);
await youtubeClient.oauth2Client.getAccessToken();
youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken()
} catch (error) {
logger.error("[publishPlaybackToYoutube] configuration invalide", {
logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message,
});
throw new HttpsError(
"failed-precondition",
"Configuration YouTube invalide ou incomplète"
);
})
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-"));
const videoPath = path.join(tmpDir, "playback.mp4");
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
const videoPath = path.join(tmpDir, 'playback.mp4')
try {
await projectsRef.doc(projectId).set(
{
youtubeStatus: "PUBLISHING",
youtubeStatus: 'PUBLISHING',
youtubePublished: false,
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
await downloadFile(project.playbackUrl, videoPath);
await downloadFile(project.playbackUrl, videoPath)
const metadata = buildVideoMetadata(project, youtubeDefaults);
const metadata = buildVideoMetadata(project, youtubeDefaults)
const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ["snippet", "status"].join(","),
part: ['snippet', 'status'].join(','),
requestBody: metadata,
media: {
body: fs.createReadStream(videoPath),
},
});
})
const videoId = uploadResponse?.data?.id;
const videoId = uploadResponse?.data?.id
if (!videoId) {
throw new Error("ID de vidéo introuvable dans la réponse YouTube");
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
}
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`;
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
await projectsRef.doc(projectId).set(
{
youtubeStatus: "PUBLISHED",
youtubeStatus: 'PUBLISHED',
youtubePublished: true,
youtubeUrl: youtubeLink,
youtubeVideoId: videoId,
@@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall(
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
logger.info("[publishPlaybackToYoutube] publication réussie", {
logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId,
videoId,
});
})
return {
videoId,
youtubeUrl: youtubeLink,
};
}
} catch (error) {
logger.error("[publishPlaybackToYoutube] échec de publication", {
logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId,
error: error?.message,
});
})
const errorMessage =
error instanceof HttpsError
? error.message
: error?.message || "Publication YouTube échouée";
: error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set(
{
youtubeStatus: "FAILED",
youtubeStatus: 'FAILED',
youtubePublished: false,
youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
);
)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw new HttpsError("internal", errorMessage);
throw new HttpsError('internal', errorMessage)
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true });
await fsp.rm(tmpDir, { recursive: true, force: true })
}
}
);
)