feat: fixes and formatter
This commit is contained in:
+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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user