feat: fixes and formatter
This commit is contained in:
@@ -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 = ''
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
exports.BATCH_TYPE = {
|
||||
ADD: "ADD",
|
||||
UPDATE: "UPDATE",
|
||||
DELETE: "DELETE",
|
||||
};
|
||||
ADD: 'ADD',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
}
|
||||
|
||||
+16
-17
@@ -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
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
|
||||
);
|
||||
'failed-precondition',
|
||||
'Stripe n’est 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
@@ -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
@@ -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
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+239
-284
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 n’a pas renvoyé d’identifiant de compte Connect.",
|
||||
);
|
||||
throw new HttpsError('internal', 'Stripe n’a pas renvoyé d’identifiant 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 n’a 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 n’a 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
module.exports = {
|
||||
REGION,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) l’ancienne 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 l’exé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 })
|
||||
}
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user