From 0dd0fa5ab7e03dc5c7642832078b908ea419b541 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Fri, 31 Oct 2025 17:31:10 +0100 Subject: [PATCH] monthly money --- app.json | 1 + functions/helpers/email.js | 6 +- functions/index.js | 1 + functions/src/payouts.js | 127 ++++++++++++++++++++++ functions/src/project.js | 75 +++++++++++++ functions/src/users.js | 32 ++++++ index.web.js | 3 + src/components/modal/MusicOptionsModal.js | 28 +++++ src/screens/Library/MusicDetails.js | 22 ++++ src/screens/Library/MusicDetails.web.js | 5 + 10 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 functions/src/payouts.js diff --git a/app.json b/app.json index e91aef8..2c501e8 100644 --- a/app.json +++ b/app.json @@ -28,6 +28,7 @@ "associatedDomains": ["applinks:minuit.starter"], "bundleIdentifier": "com.omedis.musicland.ai", "infoPlist": { + "NSMicrophoneUsageDescription": "MusicLand uses the microphone to record your voice and audio so you can create and collaborate on music projects.", "UISupportedInterfaceOrientations": [ "UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortraitUpsideDown" diff --git a/functions/helpers/email.js b/functions/helpers/email.js index 999fa13..0a1a4f3 100644 --- a/functions/helpers/email.js +++ b/functions/helpers/email.js @@ -35,7 +35,11 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
- MusicLand + MusicLand

${greeting}

Bienvenue sur MusicLand

diff --git a/functions/index.js b/functions/index.js index 1d393d9..88235b1 100644 --- a/functions/index.js +++ b/functions/index.js @@ -29,3 +29,4 @@ exports.upload = require("./src/upload"); exports.algolia = require("./src/algolia"); exports.notifications = require("./src/notifications"); exports.rankings = require("./src/rankings"); +exports.payouts = require("./src/payouts"); diff --git a/functions/src/payouts.js b/functions/src/payouts.js new file mode 100644 index 0000000..489cacd --- /dev/null +++ b/functions/src/payouts.js @@ -0,0 +1,127 @@ +const admin = require("firebase-admin"); +const { onSchedule } = require("firebase-functions/v2/scheduler"); +const { refList } = require("../index"); + +const firestore = refList.projects.firestore; + +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( + { + schedule: "0 1 1 * *", + timeZone: "Europe/Paris", + }, + async (event) => { + const { scheduleTime } = event; + const context = buildPreviousMonthContext( + scheduleTime ? new Date(scheduleTime) : new Date() + ); + const monthKey = context.monthKey; + const now = admin.firestore.Timestamp.now(); + + const statsSnapshot = await firestore + .collectionGroup("monthly") + .where("monthKey", "==", monthKey) + .orderBy("streams", "desc") + .limit(MAX_RECIPIENTS) + .get(); + + const topEntries = statsSnapshot.docs + .map((doc, index) => { + 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, + statsDocPath: doc.ref.path, + }; + }) + .filter((entry) => entry.projectId && entry.streams > 0); + + const payoutPool = Number( + (DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(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)); + }); + + if (allocations.length > 0) { + const allocatedTotal = Number( + allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2) + ); + const remainder = Number((payoutPool - allocatedTotal).toFixed(2)); + if (remainder !== 0) { + allocations[0] = Number((allocations[0] + remainder).toFixed(2)); + } + } + + const payouts = topEntries.map((entry, idx) => ({ + rank: entry.rank, + projectId: entry.projectId, + userId: entry.userId, + streams: entry.streams, + weight: weights[idx], + amount: allocations[idx], + statsDocPath: entry.statsDocPath, + })); + + const summary = { + monthKey, + month: context.month, + year: context.year, + range: { + start: admin.firestore.Timestamp.fromDate(context.rangeStart), + end: admin.firestore.Timestamp.fromDate(context.rangeEnd), + }, + revenueBaseline: DISTRIBUTION_REVENUE_BASELINE, + payoutRatio: DISTRIBUTION_RATIO, + payoutPool, + totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0), + totalRecipients: payouts.length, + totalAllocated: payouts.reduce( + (sum, payout) => Number((sum + payout.amount).toFixed(2)), + 0 + ), + payouts, + status: payouts.length ? "computed" : "no-data", + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + computedAt: now, + }; + + const docRef = firestore.collection("monthlyPayouts").doc(monthKey); + await docRef.set(summary, { merge: true }); + } +); diff --git a/functions/src/project.js b/functions/src/project.js index 3f0be78..34562b0 100644 --- a/functions/src/project.js +++ b/functions/src/project.js @@ -4,6 +4,15 @@ const { refList } = require("../index"); const { getSunoTimestamps } = require("./lyrics"); const { deleteFolder } = require("../helpers/firebase"); +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) => { @@ -53,3 +62,69 @@ exports.onProjectUpdate = onDocumentWritten( } } ); + +exports.onProjectViewsIncrement = onDocumentWritten( + "projects/{projectId}", + async (projectSnap) => { + try { + const { projectId } = projectSnap.params; + const beforeData = projectSnap?.data?.before?.data() || null; + const afterData = projectSnap?.data?.after?.data() || null; + + if (!afterData) return null; + + 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; + } + + transaction.set(statsDocRef, updatePayload, { merge: true }); + }); + + return null; + } catch (error) { + console.log("onProjectViewsIncrement error", { + message: error?.message || String(error || ""), + }); + return null; + } + } +); diff --git a/functions/src/users.js b/functions/src/users.js index e0bba5d..d1c6440 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -8,12 +8,44 @@ const { deleteFolder } = require("../helpers/firebase"); const { Resend } = require("resend"); const { welcomeTemplate } = require("../helpers/email"); const { RESEND_API_KEY } = require("../config/keys"); +const { onRequest } = require("firebase-functions/https"); const resendClient = new Resend(RESEND_API_KEY); const WELCOME_EMAIL_FROM = process.env.RESEND_FROM_EMAIL || "MusicLand "; const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand"; +exports.testWelcomMail = onRequest(async (req, res) => { + if (req.method !== "GET") { + res.set("Allow", "GET"); + return res.status(405).json({ success: false, error: "Method not allowed" }); + } + try { + const targetEmail = req.query.email || "tdtomthomas@gmail.com"; + const firstName = req.query.firstName || "Toto"; + const lastName = req.query.lastName || "Test"; + const { data, error } = await resendClient.emails.send({ + from: WELCOME_EMAIL_FROM, + to: [targetEmail], + subject: WELCOME_EMAIL_SUBJECT, + html: welcomeTemplate({ firstName, lastName }), + }); + if (error) { + console.log("Failed to send welcome email:", error); + return res + .status(500) + .json({ success: false, error: error.message || error.toString() }); + } + console.log("Welcome email sent:", data); + return res.status(200).json({ success: true, data }); + } catch (e) { + console.log(e); + return res + .status(500) + .json({ success: false, error: e.message || e.toString() }); + } +}); + exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => { try { const { email = "", firstName = "", lastName = "" } = event?.data?.data(); diff --git a/index.web.js b/index.web.js index 4ced4b3..23e775b 100644 --- a/index.web.js +++ b/index.web.js @@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo'; // Inject minimal global CSS and a small setNativeProps polyfill for web if (typeof document !== 'undefined') { + document.documentElement?.setAttribute('translate', 'no'); + document.body?.setAttribute('translate', 'no'); + const style = document.createElement('style'); style.setAttribute('data-inline-global', 'true'); style.innerHTML = ` diff --git a/src/components/modal/MusicOptionsModal.js b/src/components/modal/MusicOptionsModal.js index a4aa74c..0025ce2 100644 --- a/src/components/modal/MusicOptionsModal.js +++ b/src/components/modal/MusicOptionsModal.js @@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { openShareSheet } from "../../utils/shareSheet"; import AppActionSheet from "../AppActionSheet"; const OptionButton = ({ label, onPress, disabled = false }) => ( @@ -40,6 +41,9 @@ const MusicOptionsModal = ({ payload }) => { projectId = null, ownerId = null, title = null, + onShare, + sharePayload = null, + shareDisabled = false, } = payload || {}; const trigger = useCallback((action) => { @@ -97,6 +101,23 @@ const MusicOptionsModal = ({ payload }) => { trigger(run); }, [downloadDisabled, onDownload, trigger]); + const handleShare = useCallback(() => { + if (shareDisabled) return; + const run = () => { + if (typeof onShare === "function") { + onShare(); + return; + } + if (sharePayload) { + openShareSheet(sharePayload); + } + }; + trigger(run); + }, [onShare, shareDisabled, sharePayload, trigger]); + + const shouldShowShareOption = + typeof onShare === "function" || !!sharePayload; + return ( @@ -116,6 +137,13 @@ const MusicOptionsModal = ({ payload }) => { label="Ajouter à une playlist" onPress={handleAddToPlaylist} /> + {shouldShowShareOption && ( + + )} { }; }, [trackId, songUrl, title, artist, coverUrl, projectId]); + const sharePayload = useMemo(() => { + return createMusicSharePayload({ + projectId, + title, + artist, + }); + }, [artist, projectId, title]); + + const handleShare = useCallback(() => { + if (!sharePayload) return; + openShareSheet(sharePayload); + }, [sharePayload]); + const handleReport = useCallback(() => { if (!projectId) return; SheetManager.show("Report", { @@ -147,15 +164,20 @@ const MusicDetails = ({ route }) => { onAddToPlaylist: handleAddToPlaylist, onDownload: handleDownload, downloadDisabled: !songUrl, + onShare: sharePayload ? handleShare : null, + sharePayload, + shareDisabled: !sharePayload, }, }); }, [ handleAddToPlaylist, handleDownload, handleReport, + handleShare, owner?.id, project?.userId, projectId, + sharePayload, songUrl, title, ]); diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index f43d0dd..4cb8b4a 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -243,16 +243,21 @@ const MusicDetails = ({ route }) => { onAddToPlaylist: handleAddToPlaylist, onDownload: handleDownload, downloadDisabled: isDownloading || !songUrl, + onShare: sharePayload ? handleShare : null, + sharePayload, + shareDisabled: !sharePayload, }, }); }, [ handleAddToPlaylist, handleDownload, handleReport, + handleShare, isDownloading, owner?.id, project?.userId, projectId, + sharePayload, songUrl, title, ]);