monthly money

This commit is contained in:
Thomas Demirdjian
2025-10-31 17:31:10 +01:00
parent 6f330c1df8
commit 0dd0fa5ab7
10 changed files with 299 additions and 1 deletions
+1
View File
@@ -28,6 +28,7 @@
"associatedDomains": ["applinks:minuit.starter"], "associatedDomains": ["applinks:minuit.starter"],
"bundleIdentifier": "com.omedis.musicland.ai", "bundleIdentifier": "com.omedis.musicland.ai",
"infoPlist": { "infoPlist": {
"NSMicrophoneUsageDescription": "MusicLand uses the microphone to record your voice and audio so you can create and collaborate on music projects.",
"UISupportedInterfaceOrientations": [ "UISupportedInterfaceOrientations": [
"UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown" "UIInterfaceOrientationPortraitUpsideDown"
+5 -1
View File
@@ -35,7 +35,11 @@ function welcomeTemplate({ firstName = "", lastName = "" }) {
<div style="background:#151520;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)"> <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="padding:32px 28px">
<div style="text-align:center;margin-bottom:24px"> <div style="text-align:center;margin-bottom:24px">
<img src="${musicLandLogoSrc}" alt="MusicLand" style="height:48px" /> <img
src="${musicLandLogoSrc}"
alt="MusicLand"
style="display:block;margin:0 auto;width:180px;max-width:100%;height:auto"
/>
</div> </div>
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px">${greeting}</p> <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> <h1 style="margin:0 0 16px;font-size:28px;line-height:1.2">Bienvenue sur MusicLand</h1>
+1
View File
@@ -29,3 +29,4 @@ exports.upload = require("./src/upload");
exports.algolia = require("./src/algolia"); exports.algolia = require("./src/algolia");
exports.notifications = require("./src/notifications"); exports.notifications = require("./src/notifications");
exports.rankings = require("./src/rankings"); exports.rankings = require("./src/rankings");
exports.payouts = require("./src/payouts");
+127
View File
@@ -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 });
}
);
+75
View File
@@ -4,6 +4,15 @@ const { refList } = require("../index");
const { getSunoTimestamps } = require("./lyrics"); const { getSunoTimestamps } = require("./lyrics");
const { deleteFolder } = require("../helpers/firebase"); 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( exports.onProjectUpdate = onDocumentWritten(
"projects/{projectId}", "projects/{projectId}",
async (projectSnap) => { 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;
}
}
);
+32
View File
@@ -8,12 +8,44 @@ const { deleteFolder } = require("../helpers/firebase");
const { Resend } = require("resend"); const { Resend } = require("resend");
const { welcomeTemplate } = require("../helpers/email"); const { welcomeTemplate } = require("../helpers/email");
const { RESEND_API_KEY } = require("../config/keys"); const { RESEND_API_KEY } = require("../config/keys");
const { onRequest } = require("firebase-functions/https");
const resendClient = new Resend(RESEND_API_KEY); const resendClient = new Resend(RESEND_API_KEY);
const WELCOME_EMAIL_FROM = const WELCOME_EMAIL_FROM =
process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>"; process.env.RESEND_FROM_EMAIL || "MusicLand <musicland@minuit.app>";
const WELCOME_EMAIL_SUBJECT = "Bienvenue sur 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) => { exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
try { try {
const { email = "", firstName = "", lastName = "" } = event?.data?.data(); const { email = "", firstName = "", lastName = "" } = event?.data?.data();
+3
View File
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
// Inject minimal global CSS and a small setNativeProps polyfill for web // Inject minimal global CSS and a small setNativeProps polyfill for web
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement?.setAttribute('translate', 'no');
document.body?.setAttribute('translate', 'no');
const style = document.createElement('style'); const style = document.createElement('style');
style.setAttribute('data-inline-global', 'true'); style.setAttribute('data-inline-global', 'true');
style.innerHTML = ` style.innerHTML = `
+28
View File
@@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts"; import { FONT_FAMILY } from "../../styles/Fonts";
import { openShareSheet } from "../../utils/shareSheet";
import AppActionSheet from "../AppActionSheet"; import AppActionSheet from "../AppActionSheet";
const OptionButton = ({ label, onPress, disabled = false }) => ( const OptionButton = ({ label, onPress, disabled = false }) => (
@@ -40,6 +41,9 @@ const MusicOptionsModal = ({ payload }) => {
projectId = null, projectId = null,
ownerId = null, ownerId = null,
title = null, title = null,
onShare,
sharePayload = null,
shareDisabled = false,
} = payload || {}; } = payload || {};
const trigger = useCallback((action) => { const trigger = useCallback((action) => {
@@ -97,6 +101,23 @@ const MusicOptionsModal = ({ payload }) => {
trigger(run); trigger(run);
}, [downloadDisabled, onDownload, trigger]); }, [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 ( return (
<AppActionSheet id="MusicOptions"> <AppActionSheet id="MusicOptions">
<View style={{ gap: 20 }}> <View style={{ gap: 20 }}>
@@ -116,6 +137,13 @@ const MusicOptionsModal = ({ payload }) => {
label="Ajouter à une playlist" label="Ajouter à une playlist"
onPress={handleAddToPlaylist} onPress={handleAddToPlaylist}
/> />
{shouldShowShareOption && (
<OptionButton
label="Partager la musique"
onPress={handleShare}
disabled={shareDisabled}
/>
)}
<OptionButton <OptionButton
label="Télécharger" label="Télécharger"
onPress={handleDownload} onPress={handleDownload}
+22
View File
@@ -38,6 +38,10 @@ import {
getSegmentMeta, getSegmentMeta,
normalizeStructureType, normalizeStructureType,
} from "../../utils/songStructure"; } from "../../utils/songStructure";
import {
createMusicSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
// 20 secondes // 20 secondes
const timeBeforeIncrement = 20000; const timeBeforeIncrement = 20000;
@@ -108,6 +112,19 @@ const MusicDetails = ({ route }) => {
}; };
}, [trackId, songUrl, title, artist, coverUrl, projectId]); }, [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(() => { const handleReport = useCallback(() => {
if (!projectId) return; if (!projectId) return;
SheetManager.show("Report", { SheetManager.show("Report", {
@@ -147,15 +164,20 @@ const MusicDetails = ({ route }) => {
onAddToPlaylist: handleAddToPlaylist, onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload, onDownload: handleDownload,
downloadDisabled: !songUrl, downloadDisabled: !songUrl,
onShare: sharePayload ? handleShare : null,
sharePayload,
shareDisabled: !sharePayload,
}, },
}); });
}, [ }, [
handleAddToPlaylist, handleAddToPlaylist,
handleDownload, handleDownload,
handleReport, handleReport,
handleShare,
owner?.id, owner?.id,
project?.userId, project?.userId,
projectId, projectId,
sharePayload,
songUrl, songUrl,
title, title,
]); ]);
+5
View File
@@ -243,16 +243,21 @@ const MusicDetails = ({ route }) => {
onAddToPlaylist: handleAddToPlaylist, onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload, onDownload: handleDownload,
downloadDisabled: isDownloading || !songUrl, downloadDisabled: isDownloading || !songUrl,
onShare: sharePayload ? handleShare : null,
sharePayload,
shareDisabled: !sharePayload,
}, },
}); });
}, [ }, [
handleAddToPlaylist, handleAddToPlaylist,
handleDownload, handleDownload,
handleReport, handleReport,
handleShare,
isDownloading, isDownloading,
owner?.id, owner?.id,
project?.userId, project?.userId,
projectId, projectId,
sharePayload,
songUrl, songUrl,
title, title,
]); ]);