Merge branch 'main' of gitlab.com:agenceminuit/musicland

This commit is contained in:
2025-11-03 14:10:54 +01:00
16 changed files with 23868 additions and 2399 deletions
+5
View File
@@ -0,0 +1,5 @@
exports.BATCH_TYPE = {
ADD: "ADD",
UPDATE: "UPDATE",
DELETE: "DELETE",
};
+70 -22
View File
@@ -13,49 +13,97 @@ function basicTemplate({ title = "", content = "", button = null }) {
button?.label || "Ouvrir"
}</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">
<div style="max-width:640px;margin:40px auto;padding:24px;border:1px solid #eee;border-radius:12px">
<div style="text-align:center;margin-bottom:20px">
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
<style>
:root { color-scheme: dark; supported-color-schemes: dark; }
body { margin:0 !important; background-color:#0b0b10 !important; }
body, .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
u + .body .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
u + .body .email-content,
[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">${title}</h2>
<div>${content}</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 #eee" />
<p style="color:#666;font-size:12px">minuit.app</p>
</div>
<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>`;
}
function welcomeTemplate({ firstName = "", lastName = "" }) {
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
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">
<div style="max-width:640px;margin:0 auto;padding:40px 24px">
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)">
<div style="padding:32px 28px">
<div style="text-align:center;margin-bottom:24px">
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
<style>
:root { color-scheme: dark; supported-color-schemes: dark; }
body { margin:0 !important; background-color:#0b0b10 !important; }
body, .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
u + .body .email-surface { background-color:#0b0b10 !important; color:#ffffff !important; }
u + .body .email-content,
[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: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)">
<tr>
<td bgcolor="#151520" style="padding:32px 28px;background-color:#151520;color:#ffffff">
<div style="text-align:center;margin-bottom:24px;color:#ffffff">
<img
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">${greeting}</p>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:rgba(255,255,255,0.85)">
<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:rgba(255,255,255,0.75)">
<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:rgba(255,255,255,0.75)">
<p style="margin:0;font-size:15px;line-height:1.6;color:#ffffff!important">
L'équipe MusicLand
</p>
</div>
</div>
<p style="margin:32px 0 0;text-align:center;font-size:12px;color:rgba(255,255,255,0.45)">minuit.app · Paris, France</p>
</div>
</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>`;
}
+74
View File
@@ -1,4 +1,5 @@
const admin = require("firebase-admin");
const { BATCH_TYPE } = require("../config/types");
async function deleteFolder(path) {
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;
+37
View File
@@ -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
View File
@@ -4,18 +4,33 @@ const admin = require("firebase-admin");
// Avoid bundling a service account key and hardcoding project/bucket.
admin.initializeApp();
const db = admin.firestore();
exports.db = db;
exports.refList = {
projects: admin.firestore().collection("projects"),
playlists: admin.firestore().collection("playlists"),
tasks: admin.firestore().collection("tasks"),
notifications: admin.firestore().collection("notifications"),
users: admin.firestore().collection("users"),
projects: db.collection("projects"),
playlists: db.collection("playlists"),
tasks: db.collection("tasks"),
notifications: db.collection("notifications"),
users: db.collection("users"),
projectStreamStats: db.collection("projectStreamStats"),
projectStreamStatsMonthlyTotals: db.collection(
"projectStreamStatsMonthlyTotals",
),
monthlyPayoutEntries: db.collection("monthlyPayoutEntries"),
monthlyPayouts: db.collection("monthlyPayouts"),
};
exports.ALERT_TYPE = {
NEW_LIKE: "NEW_LIKE",
NEW_COMMENT: "NEW_COMMENT",
NEW_FOLLOWER: "NEW_FOLLOWER",
MUSIC_GENERATION_SUCCESS: "MUSIC_GENERATION_SUCCESS",
MUSIC_GENERATION_FAILED: "MUSIC_GENERATION_FAILED",
COVER_GENERATION_SUCCESS: "COVER_GENERATION_SUCCESS",
COVER_GENERATION_FAILED: "COVER_GENERATION_FAILED",
PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE",
};
// Exporter toutes les fonctions
+51 -42
View File
@@ -12,10 +12,11 @@
"axios": "^1.11.0",
"expo-server-sdk": "^4.0.0",
"ffmpeg-static": "^5.2.0",
"firebase-admin": "^12.7.0",
"firebase-functions": "^6.4.0",
"firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1",
"fluent-ffmpeg": "^2.1.3",
"genkit": "^1.18.0",
"lodash": "^4.17.21",
"resend": "^6.3.0",
"sharp": "^0.33.4"
},
@@ -65,9 +66,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
"integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz",
"integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -355,12 +356,12 @@
}
},
"node_modules/@genkit-ai/ai": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/ai/-/ai-1.21.0.tgz",
"integrity": "sha512-DLAes+w3Yv9a4zgJUL5CS8eVrNvud/LRv+F4E/O3KkpWz0uoKYREIrTZYSrDt/pF/jlqkRDQaalf7sGKS44IuQ==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/ai/-/ai-1.22.0.tgz",
"integrity": "sha512-TDKO+zWyM5YI8zE4a0IlqlpgHuLB4B4islzgWDvzdQlbjtyJp0ayODAMFhS2ruQ6+a/UdXDySRrOX/RcqF4yjA==",
"license": "Apache-2.0",
"dependencies": {
"@genkit-ai/core": "1.21.0",
"@genkit-ai/core": "1.22.0",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.11.19",
"colorette": "^2.0.20",
@@ -373,9 +374,9 @@
}
},
"node_modules/@genkit-ai/ai/node_modules/@types/node": {
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"version": "20.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -395,9 +396,9 @@
}
},
"node_modules/@genkit-ai/core": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/core/-/core-1.21.0.tgz",
"integrity": "sha512-suQ81HwtDObejkXJUBWzD5145qoHFXxdozxwzYDy8m93f7kKKtQwSJPQmYXWsTAdHJSjERHfKLCe/xrDshkPYw==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/core/-/core-1.22.0.tgz",
"integrity": "sha512-etVlpwJkPoy91xR6H5+S/AWZPJMeovb7N35+B90md1+6xWcodQF7WZ3chKcH31Xamlz+jTIvd3riiZGY9RFumg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
@@ -447,19 +448,19 @@
"license": "MIT"
},
"node_modules/@genkit-ai/firebase": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/firebase/-/firebase-1.21.0.tgz",
"integrity": "sha512-i21IStUGFn10+25+o7HL2XPtXvb2FIxqtfVHLrpSFqsT8sMdRSNceVg5mU5MHY2yjhbgpfntbs1YDO2sf4QRew==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/firebase/-/firebase-1.22.0.tgz",
"integrity": "sha512-iTOVma8be8cOFzO9yaNuzBIICgFAP+N1cuB5T8K5LE5ahsSIG7sZWUE5FwMKtEDFk9D343kgLuvW9nxLkV7IzA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@genkit-ai/google-cloud": "^1.21.0"
"@genkit-ai/google-cloud": "^1.22.0"
},
"peerDependencies": {
"@google-cloud/firestore": "^7.11.0",
"firebase": ">=11.5.0",
"firebase-admin": ">=12.2",
"genkit": "^1.21.0"
"genkit": "^1.22.0"
},
"peerDependenciesMeta": {
"firebase": {
@@ -468,9 +469,9 @@
}
},
"node_modules/@genkit-ai/google-cloud": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/google-cloud/-/google-cloud-1.21.0.tgz",
"integrity": "sha512-DsNecaDnvH7NNv4NGxIFshjMzWzXXB4fD/CRM6aLQv0TGOTip/3J3+5iKNqeSpmqDv8jIJtrOlclwhKb6cR1Wg==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/google-cloud/-/google-cloud-1.22.0.tgz",
"integrity": "sha512-PVt7oLu3VARQ4u/R4VvG/q2lsEdTke9jxQI8sg1jxHoKV03fNUwKAyNd59ZT4igrQIUCqdJD4tH3Kzh4yd4QTw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -493,13 +494,13 @@
"winston": "^3.12.0"
},
"peerDependencies": {
"genkit": "^1.21.0"
"genkit": "^1.22.0"
}
},
"node_modules/@genkit-ai/googleai": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/googleai/-/googleai-1.21.0.tgz",
"integrity": "sha512-sJauWpr0vu0cnA05UwQAfd2Vh04+Lar1YC3Nn/YBbwBow3Y7KWAqQUNaGHSka/ZYiUHxlzjYyhaDtodmnObepw==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@genkit-ai/googleai/-/googleai-1.22.0.tgz",
"integrity": "sha512-6GTfrsv2acRWlXcvi8jqH044tG/eslMQCH1wxpA//wzTnsnht5f4SJ3isKU3gcmyQtoYWiaUN8/F7OYqkIYHcw==",
"license": "Apache-2.0",
"dependencies": {
"@google/generative-ai": "^0.24.0",
@@ -507,7 +508,7 @@
"node-fetch": "^3.3.2"
},
"peerDependencies": {
"genkit": "^1.21.0"
"genkit": "^1.22.0"
}
},
"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",
"integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "1.25.1",
"@opentelemetry/resources": "1.25.1",
@@ -2974,9 +2976,9 @@
}
},
"node_modules/@types/node": {
"version": "22.18.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz",
"integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==",
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
"integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -3320,9 +3322,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz",
"integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -4749,14 +4751,14 @@
}
},
"node_modules/genkit": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.21.0.tgz",
"integrity": "sha512-catTEjxhHZaicvxwak8jFL0K0H0ndN/qE9s+N7CIbsQeJczvRDleoyFa2mtaBOCaoZt7Xj8zJlYXOmt8tRyqJQ==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/genkit/-/genkit-1.22.0.tgz",
"integrity": "sha512-GoVVO3EnNHrjkMkUPRvgx1MjBHKvOlZAu/ffMIJgLFxrH7rrUbvfHXE6Nk7uh5BNvET7+DApyhbhqz9G8sy+mQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@genkit-ai/ai": "1.21.0",
"@genkit-ai/core": "1.21.0",
"@genkit-ai/ai": "1.22.0",
"@genkit-ai/core": "1.22.0",
"uuid": "^10.0.0"
}
},
@@ -5610,6 +5612,12 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
@@ -6436,9 +6444,9 @@
"license": "MIT"
},
"node_modules/resend": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.3.0.tgz",
"integrity": "sha512-Nmg5cFojC2E07gQ/RnlcroXpclFnlr05yQP2QNbbAhfX1Q4LBqiSd6/0t/fq95MvcuP8EDbb+Eyw1NG2P0OWgw==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.4.0.tgz",
"integrity": "sha512-CTr4ix4RI5M/ucL58Wqr+LE8eI4JHtJEFaBAx6yUVNOI3eaPVtJjpNL0G/BdRSWMbwv6CtpprVOY8Xvpp6UJlA==",
"license": "MIT",
"dependencies": {
"svix": "1.76.1"
@@ -7332,6 +7340,7 @@
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
+3 -2
View File
@@ -20,10 +20,11 @@
"axios": "^1.11.0",
"expo-server-sdk": "^4.0.0",
"ffmpeg-static": "^5.2.0",
"firebase-admin": "^12.7.0",
"firebase-functions": "^6.4.0",
"firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1",
"fluent-ffmpeg": "^2.1.3",
"genkit": "^1.18.0",
"lodash": "^4.17.21",
"resend": "^6.3.0",
"sharp": "^0.33.4"
},
+75 -17
View File
@@ -1,21 +1,17 @@
const { onDocumentCreated } = require("firebase-functions/v2/firestore");
const admin = require("firebase-admin");
const logger = require("firebase-functions/logger");
const {
generateImageV2,
} = require("../helpers/gemini");
const { generateImageV2 } = require("../helpers/gemini");
const { generatePicturePrompt } = require("../helpers/prompts");
const path = require("path");
const fs = require("fs");
const axios = require("axios");
const sharp = require("sharp");
const { ALERT_TYPE, refList } = require("../index");
const { sendNotification } = require("./notifications");
const db = admin.firestore();
const bucket = admin.storage().bucket();
const LOGO_PATH = path.resolve(
__dirname,
"../assets/musicLandProduction.png",
);
const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandProduction.png");
async function buildCoverWithLogo(backgroundUrl, targetPath) {
logger.info("🖼️ [Cover] Adding logo to generated background");
@@ -124,10 +120,7 @@ async function performCoverGeneration(project) {
const [firstOption] = options;
await db
.collection("projects")
.doc(project.id)
.set(
await refList.projects.doc(project.id).set(
{
cover: {
generatedBackground: firstOption?.generatedUrl || null,
@@ -162,6 +155,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
const type = data?.type || "";
const projectId = data?.projectId || null;
let coverUrl = null;
let project = null;
try {
if (!projectId) {
throw new Error("projectId manquant dans la task");
@@ -189,13 +183,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
return;
}
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
coverStatus: "GENERATING",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
const project =
(await db.collection("projects").doc(projectId).get())?.data() || null;
project = (await refList.projects.doc(projectId).get())?.data() || null;
if (!project) {
throw new Error("Projet non trouvé");
}
@@ -217,7 +210,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
projectId,
existingOptionsCount,
});
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
coverStatus: "GENERATED",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
@@ -244,6 +237,39 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
taskId: event?.params?.taskId,
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;
} catch (error) {
logger.error("❌ [Task] Cover generation error", {
@@ -256,9 +282,41 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
{ status: "ERROR", error: error?.message || "Erreur" },
{ merge: true },
);
await db.collection("projects").doc(projectId).update({
await refList.projects.doc(projectId).update({
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
View File
@@ -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 admin = require("firebase-admin");
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_MODEL,
@@ -11,9 +17,6 @@ const {
SUNO_STATUS_PATH,
} = require("../config/suno");
// Initialiser Firestorey
const db = admin.firestore();
/**
* Marque un projet comme échoué suite à une erreur Suno
* @param {string} projectId - Identifiant du projet
@@ -22,7 +25,9 @@ const db = admin.firestore();
async function markProjectMusicFailure(projectId, error) {
if (!projectId) return;
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 sunoMessage =
error?.response?.data?.msg ||
@@ -44,12 +49,44 @@ async function markProjectMusicFailure(projectId, error) {
musicError: errorPayload,
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) {
console.error(
"❌ [generateMusic] Impossible de marquer le projet en erreur:",
err
err,
);
}
}
@@ -80,7 +117,7 @@ function extractVoiceStrings(voice) {
}
if (typeof voice === "object") {
return Object.values(voice).filter(
(v) => typeof v === "string" && v.trim()
(v) => typeof v === "string" && v.trim(),
);
}
return [];
@@ -134,7 +171,7 @@ function detectVocalGender(voiceInput = "") {
if (Array.isArray(voiceInput)) {
const baseItem = voiceInput.find(
(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;
if (text) return detectVocalGender(text);
@@ -346,7 +383,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
"Content-Type": "application/json",
Authorization: `Bearer ${SUNO_API_KEY}`,
},
}
},
);
const parsed = response.data;
@@ -370,7 +407,7 @@ exports.generateMusic = onCall(async ({ data = {} }) => {
} catch (markErr) {
console.error(
"❌ 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}`,
},
timeout: 30000, // 30 secondes de timeout
}
},
);
parsed = response.data;
@@ -547,16 +584,24 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
// 1) Récupérer le projectId associé au taskId
let projectId = null;
const projSnap = await db
.collection("projects")
const projSnap = await refList.projects
.where("sunoTaskId", "==", taskId)
.limit(1)
.get();
if (projSnap.empty) {
throw new Error("Aucun projet trouvé pour ce taskId");
}
projectId = projSnap.docs[0].id;
const { userId } = projSnap.docs[0].data() || {};
const projectDoc = projSnap.docs[0];
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) {
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
@@ -567,7 +612,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
const audioUrls = tracks
.map(
(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)
.slice(0, 2);
@@ -582,7 +627,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
has_audio_url: !!t.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(
path
path,
)}?alt=media&token=${token}`;
console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl);
return { path, url: downloadUrl };
} catch (e) {
console.error(
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
e.message
e.message,
);
return null;
}
@@ -628,19 +673,47 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
// 4) Mettre à jour le statut du projet
try {
const musicUrls = [p1?.url, p2?.url].filter(Boolean);
await db.collection("projects").doc(projectId).set(
await refList.projects.doc(projectId).set(
{
musicStatus: "GENERATED",
musicUrls,
musicError: admin.firestore.FieldValue.delete(),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
{ merge: true },
);
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,
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) {
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
}
+15 -4
View File
@@ -13,10 +13,16 @@ const resendInstance = new Resend(RESEND_API_KEY);
// Initialisation de Expo SDK
let expo = new Expo();
const db = admin.firestore();
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(
{ region: "europe-west1", document: "notifications/{notificationId}" },
async (event) => {
@@ -34,12 +40,17 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated(
throw new Error("Receiver and message are required");
}
const receiverSnap = await getCollectionRef(receiverCollection)
.doc(receiver)
.get();
const receiverData = receiverSnap.exists ? receiverSnap.data() : {};
const {
pushToken = null,
pushTokens = [],
email = "",
emailNotifications = false,
} = (await db.collection(receiverCollection).doc(receiver).get()).data();
} = receiverData;
if (!mailOnly) {
const tokensSet = new Set(
@@ -193,7 +204,7 @@ async function removeInvalidTokens({
return;
}
const docRef = db.collection(receiverCollection).doc(receiverId);
const docRef = getCollectionRef(receiverCollection).doc(receiverId);
const userSnap = await docRef.get();
const userData = userSnap?.data() || {};
+142 -64
View File
@@ -1,36 +1,17 @@
const admin = require("firebase-admin");
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { refList } = require("../index");
const firestore = refList.projects.firestore;
const _ = require("lodash");
const { refList, db, ALERT_TYPE } = require("../index");
const { batchFirestore } = require("../helpers/firebase");
const { BATCH_TYPE } = require("../config/types");
const {
buildMonthKey,
buildPreviousMonthContext,
} = require("../helpers/stats");
const { sendNotification } = require("./notifications");
const DISTRIBUTION_REVENUE_BASELINE = 1000;
const DISTRIBUTION_RATIO = 0.3;
const 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(
{
@@ -40,64 +21,164 @@ exports.distributeMonthlyPayouts = onSchedule(
async (event) => {
const { scheduleTime } = event;
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 statsSnapshot = await firestore
.collectionGroup("monthly")
const statsSnapshot = await db
.collectionGroup("monthlyListens")
.where("monthKey", "==", monthKey)
.orderBy("streams", "desc")
.limit(MAX_RECIPIENTS)
.get();
const topEntries = statsSnapshot.docs
.map((doc, index) => {
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
const totalsSnapshot = await totalsDocRef.get();
const entries = _.chain(statsSnapshot.docs)
.map((doc) => {
const data = doc.data() || {};
return {
rank: index + 1,
projectId: data.projectId || doc.ref.parent.parent?.id || null,
userId: data.userId || null,
streams: typeof data.streams === "number" ? data.streams : 0,
projectId:
_.get(data, "projectId") || doc.ref.parent.parent?.id || null,
userId: _.get(data, "userId", null),
streams: _.toFinite(_.get(data, "streams", 0)),
statsDocPath: doc.ref.path,
};
})
.filter((entry) => entry.projectId && entry.streams > 0);
.filter((entry) => entry.projectId && entry.streams > 0)
.orderBy(["streams"], ["desc"])
.value();
const payoutPool = Number(
(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams");
const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null;
let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0));
if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) {
totalStreams = payoutsTotalStreamsFromDocs;
}
const payoutPool = _.round(
DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO,
2,
);
const weights = WEIGHTS.slice(0, topEntries.length);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
let allocations = topEntries.map((entry, idx) => {
if (!weightSum) return 0;
const rawAmount = (payoutPool * weights[idx]) / weightSum;
return Number(rawAmount.toFixed(2));
let allocations = _.map(entries, (entry) => {
if (!totalStreams) return 0;
const rawAmount = (payoutPool * entry.streams) / totalStreams;
return _.round(rawAmount, 2);
});
if (allocations.length > 0) {
const allocatedTotal = Number(
allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
);
const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
const allocatedTotal = _.round(_.sum(allocations), 2);
const remainder = _.round(payoutPool - allocatedTotal, 2);
if (remainder !== 0) {
allocations[0] = Number((allocations[0] + remainder).toFixed(2));
allocations[0] = _.round(allocations[0] + remainder, 2);
}
}
const payouts = topEntries.map((entry, idx) => ({
rank: entry.rank,
const payouts = entries.map((entry, idx) => ({
rank: idx + 1,
projectId: entry.projectId,
userId: entry.userId,
streams: entry.streams,
weight: weights[idx],
share: totalStreams ? _.round(entry.streams / totalStreams, 6) : 0,
amount: allocations[idx],
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 = {
monthKey,
month: context.month,
@@ -109,19 +190,16 @@ exports.distributeMonthlyPayouts = onSchedule(
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
payoutRatio: DISTRIBUTION_RATIO,
payoutPool,
totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
totalStreams,
totalRecipients: payouts.length,
totalAllocated: payouts.reduce(
(sum, payout) => Number((sum + payout.amount).toFixed(2)),
0
),
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
payouts,
status: payouts.length ? "computed" : "no-data",
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
computedAt: now,
};
const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
const docRef = refList.monthlyPayouts.doc(monthKey);
await docRef.set(summary, { merge: true });
}
},
);
+63 -79
View File
@@ -1,69 +1,12 @@
const admin = require("firebase-admin");
const { onDocumentWritten } = require("firebase-functions/firestore");
const { refList } = require("../index");
const _ = require("lodash");
const { refList, db } = require("../index");
const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase");
const { buildMonthKey } = require("../helpers/stats");
const firestore = admin.firestore();
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(
exports.onProjectWritten = onDocumentWritten(
"projects/{projectId}",
async (projectSnap) => {
try {
@@ -71,35 +14,61 @@ exports.onProjectViewsIncrement = onDocumentWritten(
const beforeData = projectSnap?.data?.before?.data() || null;
const afterData = projectSnap?.data?.after?.data() || null;
if (!afterData) return null;
if (!afterData) {
const { userId } = beforeData || {};
const beforeViews =
typeof beforeData?.views === "number" && Number.isFinite(beforeData.views)
? beforeData.views
: 0;
const afterViews =
typeof afterData?.views === "number" && Number.isFinite(afterData.views)
? afterData.views
: 0;
if (userId) {
await deleteFolder(`users/${userId}/projects/${projectId}/`);
}
const snapshot = await refList.tasks
.where("projectId", "==", projectId)
.get();
if (!snapshot.empty) {
const batch = db.batch();
snapshot.forEach((doc) => {
batch.delete(doc.ref);
});
await batch.commit();
}
return null;
} else if (!beforeData) {
//song create
} else {
if (!beforeData?.songUrl && afterData?.songUrl) {
await getSunoTimestamps(projectId);
await refList.projects.doc(projectId).update({ hasSong: true });
}
if (!beforeData?.playbackUrl && afterData?.playbackUrl) {
await refList.projects.doc(projectId).update({ hasPlayback: true });
}
}
const beforeViews = _.toFinite(_.get(beforeData, "views", 0));
const afterViews = _.toFinite(_.get(afterData, "views", 0));
const delta = afterViews - beforeViews;
if (delta <= 0) return null;
if (delta > 0) {
const userIdRaw = _.get(afterData, "userId", null);
const userId =
typeof afterData?.userId === "string" && afterData.userId.trim().length
? afterData.userId.trim()
_.isString(userIdRaw) && _.trim(userIdRaw).length
? _.trim(userIdRaw)
: null;
const now = admin.firestore.Timestamp.now();
const monthKey = buildMonthKey(now);
const statsDocRef = firestore
.collection("projectStreamStats")
const statsDocRef = refList.projectStreamStats
.doc(projectId)
.collection("monthly")
.collection("monthlyListens")
.doc(monthKey);
const totalsDocRef =
refList.projectStreamStatsMonthlyTotals.doc(monthKey);
await firestore.runTransaction(async (transaction) => {
await db.runTransaction(async (transaction) => {
const statsSnapshot = await transaction.get(statsDocRef);
const totalsSnapshot = await transaction.get(totalsDocRef);
const updatePayload = {
projectId,
userId,
@@ -117,7 +86,22 @@ exports.onProjectViewsIncrement = onDocumentWritten(
}
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;
} catch (error) {
@@ -126,5 +110,5 @@ exports.onProjectViewsIncrement = onDocumentWritten(
});
return null;
}
}
},
);
+2 -1
View File
@@ -7,6 +7,7 @@ const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const { refList } = require("../index");
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
@@ -146,7 +147,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`;
if (projectId) {
await admin.firestore().collection("projects").doc(projectId).set(
await refList.projects.doc(projectId).set(
{
thumbnailUrl,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
+21106
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -67,6 +67,13 @@ const Input = ({
});
const ContainerView = isBlur ? BlurView : View;
const resolvedKeyboardType =
textInputProps?.keyboardType ??
(isNumeric
? "numeric"
: type === "email"
? "email-address"
: "default");
return (
<>
@@ -176,13 +183,12 @@ const Input = ({
: {})}
{...(type === "email"
? {
keyboardType: "email-address",
autoCapitalize: "none",
autoCompleteType: "email",
textContentType: "emailAddress",
}
: {})}
keyboardType={isNumeric ? "numeric" : "default"}
keyboardType={resolvedKeyboardType}
keyboardAppearance="dark"
inputAccessoryViewID={inputAccessoryViewID}
{...textInputProps}
+2049 -2086
View File
File diff suppressed because it is too large Load Diff