functions
This commit is contained in:
+86
-28
@@ -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,22 +120,19 @@ async function performCoverGeneration(project) {
|
||||
|
||||
const [firstOption] = options;
|
||||
|
||||
await db
|
||||
.collection("projects")
|
||||
.doc(project.id)
|
||||
.set(
|
||||
{
|
||||
cover: {
|
||||
generatedBackground: firstOption?.generatedUrl || null,
|
||||
result: firstOption?.finalUrl || null,
|
||||
selectedOptionId: firstOption?.id || null,
|
||||
options,
|
||||
},
|
||||
coverStatus: "GENERATED",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
await refList.projects.doc(project.id).set(
|
||||
{
|
||||
cover: {
|
||||
generatedBackground: firstOption?.generatedUrl || null,
|
||||
result: firstOption?.finalUrl || null,
|
||||
selectedOptionId: firstOption?.id || null,
|
||||
options,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
coverStatus: "GENERATED",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
|
||||
logger.info("✅ [Cover] Saved", {
|
||||
projectId: project.id,
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
+90
-106
@@ -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,53 +14,94 @@ 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;
|
||||
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;
|
||||
if (userId) {
|
||||
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
@@ -126,5 +110,5 @@ exports.onProjectViewsIncrement = onDocumentWritten(
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user