functions
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
exports.BATCH_TYPE = {
|
||||||
|
ADD: "ADD",
|
||||||
|
UPDATE: "UPDATE",
|
||||||
|
DELETE: "DELETE",
|
||||||
|
};
|
||||||
+85
-37
@@ -13,49 +13,97 @@ function basicTemplate({ title = "", content = "", button = null }) {
|
|||||||
button?.label || "Ouvrir"
|
button?.label || "Ouvrir"
|
||||||
}</a></p>`
|
}</a></p>`
|
||||||
: "";
|
: "";
|
||||||
return `<!doctype html><html><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#111">
|
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||||
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
|
<meta charset="utf-8" />
|
||||||
<div style="text-align:center;margin-bottom:20px">
|
<meta name="color-scheme" content="dark" />
|
||||||
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:40px" />
|
<meta name="supported-color-schemes" content="dark" />
|
||||||
</div>
|
<style>
|
||||||
<h2 style="margin:0 0 16px">${title}</h2>
|
:root { color-scheme: dark; supported-color-schemes: dark; }
|
||||||
<div>${content}</div>
|
body { margin:0 !important; background-color:#0b0b10 !important; }
|
||||||
${btn}
|
body, .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
|
||||||
<hr style="margin:24px 0;border:none;border-top:1px solid #eee" />
|
u + .body .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
|
||||||
<p style="color:#666;font-size:12px">minuit.app</p>
|
u + .body .email-content,
|
||||||
</div>
|
[data-ogsc] .email-content,
|
||||||
|
[data-ogsb] .email-content { background-color:#151520 !important; color:#ffffff !important; }
|
||||||
|
u + .body .email-content *,
|
||||||
|
[data-ogsc] .email-content *,
|
||||||
|
[data-ogsb] .email-content * { color:#ffffff !important; }
|
||||||
|
</style>
|
||||||
|
</head><body class="body" data-ogsc="dark" data-ogsb="dark" bgcolor="#0b0b10" style="margin:0;padding:0;background-color:#0b0b10!important;color:#ffffff!important;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="email-surface" style="border-collapse:collapse;background-color:#0b0b10">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 16px">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="#151520" class="email-content" style="max-width:640px;border-radius:12px;background-color:#151520;border:1px solid rgba(255,255,255,0.08);box-shadow:0 12px 40px rgba(0,0,0,0.35);overflow:hidden">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#151520" style="padding:24px;background-color:#151520;color:#ffffff">
|
||||||
|
<div style="text-align:center;margin-bottom:20px;color:#ffffff">
|
||||||
|
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:40px" />
|
||||||
|
</div>
|
||||||
|
<h2 style="margin:0 0 16px;font-size:24px;color:#ffffff!important">${title}</h2>
|
||||||
|
<div style="color:rgba(255,255,255,0.9)!important">${content}</div>
|
||||||
|
${btn}
|
||||||
|
<hr style="margin:24px 0;border:none;border-top:1px solid rgba(255,255,255,0.1)" />
|
||||||
|
<p style="color:rgba(255,255,255,0.45);font-size:12px;margin:0">minuit.app</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function welcomeTemplate({ firstName = "", lastName = "" }) {
|
function welcomeTemplate({ firstName = "", lastName = "" }) {
|
||||||
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||||
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
|
const greeting = fullName ? `Salut ${fullName},` : "Salut,";
|
||||||
return `<!doctype html><html><body style="margin:0;padding:0;background:#0b0b10;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||||
<div style="max-width:640px;margin:0 auto;padding:40px 24px">
|
<meta charset="utf-8" />
|
||||||
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
|
<meta name="color-scheme" content="dark" />
|
||||||
<div style="padding:32px 28px">
|
<meta name="supported-color-schemes" content="dark" />
|
||||||
<div style="text-align:center;margin-bottom:24px">
|
<style>
|
||||||
<img
|
:root { color-scheme: dark; supported-color-schemes: dark; }
|
||||||
src="${musicLandLogoSrc}"
|
body { margin:0 !important; background-color:#0b0b10 !important; }
|
||||||
alt="MusicLand"
|
body, .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
|
||||||
style="display:block;margin:0 auto;width:180px;max-width:100%;height:auto"
|
u + .body .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
|
||||||
/>
|
u + .body .email-content,
|
||||||
</div>
|
[data-ogsc] .email-content,
|
||||||
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p>
|
[data-ogsb] .email-content { background-color:#151520 !important; color:#ffffff !important; }
|
||||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
|
u + .body .email-content *,
|
||||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
|
[data-ogsc] .email-content *,
|
||||||
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
|
[data-ogsb] .email-content * { color:#ffffff !important; }
|
||||||
</p>
|
</style>
|
||||||
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.75)">
|
</head><body class="body" data-ogsc="dark" data-ogsb="dark" bgcolor="#0b0b10" style="margin:0;padding:0;background-color:#0b0b10!important;color:#ffffff!important;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5">
|
||||||
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="email-surface" style="border-collapse:collapse;background-color:#0b0b10">
|
||||||
</p>
|
<tr>
|
||||||
<p style="margin:0;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.75)">
|
<td align="center" style="padding:40px 16px">
|
||||||
L'équipe MusicLand
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="#151520" class="email-content" style="max-width:640px;border-radius:16px;overflow:hidden;background-color:#151520;border:1px solid rgba(255,255,255,0.08);box-shadow:0 12px 40px rgba(0,0,0,0.35)">
|
||||||
</p>
|
<tr>
|
||||||
</div>
|
<td bgcolor="#151520" style="padding:32px 28px;background-color:#151520;color:#ffffff">
|
||||||
</div>
|
<div style="text-align:center;margin-bottom:24px;color:#ffffff">
|
||||||
<p style="margin:32px 0 0;text-align:center;font-size:12px;color:rgba(255,255,255,0.45)">minuit.app · Paris, France</p>
|
<img
|
||||||
</div>
|
src="${musicLandLogoSrc}"
|
||||||
|
alt="MusicLand"
|
||||||
|
style="display:block;margin:0 auto;width:180px;max-width:100%;height:auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px;color:#ffffff!important">${greeting}</p>
|
||||||
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2;color:#ffffff!important">Bienvenue sur MusicLand</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||||
|
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#ffffff!important">
|
||||||
|
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:15px;line-height:1.6;color:#ffffff!important">
|
||||||
|
L'équipe MusicLand
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:32px 0 0;text-align:center;font-size:12px;color:rgba(255,255,255,0.6)">minuit.app · Paris, France</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
|
const { BATCH_TYPE } = require("../config/types");
|
||||||
|
|
||||||
async function deleteFolder(path) {
|
async function deleteFolder(path) {
|
||||||
try {
|
try {
|
||||||
@@ -11,4 +12,77 @@ async function deleteFolder(path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function batchFirestore({
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer des lots pour les opérations
|
||||||
|
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);
|
||||||
|
const payload =
|
||||||
|
doc && typeof doc.data === "object" && doc.data !== null && !Array.isArray(doc.data)
|
||||||
|
? doc.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);
|
||||||
|
} else if (type === BATCH_TYPE.UPDATE) {
|
||||||
|
if (!ref) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
currentBatch.delete(ref);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Opération non supportée : ${type}`);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ajouter le dernier lot si des opérations y sont présentes
|
||||||
|
if (operationCounter > 0) {
|
||||||
|
batches.push(currentBatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter tous les lots en parallèle
|
||||||
|
await Promise.all(batches.map((batch) => batch.commit()));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Erreur lors des opérations ${type} pour la collection ${path} :`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.batchFirestore = batchFirestore;
|
||||||
exports.deleteFolder = deleteFolder;
|
exports.deleteFolder = deleteFolder;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const normalizeDate = (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 buildPreviousMonthContext = (referenceDate) => {
|
||||||
|
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 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,
|
||||||
|
month: monthIndex + 1,
|
||||||
|
monthKey,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildMonthKey,
|
||||||
|
buildPreviousMonthContext,
|
||||||
|
};
|
||||||
+20
-5
@@ -4,18 +4,33 @@ const admin = require("firebase-admin");
|
|||||||
// Avoid bundling a service account key and hardcoding project/bucket.
|
// Avoid bundling a service account key and hardcoding project/bucket.
|
||||||
admin.initializeApp();
|
admin.initializeApp();
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
|
||||||
|
exports.db = db;
|
||||||
|
|
||||||
exports.refList = {
|
exports.refList = {
|
||||||
projects: admin.firestore().collection("projects"),
|
projects: db.collection("projects"),
|
||||||
playlists: admin.firestore().collection("playlists"),
|
playlists: db.collection("playlists"),
|
||||||
tasks: admin.firestore().collection("tasks"),
|
tasks: db.collection("tasks"),
|
||||||
notifications: admin.firestore().collection("notifications"),
|
notifications: db.collection("notifications"),
|
||||||
users: admin.firestore().collection("users"),
|
users: db.collection("users"),
|
||||||
|
projectStreamStats: db.collection("projectStreamStats"),
|
||||||
|
projectStreamStatsMonthlyTotals: db.collection(
|
||||||
|
"projectStreamStatsMonthlyTotals",
|
||||||
|
),
|
||||||
|
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
|
||||||
|
monthlyPayouts: db.collection("monthlyPayouts"),
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.ALERT_TYPE = {
|
exports.ALERT_TYPE = {
|
||||||
NEW_LIKE: "NEW_LIKE",
|
NEW_LIKE: "NEW_LIKE",
|
||||||
NEW_COMMENT: "NEW_COMMENT",
|
NEW_COMMENT: "NEW_COMMENT",
|
||||||
NEW_FOLLOWER: "NEW_FOLLOWER",
|
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",
|
||||||
|
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exporter toutes les fonctions
|
// Exporter toutes les fonctions
|
||||||
|
|||||||
Generated
+51
-42
@@ -12,10 +12,11 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"firebase-admin": "^12.7.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^6.4.0",
|
"firebase-functions": "^6.0.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"genkit": "^1.18.0",
|
"genkit": "^1.18.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"resend": "^6.3.0",
|
"resend": "^6.3.0",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4"
|
||||||
},
|
},
|
||||||
@@ -65,9 +66,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz",
|
||||||
"integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
|
"integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -355,12 +356,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/ai": {
|
"node_modules/@genkit-ai/ai": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@genkit-ai/ai/-/ai-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@genkit-ai/ai/-/ai-1.22.0.tgz",
|
||||||
"integrity": "sha512-DLAes+w3Yv9a4zgJUL5CS8eVrNvud/LRv+F4E/O3KkpWz0uoKYREIrTZYSrDt/pF/jlqkRDQaalf7sGKS44IuQ==",
|
"integrity": "sha512-TDKO+zWyM5YI8zE4a0IlqlpgHuLB4B4islzgWDvzdQlbjtyJp0ayODAMFhS2ruQ6+a/UdXDySRrOX/RcqF4yjA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/core": "1.21.0",
|
"@genkit-ai/core": "1.22.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.11.19",
|
"@types/node": "^20.11.19",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@@ -373,9 +374,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/ai/node_modules/@types/node": {
|
"node_modules/@genkit-ai/ai/node_modules/@types/node": {
|
||||||
"version": "20.19.23",
|
"version": "20.19.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -395,9 +396,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/core": {
|
"node_modules/@genkit-ai/core": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@genkit-ai/core/-/core-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@genkit-ai/core/-/core-1.22.0.tgz",
|
||||||
"integrity": "sha512-suQ81HwtDObejkXJUBWzD5145qoHFXxdozxwzYDy8m93f7kKKtQwSJPQmYXWsTAdHJSjERHfKLCe/xrDshkPYw==",
|
"integrity": "sha512-etVlpwJkPoy91xR6H5+S/AWZPJMeovb7N35+B90md1+6xWcodQF7WZ3chKcH31Xamlz+jTIvd3riiZGY9RFumg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
@@ -447,19 +448,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/firebase": {
|
"node_modules/@genkit-ai/firebase": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@genkit-ai/firebase/-/firebase-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@genkit-ai/firebase/-/firebase-1.22.0.tgz",
|
||||||
"integrity": "sha512-i21IStUGFn10+25+o7HL2XPtXvb2FIxqtfVHLrpSFqsT8sMdRSNceVg5mU5MHY2yjhbgpfntbs1YDO2sf4QRew==",
|
"integrity": "sha512-iTOVma8be8cOFzO9yaNuzBIICgFAP+N1cuB5T8K5LE5ahsSIG7sZWUE5FwMKtEDFk9D343kgLuvW9nxLkV7IzA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/google-cloud": "^1.21.0"
|
"@genkit-ai/google-cloud": "^1.22.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@google-cloud/firestore": "^7.11.0",
|
"@google-cloud/firestore": "^7.11.0",
|
||||||
"firebase": ">=11.5.0",
|
"firebase": ">=11.5.0",
|
||||||
"firebase-admin": ">=12.2",
|
"firebase-admin": ">=12.2",
|
||||||
"genkit": "^1.21.0"
|
"genkit": "^1.22.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"firebase": {
|
"firebase": {
|
||||||
@@ -468,9 +469,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/google-cloud": {
|
"node_modules/@genkit-ai/google-cloud": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@genkit-ai/google-cloud/-/google-cloud-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@genkit-ai/google-cloud/-/google-cloud-1.22.0.tgz",
|
||||||
"integrity": "sha512-DsNecaDnvH7NNv4NGxIFshjMzWzXXB4fD/CRM6aLQv0TGOTip/3J3+5iKNqeSpmqDv8jIJtrOlclwhKb6cR1Wg==",
|
"integrity": "sha512-PVt7oLu3VARQ4u/R4VvG/q2lsEdTke9jxQI8sg1jxHoKV03fNUwKAyNd59ZT4igrQIUCqdJD4tH3Kzh4yd4QTw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -493,13 +494,13 @@
|
|||||||
"winston": "^3.12.0"
|
"winston": "^3.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"genkit": "^1.21.0"
|
"genkit": "^1.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@genkit-ai/googleai": {
|
"node_modules/@genkit-ai/googleai": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@genkit-ai/googleai/-/googleai-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@genkit-ai/googleai/-/googleai-1.22.0.tgz",
|
||||||
"integrity": "sha512-sJauWpr0vu0cnA05UwQAfd2Vh04+Lar1YC3Nn/YBbwBow3Y7KWAqQUNaGHSka/ZYiUHxlzjYyhaDtodmnObepw==",
|
"integrity": "sha512-6GTfrsv2acRWlXcvi8jqH044tG/eslMQCH1wxpA//wzTnsnht5f4SJ3isKU3gcmyQtoYWiaUN8/F7OYqkIYHcw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.0",
|
"@google/generative-ai": "^0.24.0",
|
||||||
@@ -507,7 +508,7 @@
|
|||||||
"node-fetch": "^3.3.2"
|
"node-fetch": "^3.3.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"genkit": "^1.21.0"
|
"genkit": "^1.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@google-cloud/common": {
|
"node_modules/@google-cloud/common": {
|
||||||
@@ -2630,6 +2631,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz",
|
||||||
"integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==",
|
"integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "1.25.1",
|
"@opentelemetry/core": "1.25.1",
|
||||||
"@opentelemetry/resources": "1.25.1",
|
"@opentelemetry/resources": "1.25.1",
|
||||||
@@ -2974,9 +2976,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.18.12",
|
"version": "22.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
|
||||||
"integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==",
|
"integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -3320,9 +3322,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.0",
|
"version": "1.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz",
|
||||||
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
|
"integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
@@ -4749,14 +4751,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/genkit": {
|
"node_modules/genkit": {
|
||||||
"version": "1.21.0",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.22.0.tgz",
|
||||||
"integrity": "sha512-catTEjxhHZaicvxwak8jFL0K0H0ndN/qE9s+N7CIbsQeJczvRDleoyFa2mtaBOCaoZt7Xj8zJlYXOmt8tRyqJQ==",
|
"integrity": "sha512-GoVVO3EnNHrjkMkUPRvgx1MjBHKvOlZAu/ffMIJgLFxrH7rrUbvfHXE6Nk7uh5BNvET7+DApyhbhqz9G8sy+mQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/ai": "1.21.0",
|
"@genkit-ai/ai": "1.22.0",
|
||||||
"@genkit-ai/core": "1.21.0",
|
"@genkit-ai/core": "1.22.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -5610,6 +5612,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.camelcase": {
|
"node_modules/lodash.camelcase": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
@@ -6436,9 +6444,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/resend": {
|
"node_modules/resend": {
|
||||||
"version": "6.3.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.4.0.tgz",
|
||||||
"integrity": "sha512-Nmg5cFojC2E07gQ/RnlcroXpclFnlr05yQP2QNbbAhfX1Q4LBqiSd6/0t/fq95MvcuP8EDbb+Eyw1NG2P0OWgw==",
|
"integrity": "sha512-CTr4ix4RI5M/ucL58Wqr+LE8eI4JHtJEFaBAx6yUVNOI3eaPVtJjpNL0G/BdRSWMbwv6CtpprVOY8Xvpp6UJlA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"svix": "1.76.1"
|
"svix": "1.76.1"
|
||||||
@@ -7332,6 +7340,7 @@
|
|||||||
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
|
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
|
|||||||
@@ -20,10 +20,11 @@
|
|||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"expo-server-sdk": "^4.0.0",
|
"expo-server-sdk": "^4.0.0",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"firebase-admin": "^12.7.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^6.4.0",
|
"firebase-functions": "^6.0.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"genkit": "^1.18.0",
|
"genkit": "^1.18.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"resend": "^6.3.0",
|
"resend": "^6.3.0",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4"
|
||||||
},
|
},
|
||||||
|
|||||||
+86
-28
@@ -1,21 +1,17 @@
|
|||||||
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
|
||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require("firebase-functions/logger");
|
||||||
const {
|
const { generateImageV2 } = require("../helpers/gemini");
|
||||||
generateImageV2,
|
|
||||||
} = require("../helpers/gemini");
|
|
||||||
const { generatePicturePrompt } = require("../helpers/prompts");
|
const { generatePicturePrompt } = require("../helpers/prompts");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const sharp = require("sharp");
|
const sharp = require("sharp");
|
||||||
|
const { ALERT_TYPE, refList } = require("../index");
|
||||||
|
const { sendNotification } = require("./notifications");
|
||||||
|
|
||||||
const db = admin.firestore();
|
|
||||||
const bucket = admin.storage().bucket();
|
const bucket = admin.storage().bucket();
|
||||||
const LOGO_PATH = path.resolve(
|
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandProduction.png");
|
||||||
__dirname,
|
|
||||||
"../assets/musicLandProduction.png",
|
|
||||||
);
|
|
||||||
|
|
||||||
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
async function buildCoverWithLogo(backgroundUrl, targetPath) {
|
||||||
logger.info("🖼️ [Cover] Adding logo to generated background");
|
logger.info("🖼️ [Cover] Adding logo to generated background");
|
||||||
@@ -124,22 +120,19 @@ async function performCoverGeneration(project) {
|
|||||||
|
|
||||||
const [firstOption] = options;
|
const [firstOption] = options;
|
||||||
|
|
||||||
await db
|
await refList.projects.doc(project.id).set(
|
||||||
.collection("projects")
|
{
|
||||||
.doc(project.id)
|
cover: {
|
||||||
.set(
|
generatedBackground: firstOption?.generatedUrl || null,
|
||||||
{
|
result: firstOption?.finalUrl || null,
|
||||||
cover: {
|
selectedOptionId: firstOption?.id || null,
|
||||||
generatedBackground: firstOption?.generatedUrl || null,
|
options,
|
||||||
result: firstOption?.finalUrl || null,
|
|
||||||
selectedOptionId: firstOption?.id || null,
|
|
||||||
options,
|
|
||||||
},
|
|
||||||
coverStatus: "GENERATED",
|
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
||||||
},
|
},
|
||||||
{ merge: true },
|
coverStatus: "GENERATED",
|
||||||
);
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true },
|
||||||
|
);
|
||||||
|
|
||||||
logger.info("✅ [Cover] Saved", {
|
logger.info("✅ [Cover] Saved", {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
@@ -162,6 +155,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
const type = data?.type || "";
|
const type = data?.type || "";
|
||||||
const projectId = data?.projectId || null;
|
const projectId = data?.projectId || null;
|
||||||
let coverUrl = null;
|
let coverUrl = null;
|
||||||
|
let project = null;
|
||||||
try {
|
try {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
throw new Error("projectId manquant dans la task");
|
throw new Error("projectId manquant dans la task");
|
||||||
@@ -189,13 +183,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.collection("projects").doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "GENERATING",
|
coverStatus: "GENERATING",
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const project =
|
project = (await refList.projects.doc(projectId).get())?.data() || null;
|
||||||
(await db.collection("projects").doc(projectId).get())?.data() || null;
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Projet non trouvé");
|
throw new Error("Projet non trouvé");
|
||||||
}
|
}
|
||||||
@@ -217,7 +210,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
projectId,
|
projectId,
|
||||||
existingOptionsCount,
|
existingOptionsCount,
|
||||||
});
|
});
|
||||||
await db.collection("projects").doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "GENERATED",
|
coverStatus: "GENERATED",
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
@@ -244,6 +237,39 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
taskId: event?.params?.taskId,
|
taskId: event?.params?.taskId,
|
||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
project?.userId &&
|
||||||
|
typeof project.userId === "string" &&
|
||||||
|
project.userId.trim()
|
||||||
|
) {
|
||||||
|
const receiverId = project.userId.trim();
|
||||||
|
const projectTitle =
|
||||||
|
typeof project?.title === "string" && project.title.trim()
|
||||||
|
? project.title.trim()
|
||||||
|
: "ton projet";
|
||||||
|
const message = `Ta nouvelle pochette pour "${projectTitle}" est prête.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: receiverId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Pochette générée",
|
||||||
|
message,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
|
||||||
|
projectId,
|
||||||
|
projectTitle,
|
||||||
|
coverUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifError) {
|
||||||
|
logger.error("❌ [Cover] Failed to send success notification", {
|
||||||
|
projectId,
|
||||||
|
error: notifError?.message || String(notifError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ [Task] Cover generation error", {
|
logger.error("❌ [Task] Cover generation error", {
|
||||||
@@ -256,9 +282,41 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
{ status: "ERROR", error: error?.message || "Erreur" },
|
{ status: "ERROR", error: error?.message || "Erreur" },
|
||||||
{ merge: true },
|
{ merge: true },
|
||||||
);
|
);
|
||||||
await db.collection("projects").doc(projectId).update({
|
await refList.projects.doc(projectId).update({
|
||||||
coverStatus: "ERROR",
|
coverStatus: "ERROR",
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
project?.userId &&
|
||||||
|
typeof project.userId === "string" &&
|
||||||
|
project.userId.trim()
|
||||||
|
) {
|
||||||
|
const receiverId = project.userId.trim();
|
||||||
|
const projectTitle =
|
||||||
|
typeof project?.title === "string" && project.title.trim()
|
||||||
|
? project.title.trim()
|
||||||
|
: "ton projet";
|
||||||
|
const message = `La génération de la pochette pour "${projectTitle}" a échoué.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: receiverId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Pochette indisponible",
|
||||||
|
message,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.COVER_GENERATION_FAILED,
|
||||||
|
projectId,
|
||||||
|
projectTitle,
|
||||||
|
error: error?.message || String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifError) {
|
||||||
|
logger.error("❌ [Cover] Failed to send error notification", {
|
||||||
|
projectId,
|
||||||
|
error: notifError?.message || String(notifError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+95
-22
@@ -1,7 +1,13 @@
|
|||||||
const { onCall, onRequest, HttpsError } = require("firebase-functions/v2/https");
|
const {
|
||||||
|
onCall,
|
||||||
|
onRequest,
|
||||||
|
HttpsError,
|
||||||
|
} = require("firebase-functions/v2/https");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { logger } = require("firebase-functions/logger");
|
const { logger } = require("firebase-functions/logger");
|
||||||
|
const { ALERT_TYPE, refList } = require("../index");
|
||||||
|
const { sendNotification } = require("./notifications");
|
||||||
const { SUNO_API_KEY } = require("../config/keys");
|
const { SUNO_API_KEY } = require("../config/keys");
|
||||||
const {
|
const {
|
||||||
SUNO_MODEL,
|
SUNO_MODEL,
|
||||||
@@ -11,9 +17,6 @@ const {
|
|||||||
SUNO_STATUS_PATH,
|
SUNO_STATUS_PATH,
|
||||||
} = require("../config/suno");
|
} = require("../config/suno");
|
||||||
|
|
||||||
// Initialiser Firestorey
|
|
||||||
const db = admin.firestore();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marque un projet comme échoué suite à une erreur Suno
|
* Marque un projet comme échoué suite à une erreur Suno
|
||||||
* @param {string} projectId - Identifiant du projet
|
* @param {string} projectId - Identifiant du projet
|
||||||
@@ -22,7 +25,9 @@ const db = admin.firestore();
|
|||||||
async function markProjectMusicFailure(projectId, error) {
|
async function markProjectMusicFailure(projectId, error) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
const docRef = db.collection("projects").doc(projectId);
|
const docRef = refList.projects.doc(projectId);
|
||||||
|
const projectSnap = await docRef.get();
|
||||||
|
const projectData = projectSnap?.data() || {};
|
||||||
const status = error?.response?.status || error?.status || null;
|
const status = error?.response?.status || error?.status || null;
|
||||||
const sunoMessage =
|
const sunoMessage =
|
||||||
error?.response?.data?.msg ||
|
error?.response?.data?.msg ||
|
||||||
@@ -44,12 +49,44 @@ async function markProjectMusicFailure(projectId, error) {
|
|||||||
musicError: errorPayload,
|
musicError: errorPayload,
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const receiverId =
|
||||||
|
typeof projectData?.userId === "string" && projectData.userId.trim()
|
||||||
|
? projectData.userId.trim()
|
||||||
|
: null;
|
||||||
|
if (receiverId) {
|
||||||
|
const projectTitle =
|
||||||
|
typeof projectData?.title === "string" && projectData.title.trim()
|
||||||
|
? projectData.title.trim()
|
||||||
|
: "ton projet";
|
||||||
|
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: receiverId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Génération de musique échouée",
|
||||||
|
message,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.MUSIC_GENERATION_FAILED,
|
||||||
|
projectId,
|
||||||
|
projectTitle,
|
||||||
|
error: errorPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifyError) {
|
||||||
|
console.error(
|
||||||
|
"[markProjectMusicFailure] Failed to send notification:",
|
||||||
|
notifyError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ [generateMusic] Impossible de marquer le projet en erreur:",
|
"❌ [generateMusic] Impossible de marquer le projet en erreur:",
|
||||||
err
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +117,7 @@ function extractVoiceStrings(voice) {
|
|||||||
}
|
}
|
||||||
if (typeof voice === "object") {
|
if (typeof voice === "object") {
|
||||||
return Object.values(voice).filter(
|
return Object.values(voice).filter(
|
||||||
(v) => typeof v === "string" && v.trim()
|
(v) => typeof v === "string" && v.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
@@ -134,7 +171,7 @@ function detectVocalGender(voiceInput = "") {
|
|||||||
if (Array.isArray(voiceInput)) {
|
if (Array.isArray(voiceInput)) {
|
||||||
const baseItem = voiceInput.find(
|
const baseItem = voiceInput.find(
|
||||||
(v) =>
|
(v) =>
|
||||||
v && (v.category === "base" || v?.category?.toLowerCase() === "base")
|
v && (v.category === "base" || v?.category?.toLowerCase() === "base"),
|
||||||
);
|
);
|
||||||
const text = baseItem ? baseItem.value || baseItem.text || baseItem : null;
|
const text = baseItem ? baseItem.value || baseItem.text || baseItem : null;
|
||||||
if (text) return detectVocalGender(text);
|
if (text) return detectVocalGender(text);
|
||||||
@@ -346,7 +383,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = response.data;
|
const parsed = response.data;
|
||||||
@@ -370,7 +407,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
|
|||||||
} catch (markErr) {
|
} catch (markErr) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ Erreur lors de la mise à jour du statut de projet:",
|
"❌ Erreur lors de la mise à jour du statut de projet:",
|
||||||
markErr
|
markErr,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +449,7 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
|
|||||||
Authorization: `Bearer ${SUNO_API_KEY}`,
|
Authorization: `Bearer ${SUNO_API_KEY}`,
|
||||||
},
|
},
|
||||||
timeout: 30000, // 30 secondes de timeout
|
timeout: 30000, // 30 secondes de timeout
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
parsed = response.data;
|
parsed = response.data;
|
||||||
|
|
||||||
@@ -547,16 +584,24 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
|||||||
|
|
||||||
// 1) Récupérer le projectId associé au taskId
|
// 1) Récupérer le projectId associé au taskId
|
||||||
let projectId = null;
|
let projectId = null;
|
||||||
const projSnap = await db
|
const projSnap = await refList.projects
|
||||||
.collection("projects")
|
|
||||||
.where("sunoTaskId", "==", taskId)
|
.where("sunoTaskId", "==", taskId)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get();
|
.get();
|
||||||
if (projSnap.empty) {
|
if (projSnap.empty) {
|
||||||
throw new Error("Aucun projet trouvé pour ce taskId");
|
throw new Error("Aucun projet trouvé pour ce taskId");
|
||||||
}
|
}
|
||||||
projectId = projSnap.docs[0].id;
|
const projectDoc = projSnap.docs[0];
|
||||||
const { userId } = projSnap.docs[0].data() || {};
|
const projectData = projectDoc?.data() || {};
|
||||||
|
projectId = projectDoc.id;
|
||||||
|
const userId =
|
||||||
|
typeof projectData.userId === "string" && projectData.userId.trim()
|
||||||
|
? projectData.userId.trim()
|
||||||
|
: null;
|
||||||
|
const projectTitle =
|
||||||
|
typeof projectData.title === "string" && projectData.title.trim()
|
||||||
|
? projectData.title.trim()
|
||||||
|
: "ton projet";
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
|
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
|
||||||
@@ -567,7 +612,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
|||||||
const audioUrls = tracks
|
const audioUrls = tracks
|
||||||
.map(
|
.map(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl
|
t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl,
|
||||||
)
|
)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
@@ -582,7 +627,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
|||||||
has_audio_url: !!t.audio_url,
|
has_audio_url: !!t.audio_url,
|
||||||
has_stream_audio_url: !!t.stream_audio_url,
|
has_stream_audio_url: !!t.stream_audio_url,
|
||||||
})),
|
})),
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,14 +652,14 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||||
path
|
path,
|
||||||
)}?alt=media&token=${token}`;
|
)}?alt=media&token=${token}`;
|
||||||
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
|
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
|
||||||
return { path, url: downloadUrl };
|
return { path, url: downloadUrl };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
||||||
e.message
|
e.message,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -628,19 +673,47 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
|||||||
// 4) Mettre à jour le statut du projet
|
// 4) Mettre à jour le statut du projet
|
||||||
try {
|
try {
|
||||||
const musicUrls = [p1?.url, p2?.url].filter(Boolean);
|
const musicUrls = [p1?.url, p2?.url].filter(Boolean);
|
||||||
await db.collection("projects").doc(projectId).set(
|
await refList.projects.doc(projectId).set(
|
||||||
{
|
{
|
||||||
musicStatus: "GENERATED",
|
musicStatus: "GENERATED",
|
||||||
musicUrls,
|
musicUrls,
|
||||||
musicError: admin.firestore.FieldValue.delete(),
|
musicError: admin.firestore.FieldValue.delete(),
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||||||
projectId,
|
projectId,
|
||||||
musicUrlsCount: musicUrls.length,
|
musicUrlsCount: musicUrls.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const successMessage =
|
||||||
|
musicUrls.length > 0
|
||||||
|
? `Ta musique pour "${projectTitle}" est prête.`
|
||||||
|
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: userId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Musique prête",
|
||||||
|
message: successMessage,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||||
|
projectId,
|
||||||
|
projectTitle,
|
||||||
|
musicUrls,
|
||||||
|
taskId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error(
|
||||||
|
"[sunoCallback] Failed to send success notification:",
|
||||||
|
notifError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
|
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ const resendInstance = new Resend(RESEND_API_KEY);
|
|||||||
|
|
||||||
// Initialisation de Expo SDK
|
// Initialisation de Expo SDK
|
||||||
let expo = new Expo();
|
let expo = new Expo();
|
||||||
const db = admin.firestore();
|
|
||||||
|
|
||||||
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
|
const EMAIL_FROM = "MusicLand <musicland@minuit.app>";
|
||||||
|
|
||||||
|
function getCollectionRef(collectionName = "") {
|
||||||
|
const ref = refList?.[collectionName];
|
||||||
|
if (!ref) {
|
||||||
|
throw new Error(`Unknown collection "${collectionName}"`);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
||||||
{ region: "europe-west1", document: "notifications/{notificationId}" },
|
{ region: "europe-west1", document: "notifications/{notificationId}" },
|
||||||
async (event) => {
|
async (event) => {
|
||||||
@@ -34,12 +40,17 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
|
|||||||
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 {
|
const {
|
||||||
pushToken = null,
|
pushToken = null,
|
||||||
pushTokens = [],
|
pushTokens = [],
|
||||||
email = "",
|
email = "",
|
||||||
emailNotifications = false,
|
emailNotifications = false,
|
||||||
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
|
} = receiverData;
|
||||||
|
|
||||||
if (!mailOnly) {
|
if (!mailOnly) {
|
||||||
const tokensSet = new Set(
|
const tokensSet = new Set(
|
||||||
@@ -193,7 +204,7 @@ async function removeInvalidTokens({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docRef = db.collection(receiverCollection).doc(receiverId);
|
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
|
||||||
const userSnap = await docRef.get();
|
const userSnap = await docRef.get();
|
||||||
const userData = userSnap?.data() || {};
|
const userData = userSnap?.data() || {};
|
||||||
|
|
||||||
|
|||||||
+142
-64
@@ -1,36 +1,17 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||||
const { refList } = require("../index");
|
const _ = require("lodash");
|
||||||
|
const { refList, db, ALERT_TYPE } = require("../index");
|
||||||
const firestore = refList.projects.firestore;
|
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_REVENUE_BASELINE = 1000;
|
||||||
const DISTRIBUTION_RATIO = 0.3;
|
const DISTRIBUTION_RATIO = 0.3;
|
||||||
const MAX_RECIPIENTS = 10;
|
|
||||||
const WEIGHTS = Array.from({ length: MAX_RECIPIENTS }, (_, idx) => MAX_RECIPIENTS - idx);
|
|
||||||
|
|
||||||
const buildPreviousMonthContext = (referenceDate) => {
|
|
||||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
|
||||||
current.setHours(1, 0, 0, 0);
|
|
||||||
current.setDate(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);
|
|
||||||
|
|
||||||
return {
|
|
||||||
year,
|
|
||||||
month: month + 1,
|
|
||||||
monthKey,
|
|
||||||
rangeStart,
|
|
||||||
rangeEnd,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.distributeMonthlyPayouts = onSchedule(
|
exports.distributeMonthlyPayouts = onSchedule(
|
||||||
{
|
{
|
||||||
@@ -40,64 +21,164 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
async (event) => {
|
async (event) => {
|
||||||
const { scheduleTime } = event;
|
const { scheduleTime } = event;
|
||||||
const context = buildPreviousMonthContext(
|
const context = buildPreviousMonthContext(
|
||||||
scheduleTime ? new Date(scheduleTime) : new Date()
|
scheduleTime ? new Date(scheduleTime) : new Date(),
|
||||||
);
|
);
|
||||||
const monthKey = context.monthKey;
|
const monthKey = buildMonthKey(context.rangeStart);
|
||||||
const now = admin.firestore.Timestamp.now();
|
const now = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
const statsSnapshot = await firestore
|
const statsSnapshot = await db
|
||||||
.collectionGroup("monthly")
|
.collectionGroup("monthlyListens")
|
||||||
.where("monthKey", "==", monthKey)
|
.where("monthKey", "==", monthKey)
|
||||||
.orderBy("streams", "desc")
|
.orderBy("streams", "desc")
|
||||||
.limit(MAX_RECIPIENTS)
|
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const topEntries = statsSnapshot.docs
|
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
|
||||||
.map((doc, index) => {
|
const totalsSnapshot = await totalsDocRef.get();
|
||||||
|
|
||||||
|
const entries = _.chain(statsSnapshot.docs)
|
||||||
|
.map((doc) => {
|
||||||
const data = doc.data() || {};
|
const data = doc.data() || {};
|
||||||
return {
|
return {
|
||||||
rank: index + 1,
|
projectId:
|
||||||
projectId: data.projectId || doc.ref.parent.parent?.id || null,
|
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
|
||||||
userId: data.userId || null,
|
userId: _.get(data, "userId", null),
|
||||||
streams: typeof data.streams === "number" ? data.streams : 0,
|
streams: _.toFinite(_.get(data, "streams", 0)),
|
||||||
statsDocPath: doc.ref.path,
|
statsDocPath: doc.ref.path,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((entry) => entry.projectId && entry.streams > 0);
|
.filter((entry) => entry.projectId && entry.streams > 0)
|
||||||
|
.orderBy(["streams"], ["desc"])
|
||||||
|
.value();
|
||||||
|
|
||||||
const payoutPool = Number(
|
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
|
||||||
(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
|
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
|
||||||
|
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
|
||||||
|
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
|
||||||
|
totalStreams = payoutsTotalStreamsFromDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payoutPool = _.round(
|
||||||
|
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
|
||||||
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const weights = WEIGHTS.slice(0, topEntries.length);
|
let allocations = _.map(entries, (entry) => {
|
||||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
if (!totalStreams) return 0;
|
||||||
|
const rawAmount = (payoutPool * entry.streams) / totalStreams;
|
||||||
let allocations = topEntries.map((entry, idx) => {
|
return _.round(rawAmount, 2);
|
||||||
if (!weightSum) return 0;
|
|
||||||
const rawAmount = (payoutPool * weights[idx]) / weightSum;
|
|
||||||
return Number(rawAmount.toFixed(2));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allocations.length > 0) {
|
if (allocations.length > 0) {
|
||||||
const allocatedTotal = Number(
|
const allocatedTotal = _.round(_.sum(allocations), 2);
|
||||||
allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
|
const remainder = _.round(payoutPool - allocatedTotal, 2);
|
||||||
);
|
|
||||||
const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
|
|
||||||
if (remainder !== 0) {
|
if (remainder !== 0) {
|
||||||
allocations[0] = Number((allocations[0] + remainder).toFixed(2));
|
allocations[0] = _.round(allocations[0] + remainder, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payouts = topEntries.map((entry, idx) => ({
|
const payouts = entries.map((entry, idx) => ({
|
||||||
rank: entry.rank,
|
rank: idx + 1,
|
||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
userId: entry.userId,
|
userId: entry.userId,
|
||||||
streams: entry.streams,
|
streams: entry.streams,
|
||||||
weight: weights[idx],
|
share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0,
|
||||||
amount: allocations[idx],
|
amount: allocations[idx],
|
||||||
statsDocPath: entry.statsDocPath,
|
statsDocPath: entry.statsDocPath,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const userDocs = _(payouts)
|
||||||
|
.filter((payout) => !!payout.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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
totalAmount: _.round(_.sumBy(projects, "amount"), 2),
|
||||||
|
totalStreams: _.sumBy(projects, "streams"),
|
||||||
|
projects: sortedProjects,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.value();
|
||||||
|
|
||||||
|
if (userDocs.length) {
|
||||||
|
const docs = userDocs.map((userData) => {
|
||||||
|
const docId = `${monthKey}_${userData.userId}`;
|
||||||
|
return {
|
||||||
|
ref: refList.monthlyPayoutEntries.doc(docId),
|
||||||
|
data: {
|
||||||
|
monthKey,
|
||||||
|
userId: userData.userId,
|
||||||
|
payoutMonth: context.month,
|
||||||
|
totalAmount: userData.totalAmount,
|
||||||
|
totalStreams: userData.totalStreams,
|
||||||
|
projects: userData.projects,
|
||||||
|
computedAt: now,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await batchFirestore({
|
||||||
|
docs,
|
||||||
|
type: BATCH_TYPE.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
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()
|
||||||
|
? userData.userId.trim()
|
||||||
|
: null;
|
||||||
|
const amount = Number(userData.totalAmount) || 0;
|
||||||
|
if (!receiverId || amount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const amountLabel = amount.toFixed(2);
|
||||||
|
const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`;
|
||||||
|
try {
|
||||||
|
await sendNotification({
|
||||||
|
sender: "SYSTEM",
|
||||||
|
receiver: receiverId,
|
||||||
|
receiverCollection: "users",
|
||||||
|
title: "Revenus disponibles",
|
||||||
|
message,
|
||||||
|
data: {
|
||||||
|
type: ALERT_TYPE?.PAYOUT_AVAILABLE,
|
||||||
|
monthKey,
|
||||||
|
month: context.month,
|
||||||
|
year: context.year,
|
||||||
|
totalAmount: amount,
|
||||||
|
totalStreams: userData.totalStreams,
|
||||||
|
projects: userData.projects,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (notifError) {
|
||||||
|
console.log(
|
||||||
|
"[distributeMonthlyPayouts] Failed to send payout notification:",
|
||||||
|
{
|
||||||
|
userId: receiverId,
|
||||||
|
error: notifError?.message || String(notifError),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
monthKey,
|
monthKey,
|
||||||
month: context.month,
|
month: context.month,
|
||||||
@@ -109,19 +190,16 @@ exports.distributeMonthlyPayouts = onSchedule(
|
|||||||
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
|
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
|
||||||
payoutRatio: DISTRIBUTION_RATIO,
|
payoutRatio: DISTRIBUTION_RATIO,
|
||||||
payoutPool,
|
payoutPool,
|
||||||
totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
|
totalStreams,
|
||||||
totalRecipients: payouts.length,
|
totalRecipients: payouts.length,
|
||||||
totalAllocated: payouts.reduce(
|
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
|
||||||
(sum, payout) => Number((sum + payout.amount).toFixed(2)),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
payouts,
|
payouts,
|
||||||
status: payouts.length ? "computed" : "no-data",
|
status: payouts.length ? "computed" : "no-data",
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
computedAt: now,
|
computedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
|
const docRef = refList.monthlyPayouts.doc(monthKey);
|
||||||
await docRef.set(summary, { merge: true });
|
await docRef.set(summary, { merge: true });
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+90
-106
@@ -1,69 +1,12 @@
|
|||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const { onDocumentWritten } = require("firebase-functions/firestore");
|
const { onDocumentWritten } = require("firebase-functions/firestore");
|
||||||
const { refList } = require("../index");
|
const _ = require("lodash");
|
||||||
|
const { refList, db } = require("../index");
|
||||||
const { getSunoTimestamps } = require("./lyrics");
|
const { getSunoTimestamps } = require("./lyrics");
|
||||||
const { deleteFolder } = require("../helpers/firebase");
|
const { deleteFolder } = require("../helpers/firebase");
|
||||||
|
const { buildMonthKey } = require("../helpers/stats");
|
||||||
|
|
||||||
const firestore = admin.firestore();
|
exports.onProjectWritten = onDocumentWritten(
|
||||||
|
|
||||||
const buildMonthKey = (timestamp) => {
|
|
||||||
const date = timestamp.toDate();
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
return `${year}-${month}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.onProjectUpdate = onDocumentWritten(
|
|
||||||
"projects/{projectId}",
|
|
||||||
async (projectSnap) => {
|
|
||||||
try {
|
|
||||||
const { projectId } = projectSnap.params;
|
|
||||||
|
|
||||||
const currentData = projectSnap?.data?.after?.data() || null;
|
|
||||||
const previousData = projectSnap?.data?.before?.data() || null;
|
|
||||||
|
|
||||||
if (!previousData?.songUrl && currentData?.songUrl) {
|
|
||||||
await getSunoTimestamps(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentData) {
|
|
||||||
const { userId } = previousData || {};
|
|
||||||
|
|
||||||
//delete folder
|
|
||||||
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
|
||||||
|
|
||||||
//delete tasks
|
|
||||||
const snapshot = await refList.tasks
|
|
||||||
.where("projectId", "==", projectId)
|
|
||||||
.get();
|
|
||||||
if (snapshot.empty) return null;
|
|
||||||
const batch = admin.firestore().batch();
|
|
||||||
snapshot.forEach((doc) => {
|
|
||||||
batch.delete(doc.ref);
|
|
||||||
});
|
|
||||||
await batch.commit();
|
|
||||||
} else {
|
|
||||||
if (previousData) {
|
|
||||||
// edit
|
|
||||||
if (!previousData?.songUrl && currentData?.songUrl) {
|
|
||||||
await refList.projects.doc(projectId).update({
|
|
||||||
hasSong: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!previousData?.playbackUrl && currentData?.playbackUrl) {
|
|
||||||
await refList.projects.doc(projectId).update({
|
|
||||||
hasPlayback: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
exports.onProjectViewsIncrement = onDocumentWritten(
|
|
||||||
"projects/{projectId}",
|
"projects/{projectId}",
|
||||||
async (projectSnap) => {
|
async (projectSnap) => {
|
||||||
try {
|
try {
|
||||||
@@ -71,53 +14,94 @@ exports.onProjectViewsIncrement = onDocumentWritten(
|
|||||||
const beforeData = projectSnap?.data?.before?.data() || null;
|
const beforeData = projectSnap?.data?.before?.data() || null;
|
||||||
const afterData = projectSnap?.data?.after?.data() || null;
|
const afterData = projectSnap?.data?.after?.data() || null;
|
||||||
|
|
||||||
if (!afterData) return null;
|
if (!afterData) {
|
||||||
|
const { userId } = beforeData || {};
|
||||||
|
|
||||||
const beforeViews =
|
if (userId) {
|
||||||
typeof beforeData?.views === "number" && Number.isFinite(beforeData.views)
|
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
||||||
? beforeData.views
|
|
||||||
: 0;
|
|
||||||
const afterViews =
|
|
||||||
typeof afterData?.views === "number" && Number.isFinite(afterData.views)
|
|
||||||
? afterData.views
|
|
||||||
: 0;
|
|
||||||
const delta = afterViews - beforeViews;
|
|
||||||
|
|
||||||
if (delta <= 0) return null;
|
|
||||||
|
|
||||||
const userId =
|
|
||||||
typeof afterData?.userId === "string" && afterData.userId.trim().length
|
|
||||||
? afterData.userId.trim()
|
|
||||||
: null;
|
|
||||||
const now = admin.firestore.Timestamp.now();
|
|
||||||
const monthKey = buildMonthKey(now);
|
|
||||||
|
|
||||||
const statsDocRef = firestore
|
|
||||||
.collection("projectStreamStats")
|
|
||||||
.doc(projectId)
|
|
||||||
.collection("monthly")
|
|
||||||
.doc(monthKey);
|
|
||||||
|
|
||||||
await firestore.runTransaction(async (transaction) => {
|
|
||||||
const statsSnapshot = await transaction.get(statsDocRef);
|
|
||||||
const updatePayload = {
|
|
||||||
projectId,
|
|
||||||
userId,
|
|
||||||
monthKey,
|
|
||||||
updatedAt: now,
|
|
||||||
lastStreamAt: now,
|
|
||||||
lastDelta: delta,
|
|
||||||
streams: admin.firestore.FieldValue.increment(delta),
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasFirstStream =
|
|
||||||
statsSnapshot.exists && statsSnapshot.data()?.firstStreamAt;
|
|
||||||
if (!hasFirstStream) {
|
|
||||||
updatePayload.firstStreamAt = now;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.set(statsDocRef, updatePayload, { 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;
|
||||||
|
} 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: admin.firestore.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: admin.firestore.FieldValue.increment(delta),
|
||||||
|
};
|
||||||
|
const totalsHasFirstStream =
|
||||||
|
totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt;
|
||||||
|
if (!totalsHasFirstStream) {
|
||||||
|
totalsUpdatePayload.firstStreamAt = now;
|
||||||
|
}
|
||||||
|
transaction.set(totalsDocRef, totalsUpdatePayload, { merge: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,5 +110,5 @@ exports.onProjectViewsIncrement = onDocumentWritten(
|
|||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const fs = require("node:fs/promises");
|
|||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
|
const { refList } = require("../index");
|
||||||
|
|
||||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
|||||||
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
|
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
|
||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await admin.firestore().collection("projects").doc(projectId).set(
|
await refList.projects.doc(projectId).set(
|
||||||
{
|
{
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
|||||||
Generated
+21106
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,13 @@ const Input = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ContainerView = isBlur ? BlurView : View;
|
const ContainerView = isBlur ? BlurView : View;
|
||||||
|
const resolvedKeyboardType =
|
||||||
|
textInputProps?.keyboardType ??
|
||||||
|
(isNumeric
|
||||||
|
? "numeric"
|
||||||
|
: type === "email"
|
||||||
|
? "email-address"
|
||||||
|
: "default");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -176,13 +183,12 @@ const Input = ({
|
|||||||
: {})}
|
: {})}
|
||||||
{...(type === "email"
|
{...(type === "email"
|
||||||
? {
|
? {
|
||||||
keyboardType: "email-address",
|
|
||||||
autoCapitalize: "none",
|
autoCapitalize: "none",
|
||||||
autoCompleteType: "email",
|
autoCompleteType: "email",
|
||||||
textContentType: "emailAddress",
|
textContentType: "emailAddress",
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
keyboardType={isNumeric ? "numeric" : "default"}
|
keyboardType={resolvedKeyboardType}
|
||||||
keyboardAppearance="dark"
|
keyboardAppearance="dark"
|
||||||
inputAccessoryViewID={inputAccessoryViewID}
|
inputAccessoryViewID={inputAccessoryViewID}
|
||||||
{...textInputProps}
|
{...textInputProps}
|
||||||
|
|||||||
Reference in New Issue
Block a user