diff --git a/.eslintrc.js b/.eslintrc.js index a11ee9e..7c52fcb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,12 @@ module.exports = { - extends: ["expo", "prettier"], - plugins: ["prettier"], + extends: ['expo', 'prettier'], + plugins: ['prettier'], rules: { - "prettier/prettier": [ - "error", + 'prettier/prettier': [ + 'error', { singleQuote: false, }, ], }, -}; +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..35bc0da --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +dist +build +vendor +coverage +.next +.out +public/build diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2924079 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "es5" +} diff --git a/App.js b/App.js index 47d7762..653bf5e 100644 --- a/App.js +++ b/App.js @@ -1,28 +1,22 @@ -import "@expo/metro-runtime"; -import { PortalProvider } from "@gorhom/portal"; -import { DefaultTheme, NavigationContainer } from "@react-navigation/native"; -import { useFonts } from "expo-font"; -import * as SplashScreen from "expo-splash-screen"; -import { StatusBar } from "expo-status-bar"; -import moment from "moment"; -import "moment/locale/fr"; -import { Platform, Text, TextInput } from "react-native"; -import { GestureHandlerRootView } from "react-native-gesture-handler"; -import React, { - setGlobal, - useCallback, - useEffect, - useGlobal, - useState, -} from "reactn"; +import '@expo/metro-runtime' +import { PortalProvider } from '@gorhom/portal' +import { DefaultTheme, NavigationContainer } from '@react-navigation/native' +import { useFonts } from 'expo-font' +import * as SplashScreen from 'expo-splash-screen' +import { StatusBar } from 'expo-status-bar' +import moment from 'moment' +import 'moment/locale/fr' +import { Platform, Text, TextInput } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import React, { setGlobal, useCallback, useEffect, useGlobal, useState } from 'reactn' -import { MainStack, Routes } from "./src/navigation"; -import { Palette } from "./src/styles"; +import { MainStack, Routes } from './src/navigation' +import { Palette } from './src/styles' -import { navigationRef, reset } from "./src/navigation/NavigationService"; +import { navigationRef, reset } from './src/navigation/NavigationService' -import { MenuProvider } from "react-native-popup-menu"; -import initialGlobalState from "./src/config/initialGlobalState"; +import { MenuProvider } from 'react-native-popup-menu' +import initialGlobalState from './src/config/initialGlobalState' import { Inter_400Regular, @@ -30,143 +24,143 @@ import { Inter_500Medium, Inter_600SemiBold, Inter_700Bold, -} from "@expo-google-fonts/inter"; -import firebase from "./src/config/firebase"; -import Providers from "./src/providers"; +} from '@expo-google-fonts/inter' +import firebase from './src/config/firebase' +import Providers from './src/providers' -import { LogBox } from "react-native"; -import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet"; -import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer"; -import AppDownloadBanner from "./src/components/AppDownloadBanner"; -import MobileSplashVideo from "./src/components/MobileSplashVideo"; -import "./src/utils/Sheet"; +import { LogBox } from 'react-native' +import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet' +import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer' +import AppDownloadBanner from './src/components/AppDownloadBanner' +import MobileSplashVideo from './src/components/MobileSplashVideo' +import './src/utils/Sheet' -console.disableYellowBox = true; -console.reportErrorsAsExceptions = false; +console.disableYellowBox = true +console.reportErrorsAsExceptions = false -moment.locale("fr"); +moment.locale('fr') // Keep typography stable regardless of the user's system font scaling on mobile -if (Platform.OS !== "web") { +if (Platform.OS !== 'web') { if (Text.defaultProps == null) { - Text.defaultProps = {}; + Text.defaultProps = {} } if (TextInput.defaultProps == null) { - TextInput.defaultProps = {}; + TextInput.defaultProps = {} } - Text.defaultProps.allowFontScaling = false; - TextInput.defaultProps.allowFontScaling = false; + Text.defaultProps.allowFontScaling = false + TextInput.defaultProps.allowFontScaling = false } -setGlobal(initialGlobalState); +setGlobal(initialGlobalState) -SplashScreen.preventAutoHideAsync(); +SplashScreen.preventAutoHideAsync() const App = () => { - const [, setCurrentUID] = useGlobal("currentUID"); - const [, setCurrentUserRoles] = useGlobal("currentUserRoles"); + const [, setCurrentUID] = useGlobal('currentUID') + const [, setCurrentUserRoles] = useGlobal('currentUserRoles') - const [appIsReady, setAppIsReady] = useState(false); + const [appIsReady, setAppIsReady] = useState(false) - const [isInitializing, setInitializing] = useState(true); + const [isInitializing, setInitializing] = useState(true) - const routeNameRef = React.useRef(null); + const routeNameRef = React.useRef(null) const syncActiveRoute = useCallback(() => { - const currentRoute = navigationRef.current?.getCurrentRoute(); - const currentName = currentRoute?.name ?? null; + const currentRoute = navigationRef.current?.getCurrentRoute() + const currentName = currentRoute?.name ?? null if (routeNameRef.current !== currentName) { - routeNameRef.current = currentName; - setGlobal({ activeRouteName: currentName }); + routeNameRef.current = currentName + setGlobal({ activeRouteName: currentName }) } - }, [setGlobal]); + }, [setGlobal]) const handleNavigationReady = useCallback(() => { - syncActiveRoute(); - }, [syncActiveRoute]); + syncActiveRoute() + }, [syncActiveRoute]) const handleNavigationStateChange = useCallback(() => { - syncActiveRoute(); - }, [syncActiveRoute]); + syncActiveRoute() + }, [syncActiveRoute]) const [loaded] = useFonts({ - NewYorkSemibold: require("./src/assets/fonts/NewYork-Semibold.ttf"), - OpenSansRegular: require("./src/assets/fonts/OpenSans-Regular.ttf"), + NewYorkSemibold: require('./src/assets/fonts/NewYork-Semibold.ttf'), + OpenSansRegular: require('./src/assets/fonts/OpenSans-Regular.ttf'), InterRegular: Inter_400Regular, InterMedium: Inter_500Medium, InterSemiBold: Inter_600SemiBold, InterBold: Inter_700Bold, InterRegularItalic: Inter_400Regular_Italic, - HelveticaNeueRegular: require("./src/assets/fonts/HelveticaNeueRegular.ttf"), - HelveticaNeueMedium: require("./src/assets/fonts/HelveticaNeueMedium.ttf"), - HelveticaNeueBold: require("./src/assets/fonts/HelveticaNeueBold.ttf"), - OwnersRegular: require("./src/assets/fonts/OwnersRegular.ttf"), - OwnersMedium: require("./src/assets/fonts/OwnersMedium.ttf"), - OwnersBold: require("./src/assets/fonts/OwnersBold.ttf"), - }); + HelveticaNeueRegular: require('./src/assets/fonts/HelveticaNeueRegular.ttf'), + HelveticaNeueMedium: require('./src/assets/fonts/HelveticaNeueMedium.ttf'), + HelveticaNeueBold: require('./src/assets/fonts/HelveticaNeueBold.ttf'), + OwnersRegular: require('./src/assets/fonts/OwnersRegular.ttf'), + OwnersMedium: require('./src/assets/fonts/OwnersMedium.ttf'), + OwnersBold: require('./src/assets/fonts/OwnersBold.ttf'), + }) useEffect(() => { if (loaded) { - setAppIsReady(true); - LogBox.ignoreAllLogs(); + setAppIsReady(true) + LogBox.ignoreAllLogs() } - }, [loaded]); + }, [loaded]) const onLayoutRootView = useCallback(async () => { if (appIsReady) { - await SplashScreen.hideAsync(); + await SplashScreen.hideAsync() } - }, [appIsReady, loaded]); + }, [appIsReady, loaded]) useEffect(() => { - const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged); - return subscriber; - }, []); + const subscriber = firebase.auth().onAuthStateChanged(onAuthStateChanged) + return subscriber + }, []) useEffect(() => { - if (Platform.OS === "web") { - const s = document.createElement("script"); - s.src = "https://minuit.app/embed/report-a-problem.js"; - s.async = true; - s.setAttribute("data-project-id", "playbackproduction"); - s.setAttribute("data-position", "right"); - s.setAttribute("data-offset", "16"); - s.setAttribute("data-primary-color", "#FB68A8"); - s.setAttribute("data-bg-primary-color", "#0E0E12"); - s.setAttribute("data-bg-secondary-color", "#1D1825"); - s.setAttribute("data-text-color", "#F3F0F5"); + if (Platform.OS === 'web') { + const s = document.createElement('script') + s.src = 'https://minuit.app/embed/report-a-problem.js' + s.async = true + s.setAttribute('data-project-id', 'playbackproduction') + s.setAttribute('data-position', 'right') + s.setAttribute('data-offset', '16') + s.setAttribute('data-primary-color', '#FB68A8') + s.setAttribute('data-bg-primary-color', '#0E0E12') + s.setAttribute('data-bg-secondary-color', '#1D1825') + s.setAttribute('data-text-color', '#F3F0F5') - document.body.appendChild(s); + document.body.appendChild(s) return () => { try { - s.remove(); - } catch (_) { } - }; + s.remove() + } catch (_) {} + } } - }, []); + }, []) const onAuthStateChanged = async (user) => { if (isInitializing) { - setInitializing(false); + setInitializing(false) } if (user?.uid) { - setCurrentUID(user.uid); + setCurrentUID(user.uid) - const idTokenResult = await user.getIdTokenResult(); - setCurrentUserRoles(idTokenResult?.claims?.roles || []); + const idTokenResult = await user.getIdTokenResult() + setCurrentUserRoles(idTokenResult?.claims?.roles || []) } else { - setCurrentUID(null); - setGlobal(initialGlobalState); + setCurrentUID(null) + setGlobal(initialGlobalState) reset({ index: 0, routes: [{ name: Routes.Splash }], - }); + }) } - }; + } if (!loaded || isInitializing) { - return null; + return null } return ( <> @@ -177,8 +171,7 @@ const App = () => { style={[ { flex: 1, - backgroundColor: - Platform.OS === "web" ? "transparent" : Palette.darkPurple, + backgroundColor: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple, }, ]} > @@ -191,10 +184,7 @@ const App = () => { ...DefaultTheme, colors: { ...DefaultTheme.colors, - background: - Platform.OS === "web" - ? "transparent" - : Palette.darkPurple, + background: Platform.OS === 'web' ? 'transparent' : Palette.darkPurple, }, }} ref={navigationRef} @@ -202,12 +192,10 @@ const App = () => { onStateChange={handleNavigationStateChange} documentTitle={{ formatter: (options) => - options?.title - ? `${options?.title} - MusicLand` - : "MusicLand", + options?.title ? `${options?.title} - MusicLand` : 'MusicLand', }} linking={{ - prefixes: ["musicland://", "https://musicland-one.vercel.app"], + prefixes: ['musicland://', 'https://musicland-one.vercel.app'], config: { screens: { // Add any deep link configurations here if needed @@ -227,7 +215,7 @@ const App = () => { - ); -}; + ) +} -export default App; +export default App diff --git a/babel.config.js b/babel.config.js index 305e0b7..b75bdec 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,10 +1,7 @@ module.exports = function (api) { - api.cache(true); + api.cache(true) return { - presets: ["babel-preset-expo"], - plugins: [ - "@babel/plugin-proposal-export-namespace-from", - "react-native-reanimated/plugin", - ], - }; -}; \ No newline at end of file + presets: ['babel-preset-expo'], + plugins: ['@babel/plugin-proposal-export-namespace-from', 'react-native-reanimated/plugin'], + } +} diff --git a/cleanProject.js b/cleanProject.js index ee02034..37a01a1 100644 --- a/cleanProject.js +++ b/cleanProject.js @@ -1,69 +1,69 @@ -const fs = require("fs"); -const path = require("path"); -const parser = require("@babel/parser"); -const traverse = require("@babel/traverse").default; +const fs = require('fs') +const path = require('path') +const parser = require('@babel/parser') +const traverse = require('@babel/traverse').default -const entryFile = path.resolve(__dirname, "index.js"); -const srcDir = path.resolve(__dirname, "src"); +const entryFile = path.resolve(__dirname, 'index.js') +const srcDir = path.resolve(__dirname, 'src') // Extensions de fichiers à analyser -const scriptExtensions = [".js", ".jsx", ".ts", ".tsx", ".web.js"]; -const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".svg"]; +const scriptExtensions = ['.js', '.jsx', '.ts', '.tsx', '.web.js'] +const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg'] // Fonction pour obtenir tous les fichiers dans un dossier avec des extensions spécifiques function getAllFiles(dir, extensions, fileList = []) { - const files = fs.readdirSync(dir); + const files = fs.readdirSync(dir) files.forEach(function (file) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); + const filePath = path.join(dir, file) + const stat = fs.statSync(filePath) if (stat.isDirectory()) { - getAllFiles(filePath, extensions, fileList); + getAllFiles(filePath, extensions, fileList) } else if (extensions.includes(path.extname(file))) { - fileList.push(filePath); + fileList.push(filePath) } - }); - return fileList; + }) + return fileList } // Vérifie si un chemin est un fichier valide function isValidFile(filePath) { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + return fs.existsSync(filePath) && fs.statSync(filePath).isFile() } // Résout le chemin de l'import en un fichier valide function resolveImport(filePath, importPath) { - let importedFile = path.resolve(path.dirname(filePath), importPath); + let importedFile = path.resolve(path.dirname(filePath), importPath) // Liste des extensions à tester, y compris .web.js - const allExtensions = scriptExtensions; + const allExtensions = scriptExtensions // Si le chemin n'a pas d'extension, essayer avec différentes extensions if (!path.extname(importedFile)) { for (const ext of allExtensions) { if (isValidFile(importedFile + ext)) { - return importedFile + ext; + return importedFile + ext } } } // Si un fichier avec l'extension actuelle existe, le retourner if (isValidFile(importedFile)) { - return importedFile; + return importedFile } // Si le chemin est un répertoire, chercher un index avec les extensions if (fs.existsSync(importedFile) && fs.statSync(importedFile).isDirectory()) { - const indexFiles = allExtensions.map((ext) => "index" + ext); + const indexFiles = allExtensions.map((ext) => 'index' + ext) for (const indexFile of indexFiles) { - const indexFilePath = path.join(importedFile, indexFile); + const indexFilePath = path.join(importedFile, indexFile) if (isValidFile(indexFilePath)) { - return indexFilePath; + return indexFilePath } } } // Aucun fichier valide trouvé - return null; + return null } // Analyse des fichiers pour trouver les imports @@ -74,178 +74,136 @@ function getDependencies( usedFiles = new Set(), usedAssets = new Set() ) { - if (visitedFiles.has(filePath)) - return { usedDependencies, usedFiles, usedAssets }; - visitedFiles.add(filePath); - usedFiles.add(filePath); + if (visitedFiles.has(filePath)) return { usedDependencies, usedFiles, usedAssets } + visitedFiles.add(filePath) + usedFiles.add(filePath) - const content = fs.readFileSync(filePath, "utf-8"); - let ast; + const content = fs.readFileSync(filePath, 'utf-8') + let ast try { ast = parser.parse(content, { - sourceType: "module", - plugins: ["jsx", "typescript", "classProperties", "dynamicImport"], - }); + sourceType: 'module', + plugins: ['jsx', 'typescript', 'classProperties', 'dynamicImport'], + }) } catch (error) { - console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error); - return { usedDependencies, usedFiles, usedAssets }; + console.error(`Erreur lors de l'analyse du fichier ${filePath}:`, error) + return { usedDependencies, usedFiles, usedAssets } } traverse(ast, { ImportDeclaration({ node }) { - const importPath = node.source.value; - handleImport( - filePath, - importPath, - visitedFiles, - usedDependencies, - usedFiles, - usedAssets - ); + const importPath = node.source.value + handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) }, CallExpression({ node }) { if ( - node.callee.type === "Import" || - (node.callee.name === "require" && node.arguments.length) + node.callee.type === 'Import' || + (node.callee.name === 'require' && node.arguments.length) ) { - const importArg = node.arguments[0]; - if (importArg && importArg.type === "StringLiteral") { - const importPath = importArg.value; - handleImport( - filePath, - importPath, - visitedFiles, - usedDependencies, - usedFiles, - usedAssets - ); + const importArg = node.arguments[0] + if (importArg && importArg.type === 'StringLiteral') { + const importPath = importArg.value + handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) } } }, - }); + }) // Vérifier si un fichier .web.js correspondant existe - if (filePath.endsWith(".js")) { - const webFilePath = filePath.replace(/\.js$/, ".web.js"); + if (filePath.endsWith('.js')) { + const webFilePath = filePath.replace(/\.js$/, '.web.js') if (isValidFile(webFilePath)) { - getDependencies( - webFilePath, - visitedFiles, - usedDependencies, - usedFiles, - usedAssets - ); + getDependencies(webFilePath, visitedFiles, usedDependencies, usedFiles, usedAssets) } } - return { usedDependencies, usedFiles, usedAssets }; + return { usedDependencies, usedFiles, usedAssets } } // Fonction pour gérer les imports -function handleImport( - filePath, - importPath, - visitedFiles, - usedDependencies, - usedFiles, - usedAssets -) { - if (importPath.startsWith(".")) { +function handleImport(filePath, importPath, visitedFiles, usedDependencies, usedFiles, usedAssets) { + if (importPath.startsWith('.')) { // Chemin relatif - const resolvedPath = resolveImport(filePath, importPath); + const resolvedPath = resolveImport(filePath, importPath) if (resolvedPath) { - const ext = path.extname(resolvedPath); + const ext = path.extname(resolvedPath) if (scriptExtensions.includes(ext)) { - getDependencies( - resolvedPath, - visitedFiles, - usedDependencies, - usedFiles, - usedAssets - ); + getDependencies(resolvedPath, visitedFiles, usedDependencies, usedFiles, usedAssets) } else if (imageExtensions.includes(ext)) { - usedAssets.add(resolvedPath); + usedAssets.add(resolvedPath) } } } else { // Dépendance de node_modules - const dep = importPath.split("/")[0]; - usedDependencies.add(dep); + const dep = importPath.split('/')[0] + usedDependencies.add(dep) } } -(async () => { +;(async () => { // Étape 1: Obtenir tous les fichiers utilisés - const allScriptFiles = getAllFiles(srcDir, scriptExtensions); - const allAssetFiles = getAllFiles(srcDir, imageExtensions); + const allScriptFiles = getAllFiles(srcDir, scriptExtensions) + const allAssetFiles = getAllFiles(srcDir, imageExtensions) - const { usedDependencies, usedFiles, usedAssets } = - getDependencies(entryFile); + const { usedDependencies, usedFiles, usedAssets } = getDependencies(entryFile) // Étape 2: Supprimer les fichiers scripts inutilisés - const unusedScriptFiles = allScriptFiles.filter( - (file) => !usedFiles.has(file) - ); + const unusedScriptFiles = allScriptFiles.filter((file) => !usedFiles.has(file)) unusedScriptFiles.forEach((file) => { - fs.unlinkSync(file); - console.log(`Fichier script supprimé: ${file}`); - }); + fs.unlinkSync(file) + console.log(`Fichier script supprimé: ${file}`) + }) // Étape 3: Supprimer les images non utilisées - const unusedAssetFiles = allAssetFiles.filter( - (file) => !usedAssets.has(file) - ); + const unusedAssetFiles = allAssetFiles.filter((file) => !usedAssets.has(file)) unusedAssetFiles.forEach((file) => { - fs.unlinkSync(file); - console.log(`Fichier image supprimé: ${file}`); - }); + fs.unlinkSync(file) + console.log(`Fichier image supprimé: ${file}`) + }) // Étape 4: Optimiser les images utilisées sans perte de qualité async function optimizeImages(usedAssets) { // Import dynamique des modules ES - const imagemin = (await import("imagemin")).default; - const imageminOptipng = (await import("imagemin-optipng")).default; - const imageminJpegtran = (await import("imagemin-jpegtran")).default; - const imageminGifsicle = (await import("imagemin-gifsicle")).default; - const imageminSvgo = (await import("imagemin-svgo")).default; + const imagemin = (await import('imagemin')).default + const imageminOptipng = (await import('imagemin-optipng')).default + const imageminJpegtran = (await import('imagemin-jpegtran')).default + const imageminGifsicle = (await import('imagemin-gifsicle')).default + const imageminSvgo = (await import('imagemin-svgo')).default for (const file of usedAssets) { - const ext = path.extname(file).toLowerCase(); - const plugins = []; + const ext = path.extname(file).toLowerCase() + const plugins = [] - if (ext === ".png") { - plugins.push(imageminOptipng({ optimizationLevel: 3 })); - } else if (ext === ".jpg" || ext === ".jpeg") { - plugins.push(imageminJpegtran({ progressive: true })); - } else if (ext === ".gif") { - plugins.push(imageminGifsicle({ optimizationLevel: 3 })); - } else if (ext === ".svg") { - plugins.push(imageminSvgo()); + if (ext === '.png') { + plugins.push(imageminOptipng({ optimizationLevel: 3 })) + } else if (ext === '.jpg' || ext === '.jpeg') { + plugins.push(imageminJpegtran({ progressive: true })) + } else if (ext === '.gif') { + plugins.push(imageminGifsicle({ optimizationLevel: 3 })) + } else if (ext === '.svg') { + plugins.push(imageminSvgo()) } else { - continue; + continue } try { const optimized = await imagemin([file], { destination: path.dirname(file), plugins: plugins, - }); + }) if (optimized && optimized.length > 0) { - console.log(`Image optimisée: ${file}`); + console.log(`Image optimisée: ${file}`) } else { - console.log(`Image déjà optimisée ou non optimisable: ${file}`); + console.log(`Image déjà optimisée ou non optimisable: ${file}`) } } catch (error) { - console.error( - `Erreur lors de l'optimisation de l'image ${file}:`, - error - ); + console.error(`Erreur lors de l'optimisation de l'image ${file}:`, error) } } } - await optimizeImages(usedAssets); -})(); + await optimizeImages(usedAssets) +})() diff --git a/functions/config/keys.js b/functions/config/keys.js index dc5bd77..e54df06 100644 --- a/functions/config/keys.js +++ b/functions/config/keys.js @@ -1,10 +1,10 @@ -exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; +exports.GEMINI_API_KEY = 'AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y' -exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key +exports.SUNO_API_KEY = 'c1636e04f606811511e19ec6e1545aa6' // api key -exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu"; +exports.RESEND_API_KEY = 're_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu' exports.STRIPE_SECRET_KEY = - "sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu"; -exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW"; -exports.STRIPE_RETURN_URL = ""; + 'sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu' +exports.STRIPE_WEBHOOK_SECRET = 'whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW' +exports.STRIPE_RETURN_URL = '' diff --git a/functions/config/suno.js b/functions/config/suno.js index 5cb402c..a0b9e04 100644 --- a/functions/config/suno.js +++ b/functions/config/suno.js @@ -1,8 +1,7 @@ -exports.SUNO_API_BASE = "https://api.sunoapi.org"; -exports.SUNO_API_PATH = "/api/v1/generate"; -exports.SUNO_STATUS_PATH = "/api/v1/generate/record-info"; -exports.SUNO_TIMESTAMPED_LYRICS_PATH = - "/api/v1/generate/get-timestamped-lyrics"; -exports.SUNO_MODEL = "V5"; +exports.SUNO_API_BASE = 'https://api.sunoapi.org' +exports.SUNO_API_PATH = '/api/v1/generate' +exports.SUNO_STATUS_PATH = '/api/v1/generate/record-info' +exports.SUNO_TIMESTAMPED_LYRICS_PATH = '/api/v1/generate/get-timestamped-lyrics' +exports.SUNO_MODEL = 'V5' exports.SUNO_CALLBACK_URL = - "https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback"; + 'https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback' diff --git a/functions/config/types.js b/functions/config/types.js index 53eed69..17e6cc4 100644 --- a/functions/config/types.js +++ b/functions/config/types.js @@ -1,5 +1,5 @@ exports.BATCH_TYPE = { - ADD: "ADD", - UPDATE: "UPDATE", - DELETE: "DELETE", -}; + ADD: 'ADD', + UPDATE: 'UPDATE', + DELETE: 'DELETE', +} diff --git a/functions/helpers/email.js b/functions/helpers/email.js index 8617609..3934669 100644 --- a/functions/helpers/email.js +++ b/functions/helpers/email.js @@ -1,18 +1,17 @@ -const fs = require("fs"); -const path = require("path"); +const fs = require('fs') +const path = require('path') -const musicLandLogoBase64 = fs.readFileSync( - path.join(__dirname, "../assets/musicLandLogo.png"), - { encoding: "base64" }, -); -const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}`; +const musicLandLogoBase64 = fs.readFileSync(path.join(__dirname, '../assets/musicLandLogo.png'), { + encoding: 'base64', +}) +const musicLandLogoSrc = `data:image/png;base64,${musicLandLogoBase64}` -function basicTemplate({ title = "", content = "", button = null }) { +function basicTemplate({ title = '', content = '', button = null }) { const btn = button?.url ? `

${ - button?.label || "Ouvrir" + button?.label || 'Ouvrir' }

` - : ""; + : '' return ` @@ -50,12 +49,12 @@ function basicTemplate({ title = "", content = "", button = null }) { - `; + ` } -function welcomeTemplate({ firstName = "", lastName = "" }) { - const fullName = [firstName, lastName].filter(Boolean).join(" ").trim(); - const greeting = fullName ? `Salut ${fullName},` : "Salut,"; +function welcomeTemplate({ firstName = '', lastName = '' }) { + const fullName = [firstName, lastName].filter(Boolean).join(' ').trim() + const greeting = fullName ? `Salut ${fullName},` : 'Salut,' return ` @@ -104,8 +103,8 @@ function welcomeTemplate({ firstName = "", lastName = "" }) { - `; + ` } -exports.basicTemplate = basicTemplate; -exports.welcomeTemplate = welcomeTemplate; +exports.basicTemplate = basicTemplate +exports.welcomeTemplate = welcomeTemplate diff --git a/functions/helpers/firebase.js b/functions/helpers/firebase.js index 2d410a1..b871bc0 100644 --- a/functions/helpers/firebase.js +++ b/functions/helpers/firebase.js @@ -1,88 +1,82 @@ -const admin = require("firebase-admin"); -const { BATCH_TYPE } = require("../config/types"); +const admin = require('firebase-admin') +const { BATCH_TYPE } = require('../config/types') async function deleteFolder(path) { try { - console.log(`Deleting folder: ${path}`); - const bucket = admin.storage().bucket(); - await bucket.deleteFiles({ prefix: path, force: true }); - console.log(`Folder ${path} deleted successfully`); + console.log(`Deleting folder: ${path}`) + const bucket = admin.storage().bucket() + await bucket.deleteFiles({ prefix: path, force: true }) + console.log(`Folder ${path} deleted successfully`) } catch (e) { - console.log(e); + console.log(e) } } async function batchFirestore({ - path = "", // ADD only + 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; + 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; + 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); + 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 && typeof doc.data === 'object' && doc.data !== null && !Array.isArray(doc.data) ? doc.data - : 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); + 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."); + 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."); + throw new Error('batchFirestore UPDATE nécessite des données à écrire.') } - currentBatch.set(ref, payload, { merge: true }); + 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."); + throw new Error('batchFirestore DELETE nécessite une référence de document.') } - currentBatch.delete(ref); + currentBatch.delete(ref) } else { - throw new Error(`Opération non supportée : ${type}`); + throw new Error(`Opération non supportée : ${type}`) } - operationCounter++; + 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; + 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); + batches.push(currentBatch) } // Exécuter tous les lots en parallèle - await Promise.all(batches.map((batch) => batch.commit())); + await Promise.all(batches.map((batch) => batch.commit())) } catch (error) { - console.error( - `Erreur lors des opérations ${type} pour la collection ${path} :`, - error, - ); + console.error(`Erreur lors des opérations ${type} pour la collection ${path} :`, error) } } -exports.batchFirestore = batchFirestore; -exports.deleteFolder = deleteFolder; +exports.batchFirestore = batchFirestore +exports.deleteFolder = deleteFolder diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index c3d367e..cb3cd2c 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -1,44 +1,42 @@ -const { googleAI } = require("@genkit-ai/googleai"); -const { genkit, z } = require("genkit"); -const { GEMINI_API_KEY } = require("../config/keys"); -const admin = require("firebase-admin"); -const { Buffer } = require("buffer"); -const { setTimeout } = require("timers/promises"); +const { googleAI } = require('@genkit-ai/googleai') +const { genkit, z } = require('genkit') +const { GEMINI_API_KEY } = require('../config/keys') +const admin = require('firebase-admin') +const { Buffer } = require('buffer') +const { setTimeout } = require('timers/promises') // --- CONFIGURATION --- // Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. -const TEXT_MODEL_NAME = "gemini-3-pro-preview"; -const IMAGE_MODEL_NAME = "gemini-3-pro-image-preview"; +const TEXT_MODEL_NAME = 'gemini-3-pro-preview' +const IMAGE_MODEL_NAME = 'gemini-3-pro-image-preview' // --- SINGLETON PATTERN (WARM START) --- // On stocke l'instance en dehors de la fonction pour la réutiliser // entre les invocations si le conteneur est "chaud". -let aiInstance = null; +let aiInstance = null const getAiInstance = () => { if (!aiInstance) { - console.log("⚡ [Gemini] Initialisation froide (Cold Start)"); + console.log('⚡ [Gemini] Initialisation froide (Cold Start)') aiInstance = genkit({ plugins: [googleAI({ apiKey: GEMINI_API_KEY })], - }); + }) } - return aiInstance; -}; + return aiInstance +} /** * Génération de texte générique */ -exports.generateAI = async ({ system = "", prompt = "", schema }) => { - const ai = getAiInstance(); // Récupère l'instance singleton +exports.generateAI = async ({ system = '', prompt = '', schema }) => { + const ai = getAiInstance() // Récupère l'instance singleton if (prompt?.length < 1) { - throw new Error( - "Vous devez spécifier un prompt pour effectuer cette action.", - ); + throw new Error('Vous devez spécifier un prompt pour effectuer cette action.') } - console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`); - const startedAt = Date.now(); + console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`) + const startedAt = Date.now() try { const { output } = await ai.generate({ @@ -49,76 +47,68 @@ exports.generateAI = async ({ system = "", prompt = "", schema }) => { config: { temperature: 0.7, // Créativité équilibrée }, - }); + }) - return output; + return output } catch (error) { - console.error("❌ [generateAI] Error:", error.message); - throw error; + console.error('❌ [generateAI] Error:', error.message) + throw error } finally { - console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`); + console.log(`⏱️ [generateAI] Durée: ${Date.now() - startedAt}ms`) } -}; +} /** * Analyse la toxicité des paroles. * Utilise gemini-1.5-flash-002 avec des réglages permissifs pour l'analyse. */ -exports.analyseLyrics = async ({ title = "", lyrics }) => { - const ai = getAiInstance(); +exports.analyseLyrics = async ({ title = '', lyrics }) => { + const ai = getAiInstance() // --- Normalisation --- const normalizeLyrics = (raw) => { - if (!raw) return ""; - if (typeof raw === "string") return raw; + if (!raw) return '' + if (typeof raw === 'string') return raw if (Array.isArray(raw)) { return raw .map((s) => { - if (!s) return ""; - const label = s.type ? String(s.type).toUpperCase() : "SECTION"; - return `[${label}]\n${s.lyrics || ""}`; + if (!s) return '' + const label = s.type ? String(s.type).toUpperCase() : 'SECTION' + return `[${label}]\n${s.lyrics || ''}` }) .filter(Boolean) - .join("\n\n"); + .join('\n\n') } - if (typeof raw === "object") { - const parts = []; - if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`); - if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`); - return parts.join("\n\n"); + if (typeof raw === 'object') { + const parts = [] + if (raw.couplet) parts.push(`[COUPLET]\n${raw.couplet}`) + if (raw.refrain) parts.push(`[REFRAIN]\n${raw.refrain}`) + return parts.join('\n\n') } - return String(raw || ""); - }; + return String(raw || '') + } - const lyricsText = normalizeLyrics(lyrics).trim(); - if (!lyricsText) throw new Error("analyseLyrics: paroles requises."); + const lyricsText = normalizeLyrics(lyrics).trim() + if (!lyricsText) throw new Error('analyseLyrics: paroles requises.') // --- Schéma --- const moderationSchema = z.object({ - title: z.string().describe("Titre analysé"), - flagged: z - .boolean() - .describe("Vrai si le contenu nécessite un avertissement."), - blocked: z - .boolean() - .describe("Vrai UNIQUEMENT si violation grave (Haine, Violence réelle)."), - score: z - .number() - .min(0) - .max(1) - .describe("Score de risque (0=Sûr, 1=Dangereux)."), - reasons: z.array(z.string()).describe("Liste concise des raisons."), + title: z.string().describe('Titre analysé'), + flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'), + blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'), + score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'), + reasons: z.array(z.string()).describe('Liste concise des raisons.'), excerpts: z .array( z.object({ quote: z.string(), category: z.string(), severity: z.string(), - }), + }) ) .max(10), success: z.boolean(), - }); + }) // --- Prompt --- const system = `Tu es un Expert en Modération de Contenu Musical (Trust & Safety). @@ -127,20 +117,20 @@ TA MISSION : Distinguer l'expression artistique (même crue/vulgaire) du contenu 1. "FLAGGED" (Avertissement) : Vulgarités, thèmes matures, drogue, sexe consensuel. 2. "BLOCKED" (Interdit) : Discours de haine, harcèlement ciblé, pédopornographie, incitation explicite violence/suicide. -Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.`; +Analyse le CONTEXTE. Une insulte dans un clash de rap est différente d'un appel au meurtre.` const userPrompt = ` ANALYSE CETTE CHANSON : -Titre : ${title || "Inconnu"} +Titre : ${title || 'Inconnu'} PAROLES : """ ${lyricsText} """ -`; +` - console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`); - const startedAt = Date.now(); + console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`) + const startedAt = Date.now() try { const { output } = await ai.generate({ @@ -151,108 +141,103 @@ ${lyricsText} config: { // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu safetySettings: [ - { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, { - category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", - threshold: "BLOCK_NONE", + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'BLOCK_NONE', }, { - category: "HARM_CATEGORY_DANGEROUS_CONTENT", - threshold: "BLOCK_NONE", + category: 'HARM_CATEGORY_DANGEROUS_CONTENT', + threshold: 'BLOCK_NONE', }, - { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }, + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, ], }, - }); + }) - if (!output) throw new Error("Échec de l'analyse de modération."); + if (!output) throw new Error("Échec de l'analyse de modération.") // Correction de cohérence if (output.blocked) { - output.flagged = true; - if (output.score < 0.7) output.score = 0.85; + output.flagged = true + if (output.score < 0.7) output.score = 0.85 } - return output; + return output } finally { - console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`); + console.log(`⏱️ [analyseLyrics] Durée: ${Date.now() - startedAt}ms`) } -}; +} /** * Génération d'image via Imagen 3 */ -exports.generateImageV2 = async (prompt, size = 1024, path = "") => { - const ai = getAiInstance(); +exports.generateImageV2 = async (prompt, size = 1024, path = '') => { + const ai = getAiInstance() - if (typeof prompt !== "string" || prompt.trim().length < 1) { - throw new Error("Prompt requis."); + if (typeof prompt !== 'string' || prompt.trim().length < 1) { + throw new Error('Prompt requis.') } - console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`); - const startedAt = Date.now(); + console.log(`🎨 [generateImageV2] Start (${IMAGE_MODEL_NAME})`) + const startedAt = Date.now() // Optimisation du prompt pour Imagen - let enhancedPrompt = prompt.trim(); - if (!enhancedPrompt.toLowerCase().includes("high quality")) { - enhancedPrompt += ", high quality, detailed, 4k"; + let enhancedPrompt = prompt.trim() + if (!enhancedPrompt.toLowerCase().includes('high quality')) { + enhancedPrompt += ', high quality, detailed, 4k' } // Aspect ratio 1:1 pour les pochettes - enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1`; + enhancedPrompt = `${enhancedPrompt} --aspect-ratio 1:1` - const maxAttempts = 3; - let lastError = null; + const maxAttempts = 3 + let lastError = null try { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - console.log(`🔄 Tentative ${attempt}/${maxAttempts}`); + console.log(`🔄 Tentative ${attempt}/${maxAttempts}`) const response = await ai.generate({ model: googleAI.model(IMAGE_MODEL_NAME), prompt: enhancedPrompt, - }); + }) - const media = response.media; + const media = response.media if (media && media.url) { - console.log("✅ Image générée."); + console.log('✅ Image générée.') // --- Sauvegarde dans Firebase Storage --- - const dataUrl = String(media.url); - const commaIdx = dataUrl.indexOf(","); - const b64 = - commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl; - const buffer = Buffer.from(b64, "base64"); + const dataUrl = String(media.url) + const commaIdx = dataUrl.indexOf(',') + const b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : dataUrl + const buffer = Buffer.from(b64, 'base64') - const bucket = admin.storage().bucket(); - const token = require("crypto").randomUUID(); - const file = bucket.file(path); + const bucket = admin.storage().bucket() + const token = require('crypto').randomUUID() + const file = bucket.file(path) await file.save(buffer, { resumable: false, metadata: { - contentType: media.contentType || "image/png", + contentType: media.contentType || 'image/png', metadata: { firebaseStorageDownloadTokens: token }, }, - }); + }) - return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`; + return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}` } - throw new Error("Pas de média dans la réponse IA."); + throw new Error('Pas de média dans la réponse IA.') } catch (err) { - console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message); - lastError = err; - if (attempt < maxAttempts) await setTimeout(2000 * attempt); + console.warn(`⚠️ Erreur tentative ${attempt}:`, err.message) + lastError = err + if (attempt < maxAttempts) await setTimeout(2000 * attempt) } } - throw new Error( - `Échec final après ${maxAttempts} tentatives: ${lastError?.message}`, - ); + throw new Error(`Échec final après ${maxAttempts} tentatives: ${lastError?.message}`) } finally { - console.log( - `⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`, - ); + console.log(`⏱️ [generateImageV2] Durée totale: ${Date.now() - startedAt}ms`) } -}; +} diff --git a/functions/helpers/prompts.js b/functions/helpers/prompts.js index 48103e6..2feb1e6 100644 --- a/functions/helpers/prompts.js +++ b/functions/helpers/prompts.js @@ -1,137 +1,115 @@ exports.generatePicturePrompt = (project = {}) => { const { - title = "", + title = '', lyrics: lyricsRaw, musicConfig = {}, - coverStyle: coverStyleRaw = "", - artistName: artistNameRaw = "", - } = project || {}; + coverStyle: coverStyleRaw = '', + artistName: artistNameRaw = '', + } = project || {} // --- 1. Nettoyage et Normalisation --- - const sanitizeInline = (value = "") => { - if (typeof value !== "string") return ""; + const sanitizeInline = (value = '') => { + if (typeof value !== 'string') return '' return value - .replace(/[\r\n]+/g, " ") - .replace(/[<>]/g, "") - .trim(); - }; + .replace(/[\r\n]+/g, ' ') + .replace(/[<>]/g, '') + .trim() + } - const titleForPrompt = sanitizeInline(title) || "Sans titre"; - const artistName = - sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || ""); - const hasArtistName = artistName.length > 0; + const titleForPrompt = sanitizeInline(title) || 'Sans titre' + const artistName = sanitizeInline(artistNameRaw) || sanitizeInline(project?.userName || '') + const hasArtistName = artistName.length > 0 - const { - genres = [], - tempo = "", - mood = "", - instruments = [], - } = musicConfig || {}; - const userStyle = sanitizeInline(coverStyleRaw); + const { genres = [], tempo = '', mood = '', instruments = [] } = musicConfig || {} + const userStyle = sanitizeInline(coverStyleRaw) // --- 2. Intelligence Visuelle (Mapping) --- // Détermination de l'énergie visuelle const isEnergetic = - tempo && - /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test( - String(tempo), - ); + tempo && /\b(rapid|fast|vite|agité|upbeat|energ|dance|rock|metal)\b/i.test(String(tempo)) const isDark = - mood && - /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test( - String(mood), - ); + mood && /\b(sombre|triste|dark|sad|mélancoli|nuit|night|eerie)\b/i.test(String(mood)) // Construction de la Palette & Lumière - let visualAtmosphere = ""; + let visualAtmosphere = '' if (isDark) { visualAtmosphere = - "Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents."; + 'Atmosphere: Cinematic atmosphere, moody shadows, deep contrast. Palette: Midnight blue, obsidian, deep purple, metallic accents.' } else if (isEnergetic) { visualAtmosphere = - "Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast."; + 'Atmosphere: Dynamic atmosphere, high energy, vibrant saturation. Palette: Neon colors, electric blue, magenta, bright yellow, high contrast.' } else { visualAtmosphere = - "Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors."; + 'Atmosphere: Soft atmosphere, harmonious and ethereal. Palette: Pastel tones, warm gold, soft coral, balanced and elegant colors.' } // Définition du Style de Rendu (Si l'utilisateur est vague, on renforce) - let renderingStyle = userStyle - ? `Art Style: ${userStyle}` - : "Art Style: Digital Art, Mixed Media"; + let renderingStyle = userStyle ? `Art Style: ${userStyle}` : 'Art Style: Digital Art, Mixed Media' - if ( - userStyle.toLowerCase().includes("realist") || - userStyle.toLowerCase().includes("photo") - ) { + if (userStyle.toLowerCase().includes('realist') || userStyle.toLowerCase().includes('photo')) { renderingStyle += - ", 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing."; + ', 8k resolution, highly detailed texture, photorealistic, cinematic depth of field, raytracing.' } else if ( - userStyle.toLowerCase().includes("illu") || - userStyle.toLowerCase().includes("dessin") + userStyle.toLowerCase().includes('illu') || + userStyle.toLowerCase().includes('dessin') ) { renderingStyle += - ", vector art, clean lines, professional illustration, flat design or detailed painting."; + ', vector art, clean lines, professional illustration, flat design or detailed painting.' } else { // Style par défaut "Album Cover" qui marche bien - renderingStyle += - ", abstract surrealism, conceptual album art, high fidelity, masterpiece."; + renderingStyle += ', abstract surrealism, conceptual album art, high fidelity, masterpiece.' } // --- 3. Extraction de l'Inspiration (Lyrics) --- // On cherche le REFRAIN en priorité pour l'image, car c'est le cœur visuel - const sections = Array.isArray(lyricsRaw) ? lyricsRaw : []; - const chorus = sections.find( - (s) => s.type === "refrain" || s.type === "chorus", - ); - const verse = sections.find( - (s) => s.type === "couplet" || s.type === "verse", - ); + const sections = Array.isArray(lyricsRaw) ? lyricsRaw : [] + const chorus = sections.find((s) => s.type === 'refrain' || s.type === 'chorus') + const verse = sections.find((s) => s.type === 'couplet' || s.type === 'verse') // On prend 2 lignes max du refrain, ou du premier couplet - const visualHook = (chorus?.lyrics || verse?.lyrics || "") - .split("\n") + const visualHook = (chorus?.lyrics || verse?.lyrics || '') + .split('\n') .filter((l) => l.length > 10) // On évite les lignes trop courtes .slice(0, 2) - .join(". "); + .join('. ') const imageryPrompt = visualHook ? `Visual Inspiration: An interpretation of these lyrics: "${visualHook}".` - : "Visual Inspiration: Abstract visual representation of the song's mood."; + : "Visual Inspiration: Abstract visual representation of the song's mood." // --- 4. Construction du Prompt Final (Structure Optimisée Imagen 3) --- const promptParts = [ // Rôle - "Design a professional, high-quality music album cover.", + 'Design a professional, high-quality music album cover.', // 1. Le Texte (Crucial pour Imagen 3 - Doit être au début ou très clair) `**Typography & Text:**`, `The song title "${titleForPrompt}" must be the CENTERPIECE. Write it in a distinct font that matches the mood.`, hasArtistName ? `The artist name "${artistName}" must appear smaller, elegant, and legible near the bottom or top.` - : "", - "Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.", + : '', + 'Ensure perfect spelling. The text should be integrated into the artwork (e.g., metallic texture, neon glow, or bold cut-out), not just pasted on top.', // 2. Le Visuel `**Visuals:**`, renderingStyle, imageryPrompt, - `Subject: A central visual element that represents the song. ${isEnergetic ? "Dynamic composition." : "Balanced, centered composition."}`, + `Subject: A central visual element that represents the song. ${isEnergetic ? 'Dynamic composition.' : 'Balanced, centered composition.'}`, // 3. L'Atmosphère (Context from music config) `**Mood & Color:**`, visualAtmosphere, - genres.length > 0 ? `Musical Vibe Reference: ${genres.join(", ")}.` : "", + genres.length > 0 ? `Musical Vibe Reference: ${genres.join(', ')}.` : '', // 4. Contraintes Négatives (Phrasées positivement pour l'IA) - "**Constraints:**", - "Use a square 1:1 aspect ratio.", - "Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.", - "No blurry text. No messy borders. No watermarks.", - ]; + '**Constraints:**', + 'Use a square 1:1 aspect ratio.', + 'Do NOT depict literal musical instruments (like guitars or microphones) unless they are part of a surreal abstract composition.', + 'No blurry text. No messy borders. No watermarks.', + ] - return promptParts.filter(Boolean).join("\n\n"); -}; + return promptParts.filter(Boolean).join('\n\n') +} diff --git a/functions/helpers/stats.js b/functions/helpers/stats.js index 521e69d..22dfd1d 100644 --- a/functions/helpers/stats.js +++ b/functions/helpers/stats.js @@ -1,26 +1,26 @@ const normalizeDate = (input) => - typeof input?.toDate === "function" ? input.toDate() : new Date(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 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 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 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); + 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, @@ -28,10 +28,10 @@ const buildPreviousMonthContext = (referenceDate) => { monthKey, rangeStart, rangeEnd, - }; -}; + } +} module.exports = { buildMonthKey, buildPreviousMonthContext, -}; +} diff --git a/functions/helpers/stripe.js b/functions/helpers/stripe.js index 8cf4deb..85879dd 100644 --- a/functions/helpers/stripe.js +++ b/functions/helpers/stripe.js @@ -1,281 +1,263 @@ -const admin = require("firebase-admin"); -const { HttpsError } = require("firebase-functions/https"); -const Stripe = require("stripe"); -const { URL } = require("url"); +const admin = require('firebase-admin') +const { HttpsError } = require('firebase-functions/https') +const Stripe = require('stripe') +const { URL } = require('url') const { - STRIPE_SECRET_KEY = "", - STRIPE_RETURN_URL = "", - STRIPE_PORTAL_CONFIGURATION = "", + STRIPE_SECRET_KEY = '', + STRIPE_RETURN_URL = '', + STRIPE_PORTAL_CONFIGURATION = '', STRIPE_MODE: CONFIG_STRIPE_MODE, -} = require("../config/keys"); +} = require('../config/keys') const STRIPE_MODE = - typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim() + typeof CONFIG_STRIPE_MODE === 'string' && CONFIG_STRIPE_MODE.trim() ? CONFIG_STRIPE_MODE.trim() - : "test"; + : 'test' const requireEnv = (key) => { - const value = process.env?.[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); + const value = process.env?.[key] + if (typeof value === 'string' && value.trim()) { + return value.trim() } - throw new Error(`${key} not configured`); -}; + throw new Error(`${key} not configured`) +} -const STRIPE_API_VERSION = "2023-10-16"; -const DEFAULT_TEST_RETURN_URL = "http://localhost:8081"; -const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"]; +const STRIPE_API_VERSION = '2023-10-16' +const DEFAULT_TEST_RETURN_URL = 'http://localhost:8081' +const ALLOWED_RETURN_SCHEMES = ['http', 'https', 'minuit'] -let cachedStripeClient = null; -let cachedPortalConfigurationId = null; +let cachedStripeClient = null +let cachedPortalConfigurationId = null const resolveStripeSecretKey = () => { - const inlineKey = - typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : ""; + const inlineKey = typeof STRIPE_SECRET_KEY === 'string' ? STRIPE_SECRET_KEY.trim() : '' if (inlineKey) { - return inlineKey; + return inlineKey } - const required = requireEnv("STRIPE_SECRET_KEY"); - if (typeof required === "string" && required.trim()) { - return required.trim(); + const required = requireEnv('STRIPE_SECRET_KEY') + if (typeof required === 'string' && required.trim()) { + return required.trim() } - throw new Error("STRIPE_SECRET_KEY not configured"); -}; + throw new Error('STRIPE_SECRET_KEY not configured') +} const getStripeClient = () => { if (cachedStripeClient) { - return cachedStripeClient; + return cachedStripeClient } - let secretKey; + let secretKey try { - secretKey = resolveStripeSecretKey(); + secretKey = resolveStripeSecretKey() } catch (error) { - console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error); + console.error('[getStripeClient] Missing STRIPE_SECRET_KEY', error) throw new HttpsError( - "failed-precondition", - "Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.", - ); + 'failed-precondition', + 'Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.' + ) } cachedStripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION, - }); + }) - return cachedStripeClient; -}; + return cachedStripeClient +} const getReturnBaseUrl = () => { const resolveBase = () => { if (STRIPE_RETURN_URL) { - return STRIPE_RETURN_URL; + return STRIPE_RETURN_URL } - if (STRIPE_MODE !== "prod") { - return DEFAULT_TEST_RETURN_URL; + if (STRIPE_MODE !== 'prod') { + return DEFAULT_TEST_RETURN_URL } - return requireEnv("STRIPE_RETURN_URL"); - }; - - const rawBase = resolveBase(); - const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : ""; - if (!sanitizedBase) { - if (STRIPE_MODE !== "prod") { - return DEFAULT_TEST_RETURN_URL; - } - throw new Error("STRIPE_RETURN_URL not configured"); + return requireEnv('STRIPE_RETURN_URL') } - return sanitizedBase.endsWith("/") - ? sanitizedBase.slice(0, -1) - : sanitizedBase; -}; + const rawBase = resolveBase() + const sanitizedBase = typeof rawBase === 'string' ? rawBase.trim() : '' + if (!sanitizedBase) { + if (STRIPE_MODE !== 'prod') { + return DEFAULT_TEST_RETURN_URL + } + throw new Error('STRIPE_RETURN_URL not configured') + } + + return sanitizedBase.endsWith('/') ? sanitizedBase.slice(0, -1) : sanitizedBase +} const sanitizeReturnUrl = (value) => { - if (typeof value !== "string") { - return null; + if (typeof value !== 'string') { + return null } - const trimmed = value.trim(); + const trimmed = value.trim() if (!trimmed) { - return null; + return null } const validateScheme = (scheme) => { if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) { throw new HttpsError( - "invalid-argument", - `Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`, - ); + 'invalid-argument', + `Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.` + ) } - }; + } try { - const parsedUrl = new URL(trimmed); - const scheme = parsedUrl.protocol.replace(":", "").toLowerCase(); - validateScheme(scheme); - return trimmed; + const parsedUrl = new URL(trimmed) + const scheme = parsedUrl.protocol.replace(':', '').toLowerCase() + validateScheme(scheme) + return trimmed } catch (_error) { - const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i); + const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i) if (schemeMatch && schemeMatch[1]) { - const scheme = schemeMatch[1].toLowerCase(); - validateScheme(scheme); - return trimmed; + const scheme = schemeMatch[1].toLowerCase() + validateScheme(scheme) + return trimmed } - throw new HttpsError( - "invalid-argument", - `URL de retour Stripe invalide: ${trimmed}`, - ); + throw new HttpsError('invalid-argument', `URL de retour Stripe invalide: ${trimmed}`) } -}; +} const getReturnUrls = (overrides) => { - if (overrides && typeof overrides === "object") { - const successOverride = sanitizeReturnUrl(overrides.successUrl); - const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl); + if (overrides && typeof overrides === 'object') { + const successOverride = sanitizeReturnUrl(overrides.successUrl) + const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl) if (!successOverride) { throw new HttpsError( - "invalid-argument", - "successUrl est requis pour configurer les retours Stripe.", - ); + 'invalid-argument', + 'successUrl est requis pour configurer les retours Stripe.' + ) } return { successUrl: successOverride, cancelUrl: cancelOverride || successOverride, - }; + } } - const baseUrl = getReturnBaseUrl(); + const baseUrl = getReturnBaseUrl() const joinPath = (url, path) => { - const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url; - const trimmedPath = path.startsWith("/") ? path.slice(1) : path; - return `${trimmedUrl}/${trimmedPath}`; - }; + const trimmedUrl = url.endsWith('/') ? url.slice(0, -1) : url + const trimmedPath = path.startsWith('/') ? path.slice(1) : path + return `${trimmedUrl}/${trimmedPath}` + } - const appendQuery = (url, query) => - url.includes("?") ? `${url}&${query}` : `${url}?${query}`; + const appendQuery = (url, query) => (url.includes('?') ? `${url}&${query}` : `${url}?${query}`) - const successBase = joinPath(baseUrl, "payment-success"); - const cancelBase = joinPath(baseUrl, "payment-error"); + const successBase = joinPath(baseUrl, 'payment-success') + const cancelBase = joinPath(baseUrl, 'payment-error') return { - successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"), - cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"), - }; -}; + successUrl: appendQuery(successBase, 'session_id={CHECKOUT_SESSION_ID}'), + cancelUrl: appendQuery(cancelBase, 'session_id={CHECKOUT_SESSION_ID}'), + } +} -const normalizeBoolean = (value) => value === true; +const normalizeBoolean = (value) => value === true const buildCheckoutLineItems = async (productList, { stripe } = {}) => { if (!Array.isArray(productList) || productList.length === 0) { throw new HttpsError( - "invalid-argument", - "Au moins un produit est requis pour créer une session de paiement.", - ); + 'invalid-argument', + 'Au moins un produit est requis pour créer une session de paiement.' + ) } - const lineItems = []; - const summary = []; - let hasSubscription = false; + const lineItems = [] + const summary = [] + let hasSubscription = false for (let index = 0; index < productList.length; index += 1) { - const rawItem = productList[index]; - const item = rawItem && typeof rawItem === "object" ? rawItem : {}; + const rawItem = productList[index] + const item = rawItem && typeof rawItem === 'object' ? rawItem : {} - const isRenewable = normalizeBoolean(item.isRenewable); + const isRenewable = normalizeBoolean(item.isRenewable) const rawQuantity = - typeof item.quantity === "number" && Number.isFinite(item.quantity) + typeof item.quantity === 'number' && Number.isFinite(item.quantity) ? item.quantity - : parseInt(item.quantity, 10); - const quantity = - Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1; + : parseInt(item.quantity, 10) + const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1 - const priceId = typeof item.priceID === "string" ? item.priceID.trim() : ""; + const priceId = typeof item.priceID === 'string' ? item.priceID.trim() : '' if (isRenewable && !priceId) { throw new HttpsError( - "invalid-argument", - `Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`, - ); + 'invalid-argument', + `Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).` + ) } if (priceId) { - let stripePrice = null; + let stripePrice = null if (stripe) { try { - stripePrice = await stripe.prices.retrieve(priceId); + stripePrice = await stripe.prices.retrieve(priceId) } catch (error) { - console.error( - "[buildCheckoutLineItems] Unable to retrieve price", - priceId, - error, - ); + console.error('[buildCheckoutLineItems] Unable to retrieve price', priceId, error) throw new HttpsError( - "invalid-argument", - `Le price ID "${priceId}" est introuvable (élément ${index + 1}).`, - ); + 'invalid-argument', + `Le price ID "${priceId}" est introuvable (élément ${index + 1}).` + ) } } - const priceIsRecurring = - stripePrice?.type === "recurring" || !!stripePrice?.recurring; + const priceIsRecurring = stripePrice?.type === 'recurring' || !!stripePrice?.recurring if (isRenewable && !priceIsRecurring) { throw new HttpsError( - "invalid-argument", - `Le price ID "${priceId}" n'est pas compatible avec un abonnement.`, - ); + 'invalid-argument', + `Le price ID "${priceId}" n'est pas compatible avec un abonnement.` + ) } - const resolvedIsRenewable = priceIsRecurring ? true : isRenewable; + const resolvedIsRenewable = priceIsRecurring ? true : isRenewable if (resolvedIsRenewable) { - hasSubscription = true; + hasSubscription = true } lineItems.push({ price: priceId, quantity, - }); + }) summary.push({ - type: "price", + type: 'price', priceID: priceId, quantity, isRenewable: resolvedIsRenewable, - }); - continue; + }) + continue } - const rawUnitAmount = Number(item.unitAmount); - const unitAmount = Math.round(rawUnitAmount); + const rawUnitAmount = Number(item.unitAmount) + const unitAmount = Math.round(rawUnitAmount) if (!Number.isFinite(unitAmount) || unitAmount <= 0) { throw new HttpsError( - "invalid-argument", - `Le montant indiqué pour l'élément ${index + 1} est invalide.`, - ); + 'invalid-argument', + `Le montant indiqué pour l'élément ${index + 1} est invalide.` + ) } - const currency = - typeof item.currency === "string" - ? item.currency.trim().toLowerCase() - : "eur"; + const currency = typeof item.currency === 'string' ? item.currency.trim().toLowerCase() : 'eur' if (!/^[a-z]{3}$/.test(currency)) { throw new HttpsError( - "invalid-argument", - `La devise indiquée pour l'élément ${index + 1} est invalide.`, - ); + 'invalid-argument', + `La devise indiquée pour l'élément ${index + 1} est invalide.` + ) } const label = - typeof item.label === "string" && item.label.trim() - ? item.label.trim() - : "Paiement ponctuel"; + typeof item.label === 'string' && item.label.trim() ? item.label.trim() : 'Paiement ponctuel' lineItems.push({ price_data: { @@ -286,95 +268,84 @@ const buildCheckoutLineItems = async (productList, { stripe } = {}) => { unit_amount: unitAmount, }, quantity, - }); + }) summary.push({ - type: "custom", + type: 'custom', currency, unitAmount, quantity, isRenewable: false, - }); + }) } if (hasSubscription) { - const hasNonSubscription = summary.some((item) => !item.isRenewable); + const hasNonSubscription = summary.some((item) => !item.isRenewable) if (hasNonSubscription) { throw new HttpsError( - "invalid-argument", - "Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.", - ); + 'invalid-argument', + 'Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.' + ) } } - return { lineItems, summary, hasSubscription }; -}; + return { lineItems, summary, hasSubscription } +} -const ensureStripeCustomer = async ({ - uid, - stripe, - refsList, - createIfMissing = true, -}) => { +const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true }) => { if (!uid) { - return { customerId: null, userData: null }; + return { customerId: null, userData: null } } - const userRef = refsList?.users?.doc(uid); - const snapshot = userRef ? await userRef.get() : null; - const userData = snapshot?.exists ? snapshot.data() : null; + const userRef = refsList?.users?.doc(uid) + const snapshot = userRef ? await userRef.get() : null + const userData = snapshot?.exists ? snapshot.data() : null - let customerId = userData?.stripeCustomerId; + let customerId = userData?.stripeCustomerId if (customerId) { - return { customerId, userData }; + return { customerId, userData } } if (!createIfMissing) { - return { customerId: null, userData }; + return { customerId: null, userData } } - let authRecord = null; + let authRecord = null try { - authRecord = await admin.auth().getUser(uid); + authRecord = await admin.auth().getUser(uid) } catch (error) { - console.warn( - "[ensureStripeCustomer] Impossible de récupérer auth user", - error, - ); + console.warn('[ensureStripeCustomer] Impossible de récupérer auth user', error) } - const email = userData?.email || authRecord?.email || undefined; - const nameFromProfile = [userData?.firstName, userData?.lastName] - .filter(Boolean) - .join(" ") - .trim(); - const name = nameFromProfile || authRecord?.displayName || undefined; + const email = userData?.email || authRecord?.email || undefined + const nameFromProfile = [userData?.firstName, userData?.lastName].filter(Boolean).join(' ').trim() + const name = nameFromProfile || authRecord?.displayName || undefined const customer = await stripe.customers.create({ email, name, metadata: { firebaseUID: uid, - appMode: STRIPE_MODE || "test", + appMode: STRIPE_MODE || 'test', }, - }); + }) - customerId = customer.id; + customerId = customer.id if (userRef) { await userRef.set( { stripeCustomerId: customerId, }, - { merge: true }, - ); + { merge: true } + ) } return { customerId, userData: { ...userData, stripeCustomerId: customerId }, - }; -}; + } +} const formatCheckoutSessionResponse = (session) => ({ id: session.id, @@ -391,84 +362,72 @@ const formatCheckoutSessionResponse = (session) => ({ created: session.created, expires_at: session.expires_at, client_secret: session.client_secret || null, -}); +}) const getPortalConfigurationId = async (stripe) => { if (STRIPE_PORTAL_CONFIGURATION) { - return STRIPE_PORTAL_CONFIGURATION; + return STRIPE_PORTAL_CONFIGURATION } if (cachedPortalConfigurationId) { - return cachedPortalConfigurationId; + return cachedPortalConfigurationId } - if ( - !stripe || - typeof stripe.billingPortal?.configurations?.list !== "function" - ) { - throw new HttpsError( - "internal", - "Client Stripe indisponible pour la configuration du portail.", - ); + if (!stripe || typeof stripe.billingPortal?.configurations?.list !== 'function') { + throw new HttpsError('internal', 'Client Stripe indisponible pour la configuration du portail.') } try { const configurations = await stripe.billingPortal.configurations.list({ limit: 100, - }); + }) const defaultConfiguration = configurations.data.find((config) => config.is_default) || - configurations.data.find((config) => config.active); + configurations.data.find((config) => config.active) if (defaultConfiguration?.id) { - cachedPortalConfigurationId = defaultConfiguration.id; - return cachedPortalConfigurationId; + cachedPortalConfigurationId = defaultConfiguration.id + return cachedPortalConfigurationId } } catch (error) { console.warn( - "[getPortalConfigurationId] Impossible de lister les configurations de portail", - error?.message || error, - ); + '[getPortalConfigurationId] Impossible de lister les configurations de portail', + error?.message || error + ) } try { - const defaultReturnUrl = getReturnBaseUrl(); - const createdConfiguration = - await stripe.billingPortal.configurations.create({ - default_return_url: defaultReturnUrl, - business_profile: { - headline: "Minuit Starter", - }, - }); + const defaultReturnUrl = getReturnBaseUrl() + const createdConfiguration = await stripe.billingPortal.configurations.create({ + default_return_url: defaultReturnUrl, + business_profile: { + headline: 'Minuit Starter', + }, + }) if (createdConfiguration?.id) { - cachedPortalConfigurationId = createdConfiguration.id; - return cachedPortalConfigurationId; + cachedPortalConfigurationId = createdConfiguration.id + return cachedPortalConfigurationId } } catch (error) { console.warn( - "[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut", - error?.message || error, - ); + '[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut', + error?.message || error + ) } - return null; -}; + return null +} const mapStripeErrorToHttps = (error, fallbackMessage) => { - const message = - error?.raw?.message || - error?.message || - fallbackMessage || - "Erreur Stripe."; - const statusCode = error?.statusCode || error?.raw?.statusCode; - const isClientError = - typeof statusCode === "number" && statusCode >= 400 && statusCode < 500; + const message = error?.raw?.message || error?.message || fallbackMessage || 'Erreur Stripe.' + const statusCode = error?.statusCode || error?.raw?.statusCode + const isClientError = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500 - const code = isClientError ? "failed-precondition" : "internal"; - return new HttpsError(code, message); -}; + const code = isClientError ? 'failed-precondition' : 'internal' + return new HttpsError(code, message) +} module.exports = { getStripeClient, @@ -479,4 +438,4 @@ module.exports = { formatCheckoutSessionResponse, getPortalConfigurationId, mapStripeErrorToHttps, -}; +} diff --git a/functions/index.js b/functions/index.js index f81e5df..253a26f 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,54 +1,52 @@ -const admin = require("firebase-admin"); +const admin = require('firebase-admin') // Use default credentials/environment provided by Cloud Functions. // Avoid bundling a service account key and hardcoding project/bucket. -admin.initializeApp(); +admin.initializeApp() -const db = admin.firestore(); -const REGION = process.env.FIREBASE_REGION || "europe-west1"; +const db = admin.firestore() +const REGION = process.env.FIREBASE_REGION || 'europe-west1' -exports.db = db; -exports.REGION = REGION; +exports.db = db +exports.REGION = REGION exports.refList = { - 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.refsList = exports.refList; + 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.refsList = exports.refList 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", - CREDITS_UPDATED: "CREDITS_UPDATED", - PAYOUT_AVAILABLE: "PAYOUT_AVAILABLE", -}; + 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', + CREDITS_UPDATED: 'CREDITS_UPDATED', + PAYOUT_AVAILABLE: 'PAYOUT_AVAILABLE', +} // Exporter toutes les fonctions -exports.users = require("./src/users"); -exports.music = require("./src/music"); -exports.lyrics = require("./src/lyrics"); -exports.cover = require("./src/cover"); -exports.projects = require("./src/project"); -exports.thumbnail = require("./src/thumbnail"); -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"); -exports.subscription = require("./src/subscription"); -exports.youtube = require("./src/youtube"); -exports.orders = require("./src/orders"); +exports.users = require('./src/users') +exports.music = require('./src/music') +exports.lyrics = require('./src/lyrics') +exports.cover = require('./src/cover') +exports.projects = require('./src/project') +exports.thumbnail = require('./src/thumbnail') +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') +exports.subscription = require('./src/subscription') +exports.youtube = require('./src/youtube') +exports.orders = require('./src/orders') diff --git a/functions/src/algolia.js b/functions/src/algolia.js index 201294e..322d1cb 100644 --- a/functions/src/algolia.js +++ b/functions/src/algolia.js @@ -1,26 +1,23 @@ -const { onRequest } = require("firebase-functions/v2/https"); +const { onRequest } = require('firebase-functions/v2/https') -exports.algoliaTransformProjectData = onRequest( - { region: "europe-west1" }, - (req, res) => { - const payload = req.body.data; - const objectID = payload.objectID; - try { - const flat = { ...payload }; - delete flat["musicTimestamps"]; +exports.algoliaTransformProjectData = onRequest({ region: 'europe-west1' }, (req, res) => { + const payload = req.body.data + const objectID = payload.objectID + try { + const flat = { ...payload } + delete flat['musicTimestamps'] - console.log(`Change in ${payload.objectID}`); - console.log(flat); - // Ton object final doit contenir "objectID" - const result = { - objectID, - ...flat, - }; - - res.send({ result }); - } catch (e) { - console.log(`Error ${objectID}:`, e.message); - res.status(500).end(); + console.log(`Change in ${payload.objectID}`) + console.log(flat) + // Ton object final doit contenir "objectID" + const result = { + objectID, + ...flat, } - }, -); + + res.send({ result }) + } catch (e) { + console.log(`Error ${objectID}:`, e.message) + res.status(500).end() + } +}) diff --git a/functions/src/cover.js b/functions/src/cover.js index 8690b56..5974634 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -1,98 +1,97 @@ -const { onDocumentCreated } = require("firebase-functions/v2/firestore"); -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const logger = require("firebase-functions/logger"); -const path = require("path"); -const fs = require("fs"); -const axios = require("axios"); -const sharp = require("sharp"); -const crypto = require("crypto"); +const { onDocumentCreated } = require('firebase-functions/v2/firestore') +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const logger = require('firebase-functions/logger') +const path = require('path') +const fs = require('fs') +const axios = require('axios') +const sharp = require('sharp') +const crypto = require('crypto') // Imports internes -const { generateImageV2 } = require("../helpers/gemini"); -const { generatePicturePrompt } = require("../helpers/prompts"); -const { ALERT_TYPE, refList } = require("../index"); -const { sendNotification } = require("./notifications"); +const { generateImageV2 } = require('../helpers/gemini') +const { generatePicturePrompt } = require('../helpers/prompts') +const { ALERT_TYPE, refList } = require('../index') +const { sendNotification } = require('./notifications') // Configuration -const bucket = admin.storage().bucket(); -const LOGO_PATH = path.resolve(__dirname, "../assets/musicLandLogo.png"); +const bucket = admin.storage().bucket() +const LOGO_PATH = path.resolve(__dirname, '../assets/musicLandLogo.png') // Cache mémoire pour le buffer du logo (évite les I/O disque à chaque appel sur instance chaude) -let _cachedLogoBuffer = null; +let _cachedLogoBuffer = null /** * Récupère le buffer du logo depuis le cache ou le disque */ const getLogoBuffer = async () => { - if (_cachedLogoBuffer) return _cachedLogoBuffer; + if (_cachedLogoBuffer) return _cachedLogoBuffer try { - _cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH); - return _cachedLogoBuffer; + _cachedLogoBuffer = await fs.promises.readFile(LOGO_PATH) + return _cachedLogoBuffer } catch (error) { - logger.error("❌ [Cover] Impossible de lire le fichier logo", error); - throw new Error("Asset Logo manquant sur le serveur"); + logger.error('❌ [Cover] Impossible de lire le fichier logo', error) + throw new Error('Asset Logo manquant sur le serveur') } -}; +} /** * Utilitaires Strings */ const pickFirstNonEmpty = (...values) => { for (const value of values) { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() } } - return ""; -}; + return '' +} const combineNames = (...parts) => parts - .map((part) => (typeof part === "string" ? part.trim() : "")) + .map((part) => (typeof part === 'string' ? part.trim() : '')) .filter(Boolean) - .join(" ") - .trim(); + .join(' ') + .trim() /** * Résolution intelligente du nom d'artiste */ async function resolveArtistName(project = {}) { // 1. Vérification directe sur le projet ou le snapshot "owner" - const owner = project?.owner || {}; + const owner = project?.owner || {} const direct = pickFirstNonEmpty( project?.artistName, project?.userName, owner?.artistName, owner?.userName, - owner?.displayName, - ); - if (direct) return direct; + owner?.displayName + ) + if (direct) return direct // 2. Fallback : Récupération depuis la collection Users - const userId = - typeof project?.userId === "string" ? project.userId.trim() : ""; - if (!userId) return ""; + const userId = typeof project?.userId === 'string' ? project.userId.trim() : '' + if (!userId) return '' try { - const userSnapshot = await refList.users.doc(userId).get(); - if (!userSnapshot?.exists) return ""; + const userSnapshot = await refList.users.doc(userId).get() + if (!userSnapshot?.exists) return '' - const userData = userSnapshot.data() || {}; + const userData = userSnapshot.data() || {} return ( pickFirstNonEmpty( userData.artistName, userData.userName, userData.displayName, - combineNames(userData.firstName, userData.lastName), - ) || "" - ); + combineNames(userData.firstName, userData.lastName) + ) || '' + ) } catch (error) { - logger.warn("⚠️ [Cover] Artist name resolution failed", { + logger.warn('⚠️ [Cover] Artist name resolution failed', { projectId: project?.id, error: error.message, - }); - return ""; + }) + return '' } } @@ -100,60 +99,57 @@ async function resolveArtistName(project = {}) { * Ajoute le logo en filigrane sur l'image générée */ async function buildCoverWithLogo(backgroundUrl, targetPath) { - logger.info("🖼️ [Cover] Compositing logo..."); + logger.info('🖼️ [Cover] Compositing logo...') try { // Téléchargement background + Lecture Logo (parallèle) const [bgResponse, logoBuffer] = await Promise.all([ - axios.get(backgroundUrl, { responseType: "arraybuffer" }), + axios.get(backgroundUrl, { responseType: 'arraybuffer' }), getLogoBuffer(), - ]); + ]) - const baseImage = sharp(bgResponse.data); - const metadata = await baseImage.metadata(); - const width = metadata.width || 1024; - const height = metadata.height || 1024; + const baseImage = sharp(bgResponse.data) + const metadata = await baseImage.metadata() + const width = metadata.width || 1024 + const height = metadata.height || 1024 // Calcul dynamique de la taille du logo (32% de la largeur) - const desiredWidth = Math.round(width * 0.32); - const margin = Math.round(width * 0.04); + const desiredWidth = Math.round(width * 0.32) + const margin = Math.round(width * 0.04) // Redimensionnement du logo - const resizedLogo = await sharp(logoBuffer) - .resize({ width: desiredWidth }) - .png() - .toBuffer(); + const resizedLogo = await sharp(logoBuffer).resize({ width: desiredWidth }).png().toBuffer() // Positionnement (Bas Droite) - const logoMetadata = await sharp(resizedLogo).metadata(); - const left = Math.max(width - logoMetadata.width - margin, 0); - const top = Math.max(height - logoMetadata.height - margin, 0); + const logoMetadata = await sharp(resizedLogo).metadata() + const left = Math.max(width - logoMetadata.width - margin, 0) + const top = Math.max(height - logoMetadata.height - margin, 0) // Composition const stampedBuffer = await baseImage .ensureAlpha() - .composite([{ input: resizedLogo, left, top, blend: "over" }]) + .composite([{ input: resizedLogo, left, top, blend: 'over' }]) .png() - .toBuffer(); + .toBuffer() // Upload vers Storage - const token = crypto.randomUUID(); - const file = bucket.file(targetPath); + const token = crypto.randomUUID() + const file = bucket.file(targetPath) await file.save(stampedBuffer, { resumable: false, metadata: { - contentType: "image/png", - cacheControl: "public, max-age=31536000", + contentType: 'image/png', + cacheControl: 'public, max-age=31536000', metadata: { firebaseStorageDownloadTokens: token }, }, - }); + }) - return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`; + return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}` } catch (error) { - logger.error("❌ [Cover] buildCoverWithLogo failed", error); + logger.error('❌ [Cover] buildCoverWithLogo failed', error) // En cas d'échec du logo, on renvoie l'URL originale pour ne pas tout perdre - return backgroundUrl; + return backgroundUrl } } @@ -161,74 +157,69 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) { * Cœur de la logique de génération */ async function performCoverGeneration(project) { - const t0 = Date.now(); - const artistName = await resolveArtistName(project); + const t0 = Date.now() + const artistName = await resolveArtistName(project) // Génération du Prompt optimisé const prompt = generatePicturePrompt({ ...project, artistName, - }); + }) - logger.info("🎨 [Cover] Prompt generated", { + logger.info('🎨 [Cover] Prompt generated', { projectId: project.id, artistName, - promptPreview: prompt.slice(0, 100) + "...", - }); + promptPreview: prompt.slice(0, 100) + '...', + }) - const baseTimestamp = Date.now(); - const GENERATION_COUNT = 2; // Nombre de variantes simultanées + const baseTimestamp = Date.now() + const GENERATION_COUNT = 2 // Nombre de variantes simultanées // Création d'un tableau de promesses pour exécuter les tâches en parallèle - const generationPromises = Array.from({ length: GENERATION_COUNT }).map( - async (_, index) => { - const uniqueSuffix = `${baseTimestamp}-${index}`; - const storageBasePath = `users/${project.userId}/projects/${project.id}`; - const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`; - const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`; + const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => { + const uniqueSuffix = `${baseTimestamp}-${index}` + const storageBasePath = `users/${project.userId}/projects/${project.id}` + const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png` + const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png` - try { - // 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres - const generatedUrl = await generateImageV2(prompt, 1024, generatedPath); + try { + // 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres + const generatedUrl = await generateImageV2(prompt, 1024, generatedPath) - if (!generatedUrl) throw new Error("URL vide retournée par l'IA"); + if (!generatedUrl) throw new Error("URL vide retournée par l'IA") - // 2. Ajout du Logo - const finalCoverUrl = await buildCoverWithLogo( - generatedUrl, - stampedPath, - ); + // 2. Ajout du Logo + const finalCoverUrl = await buildCoverWithLogo(generatedUrl, stampedPath) - logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`); + logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`) - return { - id: uniqueSuffix, - generatedUrl, - finalUrl: finalCoverUrl, - promptUsed: prompt, - }; - } catch (e) { - // On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all - logger.error(`❌ [Cover] Option ${index + 1} failed`, { - error: e.message, - }); - return null; // On retourne null pour filtrer plus tard + return { + id: uniqueSuffix, + generatedUrl, + finalUrl: finalCoverUrl, + promptUsed: prompt, } - }, - ); + } catch (e) { + // On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all + logger.error(`❌ [Cover] Option ${index + 1} failed`, { + error: e.message, + }) + return null // On retourne null pour filtrer plus tard + } + }) // Attente de la résolution de toutes les générations - const results = await Promise.all(generationPromises); + const results = await Promise.all(generationPromises) // On garde uniquement les tentatives réussies (non null) - const options = results.filter(Boolean); + const options = results.filter(Boolean) if (options.length === 0) { - throw new Error("Toutes les tentatives de génération ont échoué."); + throw new Error('Toutes les tentatives de génération ont échoué.') } // Sauvegarde dans Firestore - const [firstOption] = options; + const [firstOption] = options await refList.projects.doc(project.id).set( { @@ -238,19 +229,19 @@ async function performCoverGeneration(project) { // selectedOptionId: firstOption.id, // disable default selection options, // Sauvegarde de toutes les variantes réussies }, - coverStatus: "GENERATED", + coverStatus: 'GENERATED', updatedAt: FieldValue.serverTimestamp(), }, - { merge: true }, - ); + { merge: true } + ) - logger.info("🏁 [Cover] Process complete", { + logger.info('🏁 [Cover] Process complete', { projectId: project.id, successCount: options.length, duration: Date.now() - t0, - }); + }) - return firstOption.finalUrl; + return firstOption.finalUrl } /** @@ -260,77 +251,72 @@ async function performCoverGeneration(project) { exports.onTaskCreateGenerateCover = onDocumentCreated( { timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent) - memory: "1GiB", - document: "tasks/{taskId}", + memory: '1GiB', + document: 'tasks/{taskId}', }, async (event) => { - const data = event.data?.data() || {}; - const { type, projectId } = data; - const taskId = event.params.taskId; + const data = event.data?.data() || {} + const { type, projectId } = data + const taskId = event.params.taskId - if (!projectId) return; // Ignorer les tâches mal formées - if (!["cover", "combine"].includes(type)) return; // Ignorer les autres types de tâches + if (!projectId) return // Ignorer les tâches mal formées + if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches - logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId }); + logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId }) try { // 1. Validation & Setup - if (type === "combine") { + if (type === 'combine') { // Feature désactivée pour le moment await event.data.ref.update({ - status: "CANCELLED", + status: 'CANCELLED', error: "La personnalisation photo n'est plus disponible.", updatedAt: FieldValue.serverTimestamp(), - }); - return; + }) + return } // Mise à jour statut projet await refList.projects.doc(projectId).update({ - coverStatus: "GENERATING", + coverStatus: 'GENERATING', updatedAt: FieldValue.serverTimestamp(), - }); + }) // 2. Chargement Projet - const projectSnap = await refList.projects.doc(projectId).get(); - if (!projectSnap.exists) throw new Error("Projet introuvable"); + const projectSnap = await refList.projects.doc(projectId).get() + if (!projectSnap.exists) throw new Error('Projet introuvable') - const project = { id: projectId, ...projectSnap.data() }; + const project = { id: projectId, ...projectSnap.data() } // Idempotency check (si déjà généré, on ne refait pas) - if ( - Array.isArray(project?.cover?.options) && - project.cover.options.length > 0 - ) { - logger.warn("⚠️ [Task] Cover already exists. Skipping."); - await refList.projects - .doc(projectId) - .update({ coverStatus: "GENERATED" }); + if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) { + logger.warn('⚠️ [Task] Cover already exists. Skipping.') + await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' }) await event.data.ref.update({ - status: "DONE", - info: "Already generated", - }); - return; + status: 'DONE', + info: 'Already generated', + }) + return } // 3. Exécution Génération - const coverUrl = await performCoverGeneration(project); + const coverUrl = await performCoverGeneration(project) // 4. Finalisation Tâche await event.data.ref.update({ - status: "DONE", + status: 'DONE', coverUrl, updatedAt: FieldValue.serverTimestamp(), - }); + }) // 5. Notification if (project.userId) { - const projectTitle = project.title || "ton projet"; + const projectTitle = project.title || 'ton projet' await sendNotification({ - sender: "SYSTEM", + sender: 'SYSTEM', receiver: project.userId, - receiverCollection: "users", - title: "Pochette prête !", + receiverCollection: 'users', + title: 'Pochette prête !', message: `La pochette pour "${projectTitle}" a été générée avec succès.`, data: { type: ALERT_TYPE?.COVER_GENERATION_SUCCESS, @@ -338,39 +324,36 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( projectTitle, coverUrl, }, - }).catch((err) => logger.warn("Notification failed", err)); + }).catch((err) => logger.warn('Notification failed', err)) } } catch (error) { - logger.error(`🔥 [Task ${taskId}] Failed`, error); + logger.error(`🔥 [Task ${taskId}] Failed`, error) // Mise à jour erreur Tâche - await event.data.ref.set( - { status: "ERROR", error: error.message }, - { merge: true }, - ); + await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true }) // Mise à jour erreur Projet await refList.projects.doc(projectId).update({ - coverStatus: "ERROR", + coverStatus: 'ERROR', updatedAt: FieldValue.serverTimestamp(), - }); + }) // Notification Erreur - const projectData = (await refList.projects.doc(projectId).get()).data(); + const projectData = (await refList.projects.doc(projectId).get()).data() if (projectData?.userId) { await sendNotification({ - sender: "SYSTEM", + sender: 'SYSTEM', receiver: projectData.userId, - receiverCollection: "users", - title: "Échec pochette", - message: `Impossible de générer la pochette pour "${projectData.title || "ton projet"}".`, + receiverCollection: 'users', + title: 'Échec pochette', + message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`, data: { type: ALERT_TYPE?.COVER_GENERATION_FAILED, projectId, error: error.message, }, - }).catch(() => { }); + }).catch(() => {}) } } - }, -); + } +) diff --git a/functions/src/helpers/orders.js b/functions/src/helpers/orders.js index edca6f4..7bec9da 100644 --- a/functions/src/helpers/orders.js +++ b/functions/src/helpers/orders.js @@ -1,60 +1,60 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') const ORDER_TYPES = { - GIFT: "GIFT", - SONG: "SONG", - COINS: "COINS", - SUBSCRIPTION: "SUBSCRIPTION", -}; + GIFT: 'GIFT', + SONG: 'SONG', + COINS: 'COINS', + SUBSCRIPTION: 'SUBSCRIPTION', +} const ORDER_STATUS = { - PENDING: "PENDING", - APPLIED: "APPLIED", - REJECTED: "REJECTED", -}; + PENDING: 'PENDING', + APPLIED: 'APPLIED', + REJECTED: 'REJECTED', +} -const ORDERS_COLLECTION = "orders"; +const ORDERS_COLLECTION = 'orders' const isFiniteNumber = (value) => { - if (typeof value === "number" && Number.isFinite(value)) { - return true; + if (typeof value === 'number' && Number.isFinite(value)) { + return true } - if (typeof value === "string") { - const parsed = Number(value); - return Number.isFinite(parsed); + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isFinite(parsed) } - return false; -}; + return false +} const normalizeAmount = (amount) => { if (!isFiniteNumber(amount)) { - return null; + return null } - return Number(amount); -}; + return Number(amount) +} const createOrderDocument = async ({ userId, type, amount, songId = null, - createdBy = "system", + createdBy = 'system', metadata = {}, orderId = null, }) => { - if (!userId || typeof userId !== "string") { - throw new Error("[orders] Missing userId when creating order"); + if (!userId || typeof userId !== 'string') { + throw new Error('[orders] Missing userId when creating order') } if (!Object.values(ORDER_TYPES).includes(type)) { - throw new Error(`[orders] Invalid order type "${type}"`); + throw new Error(`[orders] Invalid order type "${type}"`) } - const normalizedAmount = normalizeAmount(amount); + const normalizedAmount = normalizeAmount(amount) if (normalizedAmount === null || normalizedAmount === 0) { - throw new Error("[orders] Invalid order amount"); + throw new Error('[orders] Invalid order amount') } const payload = { @@ -63,25 +63,25 @@ const createOrderDocument = async ({ amount: normalizedAmount, songId: type === ORDER_TYPES.SONG ? songId || null : null, createdAt: FieldValue.serverTimestamp(), - createdBy: createdBy || "system", + createdBy: createdBy || 'system', status: ORDER_STATUS.PENDING, metadata: metadata || {}, - }; + } - const collectionRef = admin.firestore().collection(ORDERS_COLLECTION); - const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc(); + const collectionRef = admin.firestore().collection(ORDERS_COLLECTION) + const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc() if (orderId) { - const existingSnapshot = await orderRef.get(); + const existingSnapshot = await orderRef.get() if (existingSnapshot.exists) { - return { orderRef, orderId: orderRef.id }; + return { orderRef, orderId: orderRef.id } } } - await orderRef.set(payload); + await orderRef.set(payload) - return { orderRef, orderId: orderRef.id }; -}; + return { orderRef, orderId: orderRef.id } +} module.exports = { ORDER_TYPES, @@ -89,4 +89,4 @@ module.exports = { ORDERS_COLLECTION, createOrderDocument, normalizeAmount, -}; +} diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index 96c8d40..a816202 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -1,93 +1,86 @@ -const { onCall, HttpsError } = require("firebase-functions/v2/https"); -const { z } = require("genkit"); -const { generateAI, analyseLyrics } = require("../helpers/gemini"); -const { - SUNO_API_BASE, - SUNO_TIMESTAMPED_LYRICS_PATH, -} = require("../config/suno"); -const { SUNO_API_KEY } = require("../config/keys"); -const axios = require("axios"); -const { FieldValue } = require("firebase-admin/firestore"); -const { refList } = require("../index"); +const { onCall, HttpsError } = require('firebase-functions/v2/https') +const { z } = require('genkit') +const { generateAI, analyseLyrics } = require('../helpers/gemini') +const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno') +const { SUNO_API_KEY } = require('../config/keys') +const axios = require('axios') +const { FieldValue } = require('firebase-admin/firestore') +const { refList } = require('../index') // --- CONSTANTES DE STRUCTURE --- // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur const STRUCTURE_PROMPT_LABELS = { - couplet: "couplet", - refrain: "refrain", - short_intro: "introduction instrumentale courte", - long_intro: "introduction instrumentale longue", - pre_refrain_instrumental: "pré-refrain instrumental", - pont: "pont", - solo_de_guitare: "solo de guitare", - solo_de_guitare_electrique: "solo de guitare électrique", - solo_de_batterie: "solo de batterie", - solo_de_saxophone: "solo de saxophone", - solo_de_violon: "solo de violon", - break: "break", - interlude: "interlude", - interlude_melodique: "interlude mélodique", - final_apogee: "final apogée", - arret_net: "arrêt net", - fade_out: "fade out", - transition_douce: "transition douce vers le silence", -}; + couplet: 'couplet', + refrain: 'refrain', + short_intro: 'introduction instrumentale courte', + long_intro: 'introduction instrumentale longue', + pre_refrain_instrumental: 'pré-refrain instrumental', + pont: 'pont', + solo_de_guitare: 'solo de guitare', + solo_de_guitare_electrique: 'solo de guitare électrique', + solo_de_batterie: 'solo de batterie', + solo_de_saxophone: 'solo de saxophone', + solo_de_violon: 'solo de violon', + break: 'break', + interlude: 'interlude', + interlude_melodique: 'interlude mélodique', + final_apogee: 'final apogée', + arret_net: 'arrêt net', + fade_out: 'fade out', + transition_douce: 'transition douce vers le silence', +} const STRUCTURE_ALIASES = { - "short intro": "short_intro", - "introduction instrumentale courte": "short_intro", - "intro instrumentale courte": "short_intro", - "long intro": "long_intro", - "introduction instrumentale longue": "long_intro", - "intro instrumentale longue": "long_intro", - "pré-refrain": "pre_refrain_instrumental", - "pre-refrain": "pre_refrain_instrumental", - pre_refrain: "pre_refrain_instrumental", - "pre chorus": "pre_refrain_instrumental", - "pre-chorus": "pre_refrain_instrumental", - prechorus: "pre_refrain_instrumental", - "pré-refrain instrumental": "pre_refrain_instrumental", - "pre-refrain instrumental": "pre_refrain_instrumental", - "instrumental pre-chorus": "pre_refrain_instrumental", - "instrumental pre chorus": "pre_refrain_instrumental", - bridge: "pont", - guitar_solo: "solo_de_guitare", - electric_guitar_solo: "solo_de_guitare_electrique", - drum_solo: "solo_de_batterie", - sax_solo: "solo_de_saxophone", - violin_solo: "solo_de_violon", - melodic_interlude: "interlude_melodique", - grand_finale: "final_apogee", - sudden_stop: "arret_net", - soft_transition: "transition_douce", -}; + 'short intro': 'short_intro', + 'introduction instrumentale courte': 'short_intro', + 'intro instrumentale courte': 'short_intro', + 'long intro': 'long_intro', + 'introduction instrumentale longue': 'long_intro', + 'intro instrumentale longue': 'long_intro', + 'pré-refrain': 'pre_refrain_instrumental', + 'pre-refrain': 'pre_refrain_instrumental', + pre_refrain: 'pre_refrain_instrumental', + 'pre chorus': 'pre_refrain_instrumental', + 'pre-chorus': 'pre_refrain_instrumental', + prechorus: 'pre_refrain_instrumental', + 'pré-refrain instrumental': 'pre_refrain_instrumental', + 'pre-refrain instrumental': 'pre_refrain_instrumental', + 'instrumental pre-chorus': 'pre_refrain_instrumental', + 'instrumental pre chorus': 'pre_refrain_instrumental', + bridge: 'pont', + guitar_solo: 'solo_de_guitare', + electric_guitar_solo: 'solo_de_guitare_electrique', + drum_solo: 'solo_de_batterie', + sax_solo: 'solo_de_saxophone', + violin_solo: 'solo_de_violon', + melodic_interlude: 'interlude_melodique', + grand_finale: 'final_apogee', + sudden_stop: 'arret_net', + soft_transition: 'transition_douce', +} // --- UTILITAIRES --- const normalizeStructureValue = (value) => { - const raw = String(value || "") + const raw = String(value || '') .trim() - .toLowerCase(); - if (!raw) return ""; - if (STRUCTURE_PROMPT_LABELS[raw]) return raw; - if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw]; - const sanitized = raw.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); - return STRUCTURE_PROMPT_LABELS[sanitized] - ? sanitized - : STRUCTURE_ALIASES[sanitized] || sanitized; -}; + .toLowerCase() + if (!raw) return '' + if (STRUCTURE_PROMPT_LABELS[raw]) return raw + if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw] + const sanitized = raw.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + return STRUCTURE_PROMPT_LABELS[sanitized] ? sanitized : STRUCTURE_ALIASES[sanitized] || sanitized +} // Nettoyage simplifié : on laisse l'IA gérer la logique musicale plutôt que de supprimer brutalement. // On s'assure juste que les clés sont propres. const sanitizeStructureEntries = (structure = []) => { - if (!Array.isArray(structure)) return []; - return structure.map(normalizeStructureValue).filter(Boolean); -}; + if (!Array.isArray(structure)) return [] + return structure.map(normalizeStructureValue).filter(Boolean) +} const mapStructureToPrompt = (structure = []) => - sanitizeStructureEntries(structure).map( - (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, - ); + sanitizeStructureEntries(structure).map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry) // --- MODERATION --- @@ -101,40 +94,37 @@ const buildModerationBrief = ({ rhymes, }) => { const sections = [ - `${objective || ""}`, - `${context || ""}`, - `${emotion || ""}`, - ``, - `${audience || ""}`, - `${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}`, - ]; - const promptStructure = mapStructureToPrompt(structure); - if (promptStructure.length) - sections.push(`${promptStructure.join(" | ")}`); - return `\n${sections.join("\n")}\n`; -}; + `${objective || ''}`, + `${context || ''}`, + `${emotion || ''}`, + ``, + `${audience || ''}`, + `${Array.isArray(rhymes) ? rhymes.join(', ') : rhymes || ''}`, + ] + const promptStructure = mapStructureToPrompt(structure) + if (promptStructure.length) sections.push(`${promptStructure.join(' | ')}`) + return `\n${sections.join('\n')}\n` +} // --- GENERATION DE PAROLES (MAIN) --- exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { try { const { - objective = "", - context = "", - style = "", - audience = "", - emotion = "", - structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"], - rhymes = "", - } = data; + objective = '', + context = '', + style = '', + audience = '', + emotion = '', + structure: rawStructure = ['couplet', 'refrain', 'couplet', 'refrain'], + rhymes = '', + } = data // 1. Préparation Structure - const sanitizedStructure = sanitizeStructureEntries(rawStructure); + const sanitizedStructure = sanitizeStructureEntries(rawStructure) const promptStructure = sanitizedStructure.length - ? sanitizedStructure.map( - (entry) => STRUCTURE_PROMPT_LABELS[entry] || entry, - ) - : []; + ? sanitizedStructure.map((entry) => STRUCTURE_PROMPT_LABELS[entry] || entry) + : [] // 2. Modération "Pro" (Via Gemini 1.5 Pro) // On supprime les regex manuelles obsolètes. @@ -146,40 +136,34 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => { emotion, structure: promptStructure, rhymes, - }; - const moderationInput = buildModerationBrief(moderationPayload); + } + const moderationInput = buildModerationBrief(moderationPayload) try { const moderation = await analyseLyrics({ - title: "Brief utilisateur (Pré-génération)", + title: 'Brief utilisateur (Pré-génération)', lyrics: moderationInput, - }); + }) // Si Gemini dit "Blocked", on bloque. C'est la seule autorité. if (moderation?.blocked === true) { - console.warn("⛔ generateLyrics blocked by Gemini Pro", { + console.warn('⛔ generateLyrics blocked by Gemini Pro', { reasons: moderation.reasons, - }); + }) const summary = Array.isArray(moderation.reasons) - ? moderation.reasons.slice(0, 3).join(", ") - : "Contenu non conforme"; - throw new HttpsError( - "invalid-argument", - `Demande refusée par la modération : ${summary}.`, - ); + ? moderation.reasons.slice(0, 3).join(', ') + : 'Contenu non conforme' + throw new HttpsError('invalid-argument', `Demande refusée par la modération : ${summary}.`) } } catch (moderationError) { - if (moderationError instanceof HttpsError) throw moderationError; - console.error("⚠️ Moderation check error (fail open)", moderationError); + if (moderationError instanceof HttpsError) throw moderationError + console.error('⚠️ Moderation check error (fail open)', moderationError) // On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. // Ici je fail closed par sécurité pour une app publique. - throw new HttpsError( - "internal", - "Vérification de sécurité indisponible.", - ); + throw new HttpsError('internal', 'Vérification de sécurité indisponible.') } - console.log("🎵 generateLyrics: Start generation", { style, emotion }); + console.log('🎵 generateLyrics: Start generation', { style, emotion }) // 3. Le Prompt "Hit Maker" const system = ` @@ -197,27 +181,25 @@ TES RÈGLES D'OR : 5. **VOCABULAIRE** : Adapte le niveau de langue au style (Argot pour le Rap, Soutenu pour la Chanson Française, Simple pour la Pop). Retourne UNIQUEMENT un JSON valide. - `.trim(); + `.trim() - const structureTags = ( - Array.isArray(promptStructure) ? promptStructure : [] - ) + const structureTags = (Array.isArray(promptStructure) ? promptStructure : []) .map((part, index) => `
${part}
`) - .join("\n"); + .join('\n') const fallbackStructureTags = [ '
couplet
', '
refrain
', - ].join("\n"); + ].join('\n') const prompt = ` - ${objective || "Créer une chanson mémorable"} - ${context || "Libre interprétation"} - ${emotion || "Intense"} - ${style || "Pop Moderne"} - ${audience || "Tout public"} - ${rhymes || "Rimes croisées et riches"} + ${objective || 'Créer une chanson mémorable'} + ${context || 'Libre interprétation'} + ${emotion || 'Intense'} + ${style || 'Pop Moderne'} + ${audience || 'Tout public'} + ${rhymes || 'Rimes croisées et riches'} @@ -231,106 +213,97 @@ ${structureTags || fallbackStructureTags} - Si c'est "Couplet/Refrain" : Écris 4 à 12 vers. 3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre. - `.trim(); + `.trim() const lyricsSchema = z.object({ - title: z.string().describe("Titre de la chanson, court et accrocheur"), + title: z.string().describe('Titre de la chanson, court et accrocheur'), lyrics: z .array( z.object({ - type: z - .string() - .describe( - "Type de section (copier exactement la demande structure)", - ), + type: z.string().describe('Type de section (copier exactement la demande structure)'), lyrics: z .string() .describe( - "Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance.", + "Les paroles. Pour les sections instrumentales, laisser vide ou décrire l'ambiance." ), - }), + }) ) - .describe("La structure complète de la chanson"), + .describe('La structure complète de la chanson'), lyricsDescription: z .string() .describe( - "Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno.", + "Un pitch de 2 phrases décrivant l'ambiance et le thème de la chanson pour Suno." ), success: z.boolean(), - }); + }) // Appel Gemini avec le nouveau modèle Pro configuré dans helpers/gemini return await generateAI({ system, prompt, schema: lyricsSchema, - }); + }) } catch (e) { - console.error("❌ generateLyrics Error:", e); + console.error('❌ generateLyrics Error:', e) // Remontée d'erreur propre - if (e instanceof HttpsError) throw e; - throw new HttpsError( - "internal", - "Erreur lors de la génération des paroles.", - ); + if (e instanceof HttpsError) throw e + throw new HttpsError('internal', 'Erreur lors de la génération des paroles.') } -}); +}) // --- ANALYSE TOXICITÉ (EXISTANTE, NETTOYÉE) --- // Cette fonction reste utile pour l'analyse POST-création ou pour l'interface UI -const severityScore = (severity = "") => { - const normalized = String(severity || "").toLowerCase(); - if (normalized === "critical") return 4; - if (normalized === "high") return 3; - if (normalized === "medium") return 2; - if (normalized === "low") return 1; - return 0; -}; +const severityScore = (severity = '') => { + const normalized = String(severity || '').toLowerCase() + if (normalized === 'critical') return 4 + if (normalized === 'high') return 3 + if (normalized === 'medium') return 2 + if (normalized === 'low') return 1 + return 0 +} const softenModerationDecision = (rawResult = {}) => { - const result = { ...rawResult }; - const excerpts = Array.isArray(result.excerpts) ? result.excerpts : []; - const reasons = Array.isArray(result.reasons) ? result.reasons : []; + const result = { ...rawResult } + const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [] + const reasons = Array.isArray(result.reasons) ? result.reasons : [] const highestSeverity = excerpts.reduce( (max, excerpt) => Math.max(max, severityScore(excerpt?.severity)), - 0, - ); + 0 + ) const score = - typeof result.score === "number" && !Number.isNaN(result.score) + typeof result.score === 'number' && !Number.isNaN(result.score) ? Math.min(Math.max(result.score, 0), 1) - : 0; + : 0 // Détection de contexte narratif pour être plus indulgent const narrativeHint = reasons.some((reason) => - /narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test( - reason || "", - ), - ); + /narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(reason || '') + ) - const adjustments = []; + const adjustments = [] // Logique d'adoucissement : Si c'est "High" severity mais narratif, on peut parfois débloquer (selon ta politique). // Ici on reste prudent sur le block, mais on adoucit le flag. if (result.blocked) { // Débloque uniquement les erreurs manifestes (score bas mais blocked true par erreur) if (highestSeverity <= 1 && score < 0.65) { - result.blocked = false; - adjustments.push("auto-unblock-low-severity"); + result.blocked = false + adjustments.push('auto-unblock-low-severity') } } if (!result.blocked && result.flagged) { - const lowSignal = highestSeverity <= 1 && score < 0.4; - const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55; + const lowSignal = highestSeverity <= 1 && score < 0.4 + const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55 if (lowSignal) { - result.flagged = false; - adjustments.push("drop-flag-low-signal"); + result.flagged = false + adjustments.push('drop-flag-low-signal') } else if (contextual) { - result.flagged = false; - adjustments.push("drop-flag-contextual"); + result.flagged = false + adjustments.push('drop-flag-contextual') } } @@ -338,8 +311,8 @@ const softenModerationDecision = (rawResult = {}) => { ...result, moderationAdjustments: adjustments, moderationCalibration: { highestSeverity, score }, - }; -}; + } +} exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { try { @@ -356,93 +329,91 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => { z.object({ type: z.string().optional(), lyrics: z.string().optional(), - }), + }) ), ]) - .describe("Paroles à analyser"), - }); + .describe('Paroles à analyser'), + }) - const parsed = requestSchema.parse(data || {}); + const parsed = requestSchema.parse(data || {}) const aiResult = await analyseLyrics({ - title: parsed.title || "", + title: parsed.title || '', lyrics: parsed.lyrics, - }); - const result = softenModerationDecision(aiResult); + }) + const result = softenModerationDecision(aiResult) const highCats = Object.entries(result.categories || {}) // Note: categories n'est pas dans le schema Zod actuel de Gemini, à vérifier si besoin - .filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5) + .filter(([, v]) => (typeof v === 'number' ? v : 0) >= 0.5) .map(([k]) => k) - .slice(0, 5); + .slice(0, 5) - let errorCode = "OK"; - let message = "Analyse effectuée: aucun blocage."; + let errorCode = 'OK' + let message = 'Analyse effectuée: aucun blocage.' if (result.blocked) { - errorCode = "TOXIC_CONTENT_BLOCKED"; - message = `Contenu bloqué par sécurité.`; + errorCode = 'TOXIC_CONTENT_BLOCKED' + message = `Contenu bloqué par sécurité.` } else if (result.flagged) { - errorCode = "TOXIC_CONTENT_FLAGGED"; - message = `Attention: contenu sensible détecté.`; + errorCode = 'TOXIC_CONTENT_FLAGGED' + message = `Attention: contenu sensible détecté.` } - return { success: !result.blocked, errorCode, message, result }; + return { success: !result.blocked, errorCode, message, result } } catch (err) { - console.error("analyseLyricsToxicity failed", err?.message || err); + console.error('analyseLyricsToxicity failed', err?.message || err) return { success: false, - errorCode: "ANALYSE_FAILED", - message: "Erreur analyse toxicité.", - }; + errorCode: 'ANALYSE_FAILED', + message: 'Erreur analyse toxicité.', + } } -}); +}) // --- SUNO TIMESTAMPS (EXISTANT) --- async function getSunoTimestamps(projectId) { try { - if (!projectId || typeof projectId !== "string") - throw new Error("projectId invalide"); + if (!projectId || typeof projectId !== 'string') throw new Error('projectId invalide') - const docSnap = await refList.projects.doc(projectId).get(); - if (!docSnap.exists) throw new Error("Projet introuvable"); + const docSnap = await refList.projects.doc(projectId).get() + if (!docSnap.exists) throw new Error('Projet introuvable') - const { sunoTaskId, songIndex } = docSnap.data(); - if (!sunoTaskId) throw new Error("TaskId manquant"); - if (songIndex === undefined || songIndex < 0) - throw new Error("musicIndex invalide"); + const { sunoTaskId, songIndex } = docSnap.data() + if (!sunoTaskId) throw new Error('TaskId manquant') + if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide') - console.log("🔎 getSunoTimestamps", { sunoTaskId, songIndex }); + console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex }) const response = await axios.post( `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, { taskId: sunoTaskId, musicIndex: songIndex }, { headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, - }, - ); + } + ) - const dataToReturn = response.data?.data || response.data || {}; + const dataToReturn = response.data?.data || response.data || {} await refList.projects.doc(projectId).set( { musicTimestamps: { [songIndex]: dataToReturn }, updatedAt: FieldValue.serverTimestamp(), }, - { merge: true }, - ); + { merge: true } + ) - return { success: true }; + return { success: true } } catch (error) { - console.error("❌ getSunoTimestamps Error:", error.message); + console.error('❌ getSunoTimestamps Error:', error.message) return { success: false, - error: { message: error.message, type: "INTERNAL_ERROR" }, - }; + error: { message: error.message, type: 'INTERNAL_ERROR' }, + } } } -exports.getSunoTimestamps = getSunoTimestamps; +exports.getSunoTimestamps = getSunoTimestamps diff --git a/functions/src/music.js b/functions/src/music.js index cafe235..03a892a 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -1,28 +1,24 @@ -const { - onCall, - onRequest, - HttpsError, -} = require("firebase-functions/v2/https"); -const axios = require("axios"); -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { logger } = require("firebase-functions/logger"); -const { pipeline } = require("stream/promises"); -const { randomUUID } = require("crypto"); -const { ALERT_TYPE, refList } = require("../index"); -const { sendNotification } = require("./notifications"); -const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders"); -const { SUNO_API_KEY } = require("../config/keys"); +const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https') +const axios = require('axios') +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { logger } = require('firebase-functions/logger') +const { pipeline } = require('stream/promises') +const { randomUUID } = require('crypto') +const { ALERT_TYPE, refList } = require('../index') +const { sendNotification } = require('./notifications') +const { createOrderDocument, ORDER_TYPES } = require('./helpers/orders') +const { SUNO_API_KEY } = require('../config/keys') const { SUNO_MODEL, SUNO_CALLBACK_URL, SUNO_API_BASE, SUNO_API_PATH, SUNO_STATUS_PATH, -} = require("../config/suno"); +} = require('../config/suno') -const MUSIC_GENERATION_CREDIT_COST = 8; -const MUSIC_REFUND_SOURCE = "music_generation_refund"; +const MUSIC_GENERATION_CREDIT_COST = 8 +const MUSIC_REFUND_SOURCE = 'music_generation_refund' // ========================================== // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) @@ -30,98 +26,98 @@ const MUSIC_REFUND_SOURCE = "music_generation_refund"; const STYLE_MAP = { // Mapping du SONG_STYLE - Upbeat: "Upbeat, Happy, Feel-good", - "Grandios(e)": "Grand, Cinematic, Orchestral", - Chill: "Chill, Relaxed, Downtempo", - Epic: "Epic, Heroic, Trailer Music", - Dramatic: "Dramatic, Intense, Theatrical", - Comedic: "Comedy, Novelty, Funny", - Theatrical: "Musical Theater, Broadway, Storytelling", - Flamboyant: "Flamboyant, Glam, Exuberant", - Mélancolique: "Melancholic, Sad, Emotional", - Introspective: "Introspective, Deep, Thoughtful", - "Urban Tragedy": "Urban, Dark, Grit, Cinematic", - Eerie: "Eerie, Haunting, Spooky", - Mysterious: "Mysterious, Enigmatic, Suspenseful", -}; + Upbeat: 'Upbeat, Happy, Feel-good', + 'Grandios(e)': 'Grand, Cinematic, Orchestral', + Chill: 'Chill, Relaxed, Downtempo', + Epic: 'Epic, Heroic, Trailer Music', + Dramatic: 'Dramatic, Intense, Theatrical', + Comedic: 'Comedy, Novelty, Funny', + Theatrical: 'Musical Theater, Broadway, Storytelling', + Flamboyant: 'Flamboyant, Glam, Exuberant', + Mélancolique: 'Melancholic, Sad, Emotional', + Introspective: 'Introspective, Deep, Thoughtful', + 'Urban Tragedy': 'Urban, Dark, Grit, Cinematic', + Eerie: 'Eerie, Haunting, Spooky', + Mysterious: 'Mysterious, Enigmatic, Suspenseful', +} const GENRE_MAP = { // Mapping du CHOOSE_GENRE - Pop: "Pop", - "Hip Hop/Rap": "Hip Hop, Rap", - Soul: "Soul, Neo-Soul", - Blues: "Blues, Delta Blues", - Folk: "Folk, Acoustic", - Punk: "Punk Rock, High Energy", - Dance: "Dance, Club", - Grunge: "Grunge, Distorted", - EDM: "EDM, Electronic", - Trap: "Trap, 808s", - Latino: "Latin, Reggaeton", - Dancehall: "Dancehall, Island", - "Latin Pop": "Latin Pop", - Raggaeton: "Reggaeton, Urbano", - Rock: "Rock", - "Rock progressif": "Prog Rock, Complex", - "Hard Rock": "Hard Rock", - Metal: "Heavy Metal", - "R&B": "R&B, Contemporary R&B", - Phonk: "Phonk, Memphis Rap, Drift", - House: "House, Deep House", - Alternative: "Alternative Rock, Indie", - Indie: "Indie Pop", - Country: "Country, Americana", - Synthwave: "Synthwave, Retrowave, 80s", - Afrobeat: "Afrobeat, African Rhythms", - "K-Pop": "K-Pop, Idol", - Techno: "Techno, Minimal", - Funk: "Funk, Groove", - Disco: "Disco, Nu-Disco", - "New wave": "New Wave, Post-Punk", - Jazz: "Jazz, Smooth Jazz", - "Lo-Fi": "Lo-Fi, Chillhop", - "Bedroom Pop": "Bedroom Pop, Dreamy", - Ambient: "Ambient, Atmospheric", - "Dream Pop": "Dream Pop, Shoegaze", - Grime: "Grime, UK Rap", - Hyperpop: "Hyperpop, Glitch", - Gospel: "Gospel, Spiritual", -}; + Pop: 'Pop', + 'Hip Hop/Rap': 'Hip Hop, Rap', + Soul: 'Soul, Neo-Soul', + Blues: 'Blues, Delta Blues', + Folk: 'Folk, Acoustic', + Punk: 'Punk Rock, High Energy', + Dance: 'Dance, Club', + Grunge: 'Grunge, Distorted', + EDM: 'EDM, Electronic', + Trap: 'Trap, 808s', + Latino: 'Latin, Reggaeton', + Dancehall: 'Dancehall, Island', + 'Latin Pop': 'Latin Pop', + Raggaeton: 'Reggaeton, Urbano', + Rock: 'Rock', + 'Rock progressif': 'Prog Rock, Complex', + 'Hard Rock': 'Hard Rock', + Metal: 'Heavy Metal', + 'R&B': 'R&B, Contemporary R&B', + Phonk: 'Phonk, Memphis Rap, Drift', + House: 'House, Deep House', + Alternative: 'Alternative Rock, Indie', + Indie: 'Indie Pop', + Country: 'Country, Americana', + Synthwave: 'Synthwave, Retrowave, 80s', + Afrobeat: 'Afrobeat, African Rhythms', + 'K-Pop': 'K-Pop, Idol', + Techno: 'Techno, Minimal', + Funk: 'Funk, Groove', + Disco: 'Disco, Nu-Disco', + 'New wave': 'New Wave, Post-Punk', + Jazz: 'Jazz, Smooth Jazz', + 'Lo-Fi': 'Lo-Fi, Chillhop', + 'Bedroom Pop': 'Bedroom Pop, Dreamy', + Ambient: 'Ambient, Atmospheric', + 'Dream Pop': 'Dream Pop, Shoegaze', + Grime: 'Grime, UK Rap', + Hyperpop: 'Hyperpop, Glitch', + Gospel: 'Gospel, Spiritual', +} const INSTRUMENT_MAP = { - "Piano classique": "Grand Piano", - "Piano éléctrique": "Electric Piano, Rhodes", - Synthétiseur: "Synthesizer", - "Guitare acoustique": "Acoustic Guitar", - "Guitare électrique": "Electric Guitar", - Batterie: "Drums", - Banjo: "Banjo", - Violon: "Violin, Strings", - Saxophone: "Saxophone", - "Saxophone alto": "Alto Sax", - Trompette: "Trumpet", - Flûte: "Flute", - Clarinette: "Clarinet", - Djembe: "Percussion, Djembe", - Bongos: "Bongos", - Congas: "Congas", - Harmonica: "Harmonica", - Handpan: "Handpan", - Harpe: "Harp", - Xylophone: "Xylophone, Mallets", - Mandoline: "Mandolin", - Accordéon: "Accordion", - Orgue: "Organ", - Electronique: "Electronic Fx", -}; + 'Piano classique': 'Grand Piano', + 'Piano éléctrique': 'Electric Piano, Rhodes', + Synthétiseur: 'Synthesizer', + 'Guitare acoustique': 'Acoustic Guitar', + 'Guitare électrique': 'Electric Guitar', + Batterie: 'Drums', + Banjo: 'Banjo', + Violon: 'Violin, Strings', + Saxophone: 'Saxophone', + 'Saxophone alto': 'Alto Sax', + Trompette: 'Trumpet', + Flûte: 'Flute', + Clarinette: 'Clarinet', + Djembe: 'Percussion, Djembe', + Bongos: 'Bongos', + Congas: 'Congas', + Harmonica: 'Harmonica', + Handpan: 'Handpan', + Harpe: 'Harp', + Xylophone: 'Xylophone, Mallets', + Mandoline: 'Mandolin', + Accordéon: 'Accordion', + Orgue: 'Organ', + Electronique: 'Electronic Fx', +} const RHYTHM_MAP = { - "Très rapide": "Very Fast Tempo, High BPM", - Rapide: "Fast Tempo", - Normal: "Mid-tempo", - Lent: "Slow Tempo, Downtempo", - "Très lent": "Very Slow, Ballad", -}; + 'Très rapide': 'Very Fast Tempo, High BPM', + Rapide: 'Fast Tempo', + Normal: 'Mid-tempo', + Lent: 'Slow Tempo, Downtempo', + 'Très lent': 'Very Slow, Ballad', +} // ========================================== // 2. FONCTIONS DE PARSING (VOIX & STRUCTURE) @@ -131,75 +127,64 @@ const RHYTHM_MAP = { * Analyse les phrases longues du frontend pour extraire les tags vocaux Suno */ function parseVoiceTags(voiceSelections = []) { - if (!Array.isArray(voiceSelections)) return []; + if (!Array.isArray(voiceSelections)) return [] // Concatène tout pour recherche regex (Base + Sensibilité + Technique) - const fullText = voiceSelections.join(" ").toLowerCase(); - const tags = []; + const fullText = voiceSelections.join(' ').toLowerCase() + const tags = [] // Genre - if (fullText.includes("féminine") || fullText.includes("femme")) - tags.push("Female Vocals"); - if (fullText.includes("masculine") || fullText.includes("homme")) - tags.push("Male Vocals"); - if (fullText.includes("deux voix") || fullText.includes("duo")) - tags.push("Duet"); - if (fullText.includes("chœur") || fullText.includes("gospel")) - tags.push("Choir, Backing Vocals"); - if (fullText.includes("enfant")) tags.push("Youthful Vocals"); + if (fullText.includes('féminine') || fullText.includes('femme')) tags.push('Female Vocals') + if (fullText.includes('masculine') || fullText.includes('homme')) tags.push('Male Vocals') + if (fullText.includes('deux voix') || fullText.includes('duo')) tags.push('Duet') + if (fullText.includes('chœur') || fullText.includes('gospel')) tags.push('Choir, Backing Vocals') + if (fullText.includes('enfant')) tags.push('Youthful Vocals') // Style / Technique - if (fullText.includes("rap") || fullText.includes("slam")) - tags.push("Rapping, Flow"); - if (fullText.includes("parlé") || fullText.includes("raconte")) - tags.push("Spoken Word, Narration"); - if (fullText.includes("cri") || fullText.includes("scream")) - tags.push("Screaming, Aggressive"); - if (fullText.includes("murmure") || fullText.includes("chuchote")) - tags.push("Whispering, Intimate"); - if (fullText.includes("robot") || fullText.includes("synthétique")) - tags.push("Autotune, Robotic"); - if (fullText.includes("puissant")) tags.push("Powerful, Belting"); - if (fullText.includes("aérienne") || fullText.includes("légère")) - tags.push("Airy, Ethereal"); - if (fullText.includes("rauque") || fullText.includes("granuleuse")) - tags.push("Raspy, Gritty"); - if (fullText.includes("opéra") || fullText.includes("soprano")) - tags.push("Operatic"); - if (fullText.includes("sensuelle") || fullText.includes("séduisante")) - tags.push("Seductive, Breathless"); + if (fullText.includes('rap') || fullText.includes('slam')) tags.push('Rapping, Flow') + if (fullText.includes('parlé') || fullText.includes('raconte')) + tags.push('Spoken Word, Narration') + if (fullText.includes('cri') || fullText.includes('scream')) tags.push('Screaming, Aggressive') + if (fullText.includes('murmure') || fullText.includes('chuchote')) + tags.push('Whispering, Intimate') + if (fullText.includes('robot') || fullText.includes('synthétique')) tags.push('Autotune, Robotic') + if (fullText.includes('puissant')) tags.push('Powerful, Belting') + if (fullText.includes('aérienne') || fullText.includes('légère')) tags.push('Airy, Ethereal') + if (fullText.includes('rauque') || fullText.includes('granuleuse')) tags.push('Raspy, Gritty') + if (fullText.includes('opéra') || fullText.includes('soprano')) tags.push('Operatic') + if (fullText.includes('sensuelle') || fullText.includes('séduisante')) + tags.push('Seductive, Breathless') - return [...new Set(tags)]; + return [...new Set(tags)] } /** * Convertit les phrases de structure custom en Balises Suno */ function mapStructureToTag(description) { - const d = description.toLowerCase(); + const d = description.toLowerCase() - if (d.includes("intro") && d.includes("court")) return "[Short Intro]"; - if (d.includes("intro")) return "[Intro]"; - if (d.includes("pré-refrain")) return "[Pre-Chorus]"; - if (d.includes("solo guitare électrique")) return "[Electric Guitar Solo]"; - if (d.includes("solo guitare")) return "[Guitar Solo]"; - if (d.includes("solo batterie")) return "[Drum Solo]"; - if (d.includes("solo saxo")) return "[Saxophone Solo]"; - if (d.includes("solo violon")) return "[Violin Solo]"; - if (d.includes("interlude")) return "[Instrumental Interlude]"; - if (d.includes("apogée") || d.includes("finition")) return "[Big Finish]"; - if (d.includes("arrêt net")) return "[Sudden End]"; - if (d.includes("baissant le volume") || d.includes("fade")) - return "[Fade Out]"; - if (d.includes("silence")) return "[Fade to Silence]"; - if (d.includes("break") || d.includes("pause")) return "[Break]"; + if (d.includes('intro') && d.includes('court')) return '[Short Intro]' + if (d.includes('intro')) return '[Intro]' + if (d.includes('pré-refrain')) return '[Pre-Chorus]' + if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]' + if (d.includes('solo guitare')) return '[Guitar Solo]' + if (d.includes('solo batterie')) return '[Drum Solo]' + if (d.includes('solo saxo')) return '[Saxophone Solo]' + if (d.includes('solo violon')) return '[Violin Solo]' + if (d.includes('interlude')) return '[Instrumental Interlude]' + if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]' + if (d.includes('arrêt net')) return '[Sudden End]' + if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]' + if (d.includes('silence')) return '[Fade to Silence]' + if (d.includes('break') || d.includes('pause')) return '[Break]' // Mapping standard des types lyrics - if (d === "couplet" || d === "verse") return "[Verse]"; - if (d === "refrain" || d === "chorus") return "[Chorus]"; - if (d === "pont" || d === "bridge") return "[Bridge]"; + if (d === 'couplet' || d === 'verse') return '[Verse]' + if (d === 'refrain' || d === 'chorus') return '[Chorus]' + if (d === 'pont' || d === 'bridge') return '[Bridge]' - return null; // Pas de tag trouvé + return null // Pas de tag trouvé } // ========================================== @@ -210,162 +195,151 @@ function mapStructureToTag(description) { * Génère la chaîne de style optimisée (max ~200 chars) */ function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) { - const parts = []; + const parts = [] // 1. Genres (Priorité 1) if (Array.isArray(genres)) { genres.forEach((g) => { - if (GENRE_MAP[g]) parts.push(GENRE_MAP[g]); - else parts.push(g); - }); + if (GENRE_MAP[g]) parts.push(GENRE_MAP[g]) + else parts.push(g) + }) } // 2. Song Style / Vibe (Priorité 2) if (songStyle && STYLE_MAP[songStyle]) { - parts.push(STYLE_MAP[songStyle]); - } else if (songStyle && songStyle !== "Autre") { - parts.push(songStyle); + parts.push(STYLE_MAP[songStyle]) + } else if (songStyle && songStyle !== 'Autre') { + parts.push(songStyle) } // 3. Rhythm (Priorité 3) if (rhythm && RHYTHM_MAP[rhythm]) { - parts.push(RHYTHM_MAP[rhythm]); + parts.push(RHYTHM_MAP[rhythm]) } // 4. Instruments if (Array.isArray(instruments)) { instruments.slice(0, 3).forEach((i) => { // Max 3 instruments pour ne pas diluer - if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i]); - }); + if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i]) + }) } // 5. Vocals - const vocalTags = parseVoiceTags(voiceData); - parts.push(...vocalTags); + const vocalTags = parseVoiceTags(voiceData) + parts.push(...vocalTags) // Tags de qualité technique (toujours ajoutés) - parts.push("High Fidelity", "Stereo"); + parts.push('High Fidelity', 'Stereo') // Déduplication et join - const uniqueStyle = [...new Set(parts)]; - return clampLen(uniqueStyle.join(", "), 250); + const uniqueStyle = [...new Set(parts)] + return clampLen(uniqueStyle.join(', '), 250) } /** * Construit le texte final des paroles avec les balises de structure */ function buildFormattedLyrics(lyricsArray) { - if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ""; + if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return '' const formattedLines = lyricsArray.map((section) => { // Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux - const typeOrDescription = section.type || section.description || ""; - const textContent = section.lyrics || ""; + const typeOrDescription = section.type || section.description || '' + const textContent = section.lyrics || '' // Essayer de trouver un tag spécial (ex: "Introduction instrumentale") - let tag = mapStructureToTag(typeOrDescription); + let tag = mapStructureToTag(typeOrDescription) // Fallback si pas de tag spécial trouvé mais type standard if (!tag) { - if (typeOrDescription.toLowerCase().includes("couplet")) tag = "[Verse]"; - else if (typeOrDescription.toLowerCase().includes("refrain")) - tag = "[Chorus]"; - else tag = `[${typeOrDescription}]`; // Fallback générique + if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]' + else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]' + else tag = `[${typeOrDescription}]` // Fallback générique } // Si c'est une section instrumentale (pas de lyrics) if (!textContent.trim()) { - return `\n${tag}\n`; + return `\n${tag}\n` } - return `\n${tag}\n${textContent.trim()}`; - }); + return `\n${tag}\n${textContent.trim()}` + }) // Sécurité: Ajouter Intro et Outro si absents (Suno best practice) - const fullText = formattedLines.join("\n"); - let finalPrompt = fullText; + const fullText = formattedLines.join('\n') + let finalPrompt = fullText - if (!fullText.includes("[Intro]")) { - finalPrompt = "[Intro]\n" + finalPrompt; + if (!fullText.includes('[Intro]')) { + finalPrompt = '[Intro]\n' + finalPrompt } if ( - !fullText.includes("[Outro]") && - !fullText.includes("[Fade Out]") && - !fullText.includes("[Sudden End]") + !fullText.includes('[Outro]') && + !fullText.includes('[Fade Out]') && + !fullText.includes('[Sudden End]') ) { - finalPrompt = finalPrompt + "\n\n[Outro]"; + finalPrompt = finalPrompt + '\n\n[Outro]' } - return finalPrompt.trim(); + return finalPrompt.trim() } // ========================================== // 4. FONCTIONS UTILITAIRES DE BASE (GARDÉES) // ========================================== -function clampLen(str = "", max) { - if (!max) return str || ""; - if (!str) return ""; - return str.length <= max ? str : str.slice(0, max); +function clampLen(str = '', max) { + if (!max) return str || '' + if (!str) return '' + return str.length <= max ? str : str.slice(0, max) } const sanitizeField = (value, fallback = null) => { - const cleaned = typeof value === "string" ? value.trim() : value; - return typeof cleaned !== "string" || !cleaned ? fallback : cleaned; -}; + const cleaned = typeof value === 'string' ? value.trim() : value + return typeof cleaned !== 'string' || !cleaned ? fallback : cleaned +} const sanitizeMusicUrls = (urls = []) => (Array.isArray(urls) ? urls : []) - .filter((url) => typeof url === "string" && url.trim()) - .map((url) => url.trim()); + .filter((url) => typeof url === 'string' && url.trim()) + .map((url) => url.trim()) const formatProjectMeta = (projectData = {}) => { - const userId = sanitizeField(projectData?.userId); - const projectTitle = sanitizeField(projectData?.title, "ton projet"); - return { userId, projectTitle }; -}; + const userId = sanitizeField(projectData?.userId) + const projectTitle = sanitizeField(projectData?.title, 'ton projet') + return { userId, projectTitle } +} const parseSunoCallbackPayload = (rawBody = {}) => { - const body = rawBody || {}; - const code = body.code ?? body.statusCode ?? null; - const callbackType = (body?.data?.callbackType || "") - .toString() - .toLowerCase(); - const status = (body.status || body.state || callbackType || "") - .toString() - .toLowerCase(); - const taskId = sanitizeField(body?.data?.task_id); + const body = rawBody || {} + const code = body.code ?? body.statusCode ?? null + const callbackType = (body?.data?.callbackType || '').toString().toLowerCase() + const status = (body.status || body.state || callbackType || '').toString().toLowerCase() + const taskId = sanitizeField(body?.data?.task_id) const tracks = Array.isArray(body?.data?.data) ? body.data.data : Array.isArray(body.data) ? body.data - : []; - return { code, status, taskId, tracks }; -}; + : [] + return { code, status, taskId, tracks } +} const extractAudioUrlsFromTracks = (tracks = []) => tracks - .map( - (t) => - t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl, - ) + .map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl) .filter(Boolean) - .slice(0, 2); + .slice(0, 2) const fetchProjectByTaskId = async (taskId) => { - const snapshot = await refList.projects - .where("sunoTaskId", "==", taskId) - .limit(1) - .get(); - if (snapshot.empty) throw new Error("PROJECT_NOT_FOUND_FOR_TASK"); - const doc = snapshot.docs[0]; + const snapshot = await refList.projects.where('sunoTaskId', '==', taskId).limit(1).get() + if (snapshot.empty) throw new Error('PROJECT_NOT_FOUND_FOR_TASK') + const doc = snapshot.docs[0] return { projectId: doc.id, projectData: doc.data() || {}, projectRef: doc.ref, - }; -}; + } +} // ... (Garde refundMusicCredits, markProjectMusicFailure, downloadTrackToStorage, saveTracksToStorage, mergeMusicUrls inchangés) ... // Je remets les versions raccourcies pour l'exemple mais utilise tes fonctions existantes pour le stockage/db @@ -373,130 +347,122 @@ const fetchProjectByTaskId = async (taskId) => { const refundMusicCredits = async ({ projectId, userId, - reason = "music_generation_failed", + reason = 'music_generation_failed', context = {}, }) => { - if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null; + if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null try { const metadata = { source: MUSIC_REFUND_SOURCE, reason, projectId, ...context, - }; + } const { orderId } = await createOrderDocument({ userId, type: ORDER_TYPES.SONG, amount: MUSIC_GENERATION_CREDIT_COST, songId: projectId, - createdBy: "system", + createdBy: 'system', metadata, - }); - logger.log("💸 [Music] Crédits remboursés", { projectId, userId, orderId }); - return { orderId }; + }) + logger.log('💸 [Music] Crédits remboursés', { projectId, userId, orderId }) + return { orderId } } catch (error) { - logger.error("❌ [Music] Échec remboursement", { + logger.error('❌ [Music] Échec remboursement', { projectId, userId, error: error?.message, - }); - return null; + }) + return null } -}; +} async function markProjectMusicFailure(projectId, error) { - if (!projectId) return; + if (!projectId) return try { - const docRef = refList.projects.doc(projectId); - const projectSnap = await docRef.get(); - const projectData = projectSnap?.data() || {}; - const status = error?.response?.status || error?.status || null; + 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 || - error?.response?.data?.message || - error?.message || - "Erreur"; - const errorPayload = { source: "SUNO_API", message: sunoMessage }; + error?.response?.data?.msg || error?.response?.data?.message || error?.message || 'Erreur' + const errorPayload = { source: 'SUNO_API', message: sunoMessage } - const receiverId = sanitizeField(projectData?.userId); - const alreadyRefunded = projectData?.musicCreditsRefunded === true; - let refundResult = null; + const receiverId = sanitizeField(projectData?.userId) + const alreadyRefunded = projectData?.musicCreditsRefunded === true + let refundResult = null if (receiverId && !alreadyRefunded) { refundResult = await refundMusicCredits({ projectId, userId: receiverId, reason: sunoMessage, - }); + }) } await docRef.set( { - musicStatus: "FAILED", + musicStatus: 'FAILED', musicError: errorPayload, updatedAt: FieldValue.serverTimestamp(), }, - { merge: true }, - ); + { merge: true } + ) // (Notification logic here...) } catch (err) { - console.error("Error marking failure", err); + console.error('Error marking failure', err) } } -const downloadTrackToStorage = async ( - url, - { userId, projectId, taskId, bucket, index }, -) => { - if (!url) return null; +const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket, index }) => { + if (!url) return null try { - const resp = await axios.get(url, { responseType: "stream" }); - const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`; - const token = randomUUID(); - const file = bucket.file(path); + const resp = await axios.get(url, { responseType: 'stream' }) + const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3` + const token = randomUUID() + const file = bucket.file(path) const writeStream = file.createWriteStream({ resumable: false, metadata: { - contentType: "audio/mpeg", + contentType: 'audio/mpeg', metadata: { firebaseStorageDownloadTokens: token }, }, - }); - await pipeline(resp.data, writeStream); + }) + await pipeline(resp.data, writeStream) return { path, url: `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`, - }; + } } catch (error) { - return null; + return null } -}; +} const saveTracksToStorage = async (audioUrls, meta) => { - if (!audioUrls?.length) return []; - const bucket = admin.storage().bucket(); + if (!audioUrls?.length) return [] + const bucket = admin.storage().bucket() const results = await Promise.all( - audioUrls.map((url, index) => - downloadTrackToStorage(url, { ...meta, bucket, index }), - ), - ); - return results.filter(Boolean).map((entry) => entry.url); -}; + audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index })) + ) + return results.filter(Boolean).map((entry) => entry.url) +} const mergeMusicUrls = async (projectRef, newUrls) => { - const sanitized = sanitizeMusicUrls(newUrls); - let existing = []; + const sanitized = sanitizeMusicUrls(newUrls) + let existing = [] try { - existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls); + existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls) } catch (e) {} - const final = [...new Set([...existing, ...sanitized])]; + const final = [...new Set([...existing, ...sanitized])] await projectRef.set( { - musicStatus: "GENERATED", + musicStatus: 'GENERATED', musicUrls: final, musicError: FieldValue.delete(), }, - { merge: true }, - ); - return final; -}; + { merge: true } + ) + return final +} // ========================================== // 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS) @@ -506,24 +472,23 @@ exports.generateMusic = onCall(async ({ data = {} }) => { try { // Extraction des données du Frontend const { - title = "", + title = '', lyrics = [], // Tableau d'objets {type, lyrics} ou strings genres = [], // ["Pop", "Rock"] - songStyle = "", // "Upbeat" + songStyle = '', // "Upbeat" voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet) instruments = [], // ["Piano", "Violon"] - rhythm = "", // "Très rapide" + rhythm = '', // "Très rapide" // Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit, // mais ont déjà servi à générer les paroles (lyrics). // audience, context, objective, etc. - } = data; + } = data // Normalisation input Voix (peut être array, string ou objet selon ta spec) - let voiceData = []; - if (Array.isArray(voice)) voiceData = voice; - else if (typeof voice === "object" && voice !== null) - voiceData = Object.values(voice).flat(); - else if (typeof voice === "string") voiceData = [voice]; + let voiceData = [] + if (Array.isArray(voice)) voiceData = voice + else if (typeof voice === 'object' && voice !== null) voiceData = Object.values(voice).flat() + else if (typeof voice === 'string') voiceData = [voice] // 1. Construction du STYLE MUSICAL (Tags) const optimizedStyle = buildSunoStyle({ @@ -532,50 +497,46 @@ exports.generateMusic = onCall(async ({ data = {} }) => { voiceData, instruments, rhythm, - }); + }) // 2. Construction du PROMPT (Paroles + Structure) - const formattedPrompt = buildFormattedLyrics(lyrics); - const safeTitle = clampLen(title, 80); + const formattedPrompt = buildFormattedLyrics(lyrics) + const safeTitle = clampLen(title, 80) // 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // On regarde si on trouve 'male' ou 'female' dans les tags générés - let vGender = null; - if (optimizedStyle.includes("Female")) vGender = "female"; - else if (optimizedStyle.includes("Male")) vGender = "male"; + let vGender = null + if (optimizedStyle.includes('Female')) vGender = 'female' + else if (optimizedStyle.includes('Male')) vGender = 'male' - console.log("🎵 [Suno Optimisation] Result:", { + console.log('🎵 [Suno Optimisation] Result:', { style: optimizedStyle, gender: vGender, title: safeTitle, - promptStructure: formattedPrompt.substring(0, 150) + "...", // Aperçu - }); + promptStructure: formattedPrompt.substring(0, 150) + '...', // Aperçu + }) // 4. Appel API const payload = { customMode: true, instrumental: false, - model: SUNO_MODEL || "chirp-v3-5", // Toujours viser le dernier modèle + model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle prompt: clampLen(formattedPrompt, 3000), title: safeTitle, style: optimizedStyle, - callBackUrl: SUNO_CALLBACK_URL || "", - }; + callBackUrl: SUNO_CALLBACK_URL || '', + } - if (vGender) payload.vocalGender = vGender; + if (vGender) payload.vocalGender = vGender - const response = await axios.post( - `${SUNO_API_BASE}${SUNO_API_PATH}`, - payload, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${SUNO_API_KEY}`, - }, + const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${SUNO_API_KEY}`, }, - ); + }) - const parsed = response.data; + const parsed = response.data return { success: !!parsed?.data?.taskId, @@ -583,97 +544,89 @@ exports.generateMusic = onCall(async ({ data = {} }) => { model: payload.model, title: safeTitle, style: optimizedStyle, - promptPreview: formattedPrompt.substring(0, 50) + "...", + promptPreview: formattedPrompt.substring(0, 50) + '...', }, response: parsed || {}, - }; + } } catch (error) { - console.error("❌ Erreur generateMusic:", error); + console.error('❌ Erreur generateMusic:', error) try { - await markProjectMusicFailure(data?.projectId, error); + await markProjectMusicFailure(data?.projectId, error) } catch (e) {} - const status = error?.response?.status || "INTERNAL_ERROR"; - const message = - error?.response?.data?.msg || error?.message || "Erreur Suno"; - throw new HttpsError("internal", message, { status, source: "SUNO_API" }); + const status = error?.response?.status || 'INTERNAL_ERROR' + const message = error?.response?.data?.msg || error?.message || 'Erreur Suno' + throw new HttpsError('internal', message, { status, source: 'SUNO_API' }) } -}); +}) // GET STATUS (inchangé mais inclus pour complétude) exports.getSunoStatus = onCall(async ({ data = {} }) => { - const { taskId } = data; - if (!taskId) throw new Error("TaskId manquant"); + const { taskId } = data + if (!taskId) throw new Error('TaskId manquant') try { - const response = await axios.get( - `${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, - { - headers: { Authorization: `Bearer ${SUNO_API_KEY}` }, - timeout: 30000, - }, - ); + const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { + headers: { Authorization: `Bearer ${SUNO_API_KEY}` }, + timeout: 30000, + }) return { success: true, taskId, data: response.data?.data || response.data, - }; + } } catch (error) { - if (taskId.includes("test")) + if (taskId.includes('test')) return { success: true, taskId, - data: { status: "not_found", isTestId: true }, - }; - return { success: false, taskId, error: { message: error.message } }; + data: { status: 'not_found', isTestId: true }, + } + return { success: false, taskId, error: { message: error.message } } } -}); +}) // CALLBACK (Standard) -exports.sunoCallback = onRequest( - { methods: ["POST"], memory: "1GiB" }, - async (req, res) => { - if (req.method !== "POST") - return res.status(405).json({ error: "Method not allowed" }); - const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body); +exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (req, res) => { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }) + const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body) - if (code !== 200 || status !== "complete" || !taskId) - return res.status(200).json({ success: true, ignored: true }); + if (code !== 200 || status !== 'complete' || !taskId) + return res.status(200).json({ success: true, ignored: true }) - try { - const { projectId, projectData, projectRef } = - await fetchProjectByTaskId(taskId); - const { userId, projectTitle } = formatProjectMeta(projectData); + try { + const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId) + const { userId, projectTitle } = formatProjectMeta(projectData) - const storedUrls = await saveTracksToStorage( - extractAudioUrlsFromTracks(tracks), - { userId, projectId, taskId }, - ); - const musicUrls = await mergeMusicUrls(projectRef, storedUrls); + const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), { + userId, + projectId, + taskId, + }) + const musicUrls = await mergeMusicUrls(projectRef, storedUrls) - if (userId) { - try { - await sendNotification({ - sender: "SYSTEM", - receiver: userId, - receiverCollection: "users", - title: "Musique prête", - message: `Ta musique pour "${projectTitle}" est prête.`, - data: { - type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS, - projectId, - projectTitle, - musicUrls, - taskId, - }, - }); - } catch (e) { - console.error(e); - } + if (userId) { + try { + await sendNotification({ + sender: 'SYSTEM', + receiver: userId, + receiverCollection: 'users', + title: 'Musique prête', + message: `Ta musique pour "${projectTitle}" est prête.`, + data: { + type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS, + projectId, + projectTitle, + musicUrls, + taskId, + }, + }) + } catch (e) { + console.error(e) } - return res.status(200).json({ success: true, projectId }); - } catch (error) { - // Gestion erreur silencieuse pour le webhook - return res.status(500).json({ error: error.message }); } - }, -); + return res.status(200).json({ success: true, projectId }) + } catch (error) { + // Gestion erreur silencieuse pour le webhook + return res.status(500).json({ error: error.message }) + } +}) diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 3c8b2af..8110d3c 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -1,100 +1,88 @@ -const { - onDocumentCreated, - onDocumentWritten, -} = require("firebase-functions/v2/firestore"); -const { FieldValue } = require("firebase-admin/firestore"); -const { refList, ALERT_TYPE } = require("../index"); -const { Expo } = require("expo-server-sdk"); -const { Resend } = require("resend"); -const { basicTemplate } = require("../helpers/email"); -const { RESEND_API_KEY } = require("../config/keys"); +const { onDocumentCreated, onDocumentWritten } = require('firebase-functions/v2/firestore') +const { FieldValue } = require('firebase-admin/firestore') +const { refList, ALERT_TYPE } = require('../index') +const { Expo } = require('expo-server-sdk') +const { Resend } = require('resend') +const { basicTemplate } = require('../helpers/email') +const { RESEND_API_KEY } = require('../config/keys') -const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null; +const resendInstance = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null // Initialisation de Expo SDK -let expo = new Expo(); -const EMAIL_FROM = "MusicLand "; -const DEFAULT_EMAIL_TITLE = "MusicLand"; +let expo = new Expo() +const EMAIL_FROM = 'MusicLand ' +const DEFAULT_EMAIL_TITLE = 'MusicLand' -function getCollectionRef(collectionName = "") { - const ref = refList?.[collectionName]; +function getCollectionRef(collectionName = '') { + const ref = refList?.[collectionName] if (!ref) { - throw new Error(`Unknown collection "${collectionName}"`); + throw new Error(`Unknown collection "${collectionName}"`) } - return ref; + return ref } function cleanString(value) { - return typeof value === "string" && value.trim() ? value.trim() : null; + return typeof value === 'string' && value.trim() ? value.trim() : null } -function buildNotificationEmailPayload({ - title = "", - message = "", - template = {}, -} = {}) { - const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE; - const fallbackContent = cleanString(message) || ""; - const overrides = template && typeof template === "object" ? template : {}; +function buildNotificationEmailPayload({ title = '', message = '', template = {} } = {}) { + const fallbackTitle = cleanString(title) || DEFAULT_EMAIL_TITLE + const fallbackContent = cleanString(message) || '' + const overrides = template && typeof template === 'object' ? template : {} - const subject = cleanString(overrides.subject) || fallbackTitle; - const emailTitle = cleanString(overrides.title) || fallbackTitle; - const content = cleanString(overrides.content) || fallbackContent; + const subject = cleanString(overrides.subject) || fallbackTitle + const emailTitle = cleanString(overrides.title) || fallbackTitle + const content = cleanString(overrides.content) || fallbackContent - let button = null; - if (overrides.button && typeof overrides.button === "object") { - const buttonUrl = - cleanString(overrides.button.url) || cleanString(overrides.button.href); + let button = null + if (overrides.button && typeof overrides.button === 'object') { + const buttonUrl = cleanString(overrides.button.url) || cleanString(overrides.button.href) if (buttonUrl) { button = { url: buttonUrl, label: - cleanString(overrides.button.label) || - cleanString(overrides.button.text) || - undefined, - }; + cleanString(overrides.button.label) || cleanString(overrides.button.text) || undefined, + } } } - const templatePayload = { title: emailTitle, content }; + const templatePayload = { title: emailTitle, content } if (button) { - templatePayload.button = button; + templatePayload.button = button } return { subject, html: basicTemplate(templatePayload), - }; + } } exports.sendNotificationWhenDocIsCreated = onDocumentCreated( - { region: "europe-west1", document: "notifications/{notificationId}" }, + { region: 'europe-west1', document: 'notifications/{notificationId}' }, async (event) => { try { const { receiver = null, - title = "MusicLand", - receiverCollection = "users", - message = "", + title = 'MusicLand', + receiverCollection = 'users', + message = '', data: notifData = {}, mailOnly = false, - } = event.data.data(); + } = event.data.data() if (!receiver || !message) { - throw new Error("Receiver and message are required"); + throw new Error('Receiver and message are required') } - const receiverSnap = await getCollectionRef(receiverCollection) - .doc(receiver) - .get(); - const receiverData = receiverSnap.exists ? receiverSnap.data() : {}; + const receiverSnap = await getCollectionRef(receiverCollection).doc(receiver).get() + const receiverData = receiverSnap.exists ? receiverSnap.data() : {} const { pushToken = null, pushTokens = [], - email: receiverEmail = "", + email: receiverEmail = '', emailNotifications = false, - } = receiverData; + } = receiverData if (!mailOnly) { try { @@ -102,129 +90,124 @@ exports.sendNotificationWhenDocIsCreated = onDocumentCreated( [] .concat(Array.isArray(pushTokens) ? pushTokens : []) .concat(pushToken ? [pushToken] : []) - .filter(Boolean), - ); - const tokens = Array.from(tokensSet); + .filter(Boolean) + ) + const tokens = Array.from(tokensSet) if (!tokens?.length) { await sendExpoNotification({ tokens, receiverId: receiver, receiverCollection, - title: title || "MusicLand", + title: title || 'MusicLand', message: message, data: notifData || {}, - }); + }) } else { - console.log("User push token not found"); + console.log('User push token not found') } } catch (e) { - console.log("Error sending notif:", e); + console.log('Error sending notif:', e) } } if ((emailNotifications || mailOnly) && !!receiverEmail) { if (!resendInstance) { - console.warn( - "Resend client not configured; unable to send notification email.", - ); + console.warn('Resend client not configured; unable to send notification email.') } else { try { const { subject, html } = buildNotificationEmailPayload({ title, message, template: notifData?.email, - }); + }) await resendInstance.emails.send({ from: EMAIL_FROM, to: [receiverEmail], subject, html, - }); + }) } catch (e) { - console.log("Error sending email:", e); + console.log('Error sending email:', e) } } } else { console.log( - `[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? "user preference" : "missing email"}) for user ${receiver} and type ${notifData?.type || "UNKNOWN"}`, - ); + `[sendNotificationWhenDocIsCreated] Email not sent (${receiverEmail ? 'user preference' : 'missing email'}) for user ${receiver} and type ${notifData?.type || 'UNKNOWN'}` + ) } } catch (e) { - console.log(e); - return e; + console.log(e) + return e } - }, -); + } +) // Fonction pour envoyer la notification via Expo SDK async function sendExpoNotification({ tokens = [], - title = "", - message = "", + title = '', + message = '', data = {}, receiverId = null, - receiverCollection = "users", + receiverCollection = 'users', }) { try { - const candidateTokens = Array.isArray(tokens) ? tokens : [tokens]; - const validTokens = []; - const invalidTokens = []; + const candidateTokens = Array.isArray(tokens) ? tokens : [tokens] + const validTokens = [] + const invalidTokens = [] candidateTokens.forEach((token) => { if (Expo.isExpoPushToken(token)) { - validTokens.push(token); + validTokens.push(token) } else if (token) { - invalidTokens.push(token); + invalidTokens.push(token) } - }); + }) if (invalidTokens.length && receiverId) { await removeInvalidTokens({ tokens: invalidTokens, receiverId, receiverCollection, - }); + }) } if (!validTokens.length) { - console.warn("No valid Expo push tokens to send notification"); - return { sent: false }; + console.warn('No valid Expo push tokens to send notification') + return { sent: false } } const messages = validTokens.map((token) => ({ to: token, - sound: "default", + sound: 'default', title: title, body: message, data: data || {}, - priority: "high", + priority: 'high', badge: 1, - channelId: "default", - })); + channelId: 'default', + })) - const chunks = expo.chunkPushNotifications(messages); - const receipts = []; - const tokensToPrune = new Set(); + const chunks = expo.chunkPushNotifications(messages) + const receipts = [] + const tokensToPrune = new Set() for (const chunk of chunks) { - const chunkReceipts = await expo.sendPushNotificationsAsync(chunk); + const chunkReceipts = await expo.sendPushNotificationsAsync(chunk) chunkReceipts.forEach((receipt, index) => { - if (receipt?.status === "error") { - const errorCode = receipt?.details?.error || receipt?.details?.code; - console.log("Error sending notification:", receipt); - if ( - errorCode === "DeviceNotRegistered" || - errorCode === "PushTokenNotRegistered" - ) { - const token = chunk[index]?.to; + if (receipt?.status === 'error') { + const errorCode = receipt?.details?.error || receipt?.details?.code + console.log('Error sending notification:', receipt) + if (errorCode === 'DeviceNotRegistered' || errorCode === 'PushTokenNotRegistered') { + const token = chunk[index]?.to if (token) { - tokensToPrune.add(token); + tokensToPrune.add(token) } } } - }); - receipts.push(...chunkReceipts); + }) + receipts.push(...chunkReceipts) } if (tokensToPrune.size && receiverId) { @@ -232,68 +215,64 @@ async function sendExpoNotification({ tokens: Array.from(tokensToPrune), receiverId, receiverCollection, - }); + }) } - console.log("Sent push notifications:", receipts); + console.log('Sent push notifications:', receipts) - return { sent: true }; + return { sent: true } } catch (e) { - console.log("Error sending notification:", e); - throw e; + console.log('Error sending notification:', e) + throw e } } -async function removeInvalidTokens({ - tokens = [], - receiverId, - receiverCollection, -}) { +async function removeInvalidTokens({ tokens = [], receiverId, receiverCollection }) { try { if (!receiverId || !tokens.length) { - return; + return } - const uniqueTokens = Array.from(new Set(tokens.filter(Boolean))); + const uniqueTokens = Array.from(new Set(tokens.filter(Boolean))) if (!uniqueTokens.length) { - return; + return } - const docRef = getCollectionRef(receiverCollection).doc(receiverId); - const userSnap = await docRef.get(); - const userData = userSnap?.data() || {}; + const docRef = getCollectionRef(receiverCollection).doc(receiverId) + const userSnap = await docRef.get() + const userData = userSnap?.data() || {} const updates = { pushTokens: FieldValue.arrayRemove(...uniqueTokens), - }; - - if (uniqueTokens.includes(userData?.pushToken)) { - updates.pushToken = FieldValue.delete(); } - await docRef.set(updates, { merge: true }); + if (uniqueTokens.includes(userData?.pushToken)) { + updates.pushToken = FieldValue.delete() + } + + await docRef.set(updates, { merge: true }) console.log( - "Pruned invalid push tokens", - JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2), - ); + 'Pruned invalid push tokens', + JSON.stringify({ receiverId, tokens: uniqueTokens }, null, 2) + ) } catch (error) { - console.log("Failed to prune invalid push tokens:", error); + console.log('Failed to prune invalid push tokens:', error) } } // Fonction pour ajouter une notification à la base de données const sendNotification = async ({ - sender = "SYSTEM", + sender = 'SYSTEM', receiver = null, - receiverCollection = "users", - title = "", + receiverCollection = 'users', + title = '', message = null, mailOnly = false, data = {}, }) => { try { if (!receiver || !message) { - throw new Error("Receiver and message are required"); + throw new Error('Receiver and message are required') } const payload = { sender, @@ -306,81 +285,78 @@ const sendNotification = async ({ readAt: null, mailOnly, data, - }; - const { id } = await refList.notifications.add(payload); + } + const { id } = await refList.notifications.add(payload) console.log( - "[sendNotification] Notification created", - JSON.stringify({ id, receiver, receiverCollection }, null, 2), - ); + '[sendNotification] Notification created', + JSON.stringify({ id, receiver, receiverCollection }, null, 2) + ) - return id; + return id } catch (e) { - console.log("[sendNotification] Error creating notification:", e); + console.log('[sendNotification] Error creating notification:', e) } -}; +} -exports.sendNotification = sendNotification; +exports.sendNotification = sendNotification exports.createProjectCommentNotification = onDocumentCreated( { - region: "europe-west1", - document: "projects/{projectId}/comments/{commentId}", + region: 'europe-west1', + document: 'projects/{projectId}/comments/{commentId}', }, async (event) => { try { console.log( - "[createProjectCommentNotification] Trigger received", - JSON.stringify(event.params || {}, null, 2), - ); - const { data: snap } = event; - const { projectId, commentId } = event.params || {}; - const comment = snap?.data(); + '[createProjectCommentNotification] Trigger received', + JSON.stringify(event.params || {}, null, 2) + ) + const { data: snap } = event + const { projectId, commentId } = event.params || {} + const comment = snap?.data() if (!projectId || !comment) { - console.log( - "[createProjectCommentNotification] Missing project/comment data", - { hasProjectId: !!projectId, hasComment: !!comment }, - ); - return null; + console.log('[createProjectCommentNotification] Missing project/comment data', { + hasProjectId: !!projectId, + hasComment: !!comment, + }) + return null } console.log( - "[createProjectCommentNotification] Comment payload", - JSON.stringify(comment, null, 2), - ); + '[createProjectCommentNotification] Comment payload', + JSON.stringify(comment, null, 2) + ) - const projectSnap = await refList.projects.doc(projectId).get(); + const projectSnap = await refList.projects.doc(projectId).get() if (!projectSnap.exists) { - console.log( - "[createProjectCommentNotification] Project not found", - projectId, - ); - return null; + console.log('[createProjectCommentNotification] Project not found', projectId) + return null } - const project = projectSnap.data() || {}; - const receiver = project.userId || null; + const project = projectSnap.data() || {} + const receiver = project.userId || null if (!receiver || receiver === comment.userId) { console.log( - "[createProjectCommentNotification] Invalid receiver", - JSON.stringify({ receiver, commentUserId: comment.userId }), - ); - return null; + '[createProjectCommentNotification] Invalid receiver', + JSON.stringify({ receiver, commentUserId: comment.userId }) + ) + return null } const commenterName = - typeof comment?.userName === "string" && comment.userName.trim() + typeof comment?.userName === 'string' && comment.userName.trim() ? comment.userName.trim() - : "Un utilisateur"; + : 'Un utilisateur' const projectTitle = - typeof project.title === "string" && project.title.trim() + typeof project.title === 'string' && project.title.trim() ? project.title.trim() - : "ton projet"; - const message = `${commenterName} a commenté ton projet "${projectTitle}"`; + : 'ton projet' + const message = `${commenterName} a commenté ton projet "${projectTitle}"` console.log( - "[createProjectCommentNotification] Creating notification", + '[createProjectCommentNotification] Creating notification', JSON.stringify( { receiver, @@ -389,15 +365,15 @@ exports.createProjectCommentNotification = onDocumentCreated( projectId, }, null, - 2, - ), - ); + 2 + ) + ) await sendNotification({ - sender: comment.userId || "SYSTEM", + sender: comment.userId || 'SYSTEM', receiver, - receiverCollection: "users", - title: "Nouveau commentaire", + receiverCollection: 'users', + title: 'Nouveau commentaire', message, data: { type: ALERT_TYPE?.NEW_COMMENT, @@ -405,109 +381,94 @@ exports.createProjectCommentNotification = onDocumentCreated( commentId, commenterId: comment.userId || null, commenterName: commenterName, - commenterProfilePicture: comment?.profilePicture || "", - text: - typeof comment?.text === "string" && comment.text.trim() - ? comment.text.trim() - : "", + commenterProfilePicture: comment?.profilePicture || '', + text: typeof comment?.text === 'string' && comment.text.trim() ? comment.text.trim() : '', }, - }); + }) - console.log( - "[createProjectCommentNotification] Notification creation complete", - ); + console.log('[createProjectCommentNotification] Notification creation complete') - return null; + return null } catch (error) { - console.log("createProjectCommentNotification error:", error); - return error; + console.log('createProjectCommentNotification error:', error) + return error } - }, -); + } +) exports.createProjectLikeNotification = onDocumentWritten( { - region: "europe-west1", - document: "projects/{projectId}", + region: 'europe-west1', + document: 'projects/{projectId}', }, async (event) => { try { - const { projectId } = event.params || {}; - const before = event?.data?.before?.data() || {}; - const after = event?.data?.after?.data() || {}; + const { projectId } = event.params || {} + const before = event?.data?.before?.data() || {} + const after = event?.data?.after?.data() || {} if (!projectId || !after) { - return null; + return null } - const ownerId = after.userId || null; + const ownerId = after.userId || null if (!ownerId) { - return null; + return null } - const beforeSongLikes = Array.isArray(before?.likes?.song) - ? before.likes.song - : []; - const afterSongLikes = Array.isArray(after?.likes?.song) - ? after.likes.song - : []; + const beforeSongLikes = Array.isArray(before?.likes?.song) ? before.likes.song : [] + const afterSongLikes = Array.isArray(after?.likes?.song) ? after.likes.song : [] const beforePlaybackLikes = Array.isArray(before?.likes?.playback) ? before.likes.playback - : []; - const afterPlaybackLikes = Array.isArray(after?.likes?.playback) - ? after.likes.playback - : []; + : [] + const afterPlaybackLikes = Array.isArray(after?.likes?.playback) ? after.likes.playback : [] - const beforeSongSet = new Set(beforeSongLikes); - const beforePlaybackSet = new Set(beforePlaybackLikes); + const beforeSongSet = new Set(beforeSongLikes) + const beforePlaybackSet = new Set(beforePlaybackLikes) - const newSongLikers = afterSongLikes.filter( - (uid) => uid && !beforeSongSet.has(uid), - ); + const newSongLikers = afterSongLikes.filter((uid) => uid && !beforeSongSet.has(uid)) const newPlaybackLikers = afterPlaybackLikes.filter( - (uid) => uid && !beforePlaybackSet.has(uid), - ); + (uid) => uid && !beforePlaybackSet.has(uid) + ) - const newLikers = []; + const newLikers = [] newSongLikers.forEach((uid) => { - newLikers.push({ likerId: uid, likeType: "song" }); - }); + newLikers.push({ likerId: uid, likeType: 'song' }) + }) newPlaybackLikers.forEach((uid) => { - newLikers.push({ likerId: uid, likeType: "playback" }); - }); + newLikers.push({ likerId: uid, likeType: 'playback' }) + }) if (!newLikers.length) { - return null; + return null } const projectTitle = - typeof after.title === "string" && after.title.trim() - ? after.title.trim() - : "ton projet"; + typeof after.title === 'string' && after.title.trim() ? after.title.trim() : 'ton projet' await Promise.all( newLikers.map(async ({ likerId, likeType }) => { if (!likerId || likerId === ownerId) { - return null; + return null } - const likerSnap = await refList.users.doc(likerId).get(); - const liker = likerSnap?.data() || {}; + const likerSnap = await refList.users.doc(likerId).get() + const liker = likerSnap?.data() || {} const likerName = - typeof liker?.userName === "string" && liker.userName.trim() + typeof liker?.userName === 'string' && liker.userName.trim() ? liker.userName.trim() - : "Un utilisateur"; + : 'Un utilisateur' - const isPlaybackLike = likeType === "playback"; - const assetLabel = isPlaybackLike ? "ton playback" : "ta musique"; - const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"`; + const isPlaybackLike = likeType === 'playback' + const assetLabel = isPlaybackLike ? 'ton playback' : 'ta musique' + const message = `${likerName} a aimé ${assetLabel} "${projectTitle}"` await sendNotification({ sender: likerId, receiver: ownerId, - receiverCollection: "users", - title: "Nouveau like", + receiverCollection: 'users', + title: 'Nouveau like', message, data: { type: ALERT_TYPE?.NEW_LIKE, @@ -516,92 +477,86 @@ exports.createProjectLikeNotification = onDocumentWritten( likerName, likeType, }, - }); + }) - return null; - }), - ); + return null + }) + ) - return null; + return null } catch (error) { - console.log("[createProjectLikeNotification] error:", error); - return error; + console.log('[createProjectLikeNotification] error:', error) + return error } - }, -); + } +) exports.createNewFollowerNotification = onDocumentWritten( { - region: "europe-west1", - document: "users/{userId}", + region: 'europe-west1', + document: 'users/{userId}', }, async (event) => { try { - const { userId } = event.params || {}; - const before = event?.data?.before?.data() || {}; - const after = event?.data?.after?.data() || {}; + const { userId } = event.params || {} + const before = event?.data?.before?.data() || {} + const after = event?.data?.after?.data() || {} if (!userId || !after) { - return null; + return null } - const beforeFollowers = Array.isArray(before?.followedBy) - ? before.followedBy - : []; - const afterFollowers = Array.isArray(after?.followedBy) - ? after.followedBy - : []; + const beforeFollowers = Array.isArray(before?.followedBy) ? before.followedBy : [] + const afterFollowers = Array.isArray(after?.followedBy) ? after.followedBy : [] if (afterFollowers.length <= beforeFollowers.length) { - return null; + return null } - const previousSet = new Set(beforeFollowers); - const newFollowers = afterFollowers.filter( - (uid) => !previousSet.has(uid), - ); + const previousSet = new Set(beforeFollowers) + const newFollowers = afterFollowers.filter((uid) => !previousSet.has(uid)) if (!newFollowers.length) { - return null; + return null } await Promise.all( newFollowers.map(async (followerId) => { if (!followerId || followerId === userId) { - return null; + return null } - const followerSnap = await refList.users.doc(followerId).get(); - const follower = followerSnap?.data() || {}; + const followerSnap = await refList.users.doc(followerId).get() + const follower = followerSnap?.data() || {} const followerName = - typeof follower?.userName === "string" && follower.userName.trim() + typeof follower?.userName === 'string' && follower.userName.trim() ? follower.userName.trim() - : "Un utilisateur"; + : 'Un utilisateur' - const message = `${followerName} te suit maintenant`; + const message = `${followerName} te suit maintenant` await sendNotification({ sender: followerId, receiver: userId, - receiverCollection: "users", - title: "Nouvel abonné", + receiverCollection: 'users', + title: 'Nouvel abonné', message, data: { type: ALERT_TYPE?.NEW_FOLLOWER, followerId, followerName, - followerProfilePicture: follower?.profilePicture || "", + followerProfilePicture: follower?.profilePicture || '', }, - }); + }) - return null; - }), - ); + return null + }) + ) - return null; + return null } catch (error) { - console.log("[createNewFollowerNotification] error:", error); - return error; + console.log('[createNewFollowerNotification] error:', error) + return error } - }, -); + } +) diff --git a/functions/src/orders.js b/functions/src/orders.js index bfd8722..e21c8d4 100644 --- a/functions/src/orders.js +++ b/functions/src/orders.js @@ -1,86 +1,81 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { onDocumentCreated } = require("firebase-functions/firestore"); -const { HttpsError, onCall } = require("firebase-functions/https"); +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { onDocumentCreated } = require('firebase-functions/firestore') +const { HttpsError, onCall } = require('firebase-functions/https') -const { REGION, ALERT_TYPE } = require("../index"); -const { sendNotification } = require("./notifications"); +const { REGION, ALERT_TYPE } = require('../index') +const { sendNotification } = require('./notifications') const { ORDER_TYPES, ORDER_STATUS, ORDERS_COLLECTION, createOrderDocument, normalizeAmount, -} = require("./helpers/orders"); +} = require('./helpers/orders') -const USERS_COLLECTION = "users"; +const USERS_COLLECTION = 'users' const formatCoinsText = (value) => { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; + if (typeof value !== 'number' || Number.isNaN(value)) { + return null } - const absoluteValue = Math.abs(value); + const absoluteValue = Math.abs(value) if (!Number.isFinite(absoluteValue)) { - return null; + return null } - const formatted = Number.isInteger(absoluteValue) - ? `${absoluteValue}` - : absoluteValue.toFixed(2); - const suffix = absoluteValue === 1 ? "crédit" : "crédits"; + const formatted = Number.isInteger(absoluteValue) ? `${absoluteValue}` : absoluteValue.toFixed(2) + const suffix = absoluteValue === 1 ? 'crédit' : 'crédits' - return `${formatted} ${suffix}`; -}; + return `${formatted} ${suffix}` +} const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => { - if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) { - return null; + if (typeof amount !== 'number' || Number.isNaN(amount) || amount === 0) { + return null } - const coinsText = formatCoinsText(amount); + const coinsText = formatCoinsText(amount) if (!coinsText) { - return null; + return null } - const balanceText = formatCoinsText(balanceAfter); - const balanceSentence = balanceText - ? ` Ton solde est maintenant de ${balanceText}.` - : ""; + const balanceText = formatCoinsText(balanceAfter) + const balanceSentence = balanceText ? ` Ton solde est maintenant de ${balanceText}.` : '' if (amount > 0) { if (orderType === ORDER_TYPES.COINS) { return { - title: "Crédits achetés", + title: 'Crédits achetés', message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`, - action: "PURCHASED", - }; + action: 'PURCHASED', + } } if (orderType === ORDER_TYPES.GIFT) { return { - title: "Crédits reçus", + title: 'Crédits reçus', message: `Tu as reçu ${coinsText}.${balanceSentence}`, - action: "EARNED", - }; + action: 'EARNED', + } } return { - title: "Crédits ajoutés", + title: 'Crédits ajoutés', message: `Ton solde augmente de ${coinsText}.${balanceSentence}`, - action: "CREDITED", - }; + action: 'CREDITED', + } } - const reason = - orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : ""; + const reason = orderType === ORDER_TYPES.SONG ? ' pour générer un nouveau son' : '' return { - title: "Crédits dépensés", + title: 'Crédits dépensés', message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`, - action: "SPENT", - }; -}; + action: 'SPENT', + } +} const notifyOrderApplied = async ({ userId, @@ -95,226 +90,200 @@ const notifyOrderApplied = async ({ amount, orderType, balanceAfter, - }); + }) if (!content || !userId) { - return; + return } try { await sendNotification({ - sender: "SYSTEM", + sender: 'SYSTEM', receiver: userId, receiverCollection: USERS_COLLECTION, title: content.title, message: content.message, data: { - type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED", + type: ALERT_TYPE?.CREDITS_UPDATED || 'CREDITS_UPDATED', orderId, orderType: orderType || null, amount, balanceBefore, balanceAfter, action: content.action, - source: - typeof metadata?.source === "string" ? metadata.source : null, + source: typeof metadata?.source === 'string' ? metadata.source : null, metadata: metadata || {}, }, - }); + }) } catch (error) { - console.error( - "[orders-onOrderCreated] Failed to send notification", - orderId, - error, - ); + console.error('[orders-onOrderCreated] Failed to send notification', orderId, error) } -}; +} -const onOrderCreated = onDocumentCreated( - `${ORDERS_COLLECTION}/{orderId}`, - async (event) => { - const orderRef = event?.data?.ref; - const orderData = event?.data?.data(); +const onOrderCreated = onDocumentCreated(`${ORDERS_COLLECTION}/{orderId}`, async (event) => { + const orderRef = event?.data?.ref + const orderData = event?.data?.data() - if (!orderRef || !orderData) { - return; - } + if (!orderRef || !orderData) { + return + } - if (orderData?.processedAt) { - return; - } + if (orderData?.processedAt) { + return + } - const userId = - typeof orderData.userId === "string" ? orderData.userId.trim() : ""; - const amount = normalizeAmount(orderData.amount); + const userId = typeof orderData.userId === 'string' ? orderData.userId.trim() : '' + const amount = normalizeAmount(orderData.amount) - if (!userId) { - await orderRef.set( - { - status: ORDER_STATUS.REJECTED, - processedAt: FieldValue.serverTimestamp(), - failureReason: "USER_NOT_FOUND", - }, - { merge: true }, - ); - return; - } + if (!userId) { + await orderRef.set( + { + status: ORDER_STATUS.REJECTED, + processedAt: FieldValue.serverTimestamp(), + failureReason: 'USER_NOT_FOUND', + }, + { merge: true } + ) + return + } - if (amount === null || amount === 0) { - await orderRef.set( - { - status: ORDER_STATUS.REJECTED, - processedAt: FieldValue.serverTimestamp(), - failureReason: "INVALID_AMOUNT", - }, - { merge: true }, - ); - return; - } + if (amount === null || amount === 0) { + await orderRef.set( + { + status: ORDER_STATUS.REJECTED, + processedAt: FieldValue.serverTimestamp(), + failureReason: 'INVALID_AMOUNT', + }, + { merge: true } + ) + return + } - const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId); + const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId) - let notificationContext = null; + let notificationContext = null - try { - await admin.firestore().runTransaction(async (transaction) => { - const userSnapshot = await transaction.get(userRef); - const userData = userSnapshot?.data() || {}; - const currentBalanceValue = normalizeAmount(userData?.coins); - const currentBalance = - currentBalanceValue !== null ? currentBalanceValue : 0; + try { + await admin.firestore().runTransaction(async (transaction) => { + const userSnapshot = await transaction.get(userRef) + const userData = userSnapshot?.data() || {} + const currentBalanceValue = normalizeAmount(userData?.coins) + const currentBalance = currentBalanceValue !== null ? currentBalanceValue : 0 - const nextBalance = currentBalance + amount; - - if ( - amount < 0 && - nextBalance < 0 && - orderData?.type === ORDER_TYPES.SONG - ) { - transaction.set( - orderRef, - { - status: ORDER_STATUS.REJECTED, - processedAt: FieldValue.serverTimestamp(), - failureReason: "INSUFFICIENT_FUNDS", - balanceBefore: currentBalance, - balanceAfter: currentBalance, - }, - { merge: true }, - ); - return; - } - - if (userSnapshot?.exists) { - transaction.update(userRef, { - coins: nextBalance, - updatedAt: FieldValue.serverTimestamp(), - }); - } else { - transaction.set( - userRef, - { - coins: nextBalance, - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), - }, - { merge: true }, - ); - } + const nextBalance = currentBalance + amount + if (amount < 0 && nextBalance < 0 && orderData?.type === ORDER_TYPES.SONG) { transaction.set( orderRef, { - status: ORDER_STATUS.APPLIED, + status: ORDER_STATUS.REJECTED, processedAt: FieldValue.serverTimestamp(), + failureReason: 'INSUFFICIENT_FUNDS', balanceBefore: currentBalance, - balanceAfter: nextBalance, + balanceAfter: currentBalance, }, - { merge: true }, - ); + { merge: true } + ) + return + } - notificationContext = { + if (userSnapshot?.exists) { + transaction.update(userRef, { + coins: nextBalance, + updatedAt: FieldValue.serverTimestamp(), + }) + } else { + transaction.set( + userRef, + { + coins: nextBalance, + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true } + ) + } + + transaction.set( + orderRef, + { + status: ORDER_STATUS.APPLIED, + processedAt: FieldValue.serverTimestamp(), balanceBefore: currentBalance, balanceAfter: nextBalance, - }; - }); - } catch (error) { - console.error( - "[orders-onOrderCreated] Failed to process order", - orderRef.id, - error, - ); - - await orderRef.set( - { - status: ORDER_STATUS.REJECTED, - processedAt: FieldValue.serverTimestamp(), - failureReason: "PROCESSING_ERROR", - errorMessage: error?.message || String(error), }, - { merge: true }, - ); - return; - } + { merge: true } + ) - if (notificationContext) { - await notifyOrderApplied({ - userId, - orderId: orderRef.id, - amount, - orderType: - typeof orderData?.type === "string" ? orderData.type : null, - balanceBefore: notificationContext.balanceBefore, - balanceAfter: notificationContext.balanceAfter, - metadata: orderData?.metadata || {}, - }); - } - }, -); + notificationContext = { + balanceBefore: currentBalance, + balanceAfter: nextBalance, + } + }) + } catch (error) { + console.error('[orders-onOrderCreated] Failed to process order', orderRef.id, error) -const createSongOrder = onCall({ region: REGION }, async (request) => { - const { auth, data } = request || {}; - - if (!auth?.uid) { - throw new HttpsError("unauthenticated", "Authentification requise."); + await orderRef.set( + { + status: ORDER_STATUS.REJECTED, + processedAt: FieldValue.serverTimestamp(), + failureReason: 'PROCESSING_ERROR', + errorMessage: error?.message || String(error), + }, + { merge: true } + ) + return } - const amount = normalizeAmount(data?.amount); + if (notificationContext) { + await notifyOrderApplied({ + userId, + orderId: orderRef.id, + amount, + orderType: typeof orderData?.type === 'string' ? orderData.type : null, + balanceBefore: notificationContext.balanceBefore, + balanceAfter: notificationContext.balanceAfter, + metadata: orderData?.metadata || {}, + }) + } +}) + +const createSongOrder = onCall({ region: REGION }, async (request) => { + const { auth, data } = request || {} + + if (!auth?.uid) { + throw new HttpsError('unauthenticated', 'Authentification requise.') + } + + const amount = normalizeAmount(data?.amount) if (amount === null || amount >= 0) { throw new HttpsError( - "invalid-argument", - "Le montant doit être négatif pour un achat de musique.", - ); + 'invalid-argument', + 'Le montant doit être négatif pour un achat de musique.' + ) } - const songId = - typeof data?.songId === "string" && data.songId.trim() - ? data.songId.trim() - : null; + const songId = typeof data?.songId === 'string' && data.songId.trim() ? data.songId.trim() : null - const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid); - const userSnapshot = await userRef.get(); - const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins); - const currentCoins = - currentCoinsValue !== null ? currentCoinsValue : 0; + const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid) + const userSnapshot = await userRef.get() + const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins) + const currentCoins = currentCoinsValue !== null ? currentCoinsValue : 0 if (currentCoins + amount < 0) { - throw new HttpsError( - "failed-precondition", - "Crédits insuffisants pour finaliser l'opération.", - ); + throw new HttpsError('failed-precondition', "Crédits insuffisants pour finaliser l'opération.") } const metadata = { source: - typeof data?.source === "string" && data.source.trim() + typeof data?.source === 'string' && data.source.trim() ? data.source.trim() - : "music_generation", - }; + : 'music_generation', + } - if (typeof data?.requestId === "string" && data.requestId.trim()) { - metadata.requestId = data.requestId.trim(); + if (typeof data?.requestId === 'string' && data.requestId.trim()) { + metadata.requestId = data.requestId.trim() } const { orderId } = await createOrderDocument({ @@ -324,12 +293,12 @@ const createSongOrder = onCall({ region: REGION }, async (request) => { songId, createdBy: auth.uid, metadata, - }); + }) - return { orderId }; -}); + return { orderId } +}) module.exports = { onOrderCreated, createSongOrder, -}; +} diff --git a/functions/src/payouts.js b/functions/src/payouts.js index d94f2d7..4a02b2e 100644 --- a/functions/src/payouts.js +++ b/functions/src/payouts.js @@ -1,151 +1,134 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { onSchedule } = require("firebase-functions/v2/scheduler"); -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 admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { onSchedule } = require('firebase-functions/v2/scheduler') +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 ACTIVE_SUBSCRIPTION_STATUSES = new Set(["active"]); +const DISTRIBUTION_REVENUE_BASELINE = 1000 +const DISTRIBUTION_RATIO = 0.3 +const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active']) const hasActiveSubscription = (userData) => { - if (!userData || typeof userData !== "object") { - return false; + if (!userData || typeof userData !== 'object') { + return false } - const isPremium = userData.isPremium === true; + const isPremium = userData.isPremium === true if (!isPremium) { - return false; + return false } const status = - typeof userData.stripeSubscriptionStatus === "string" + typeof userData.stripeSubscriptionStatus === 'string' ? userData.stripeSubscriptionStatus.trim().toLowerCase() - : null; + : null if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { - return true; + return true } const billingPeriod = - typeof userData.premiumBillingPeriod === "string" + typeof userData.premiumBillingPeriod === 'string' ? userData.premiumBillingPeriod.trim().toLowerCase() - : null; - if (billingPeriod === "monthly" || billingPeriod === "annual") { + : null + if (billingPeriod === 'monthly' || billingPeriod === 'annual') { // Fallback: billing period is set only for active subscribers. - return true; + return true } - return false; -}; + return false +} exports.distributeMonthlyPayouts = onSchedule( { - schedule: "0 1 1 * *", - timeZone: "Europe/Paris", + schedule: '0 1 1 * *', + timeZone: 'Europe/Paris', }, async (event) => { - const { scheduleTime } = event; - const context = buildPreviousMonthContext( - scheduleTime ? new Date(scheduleTime) : new Date(), - ); - const monthKey = buildMonthKey(context.rangeStart); - const now = admin.firestore.Timestamp.now(); + const { scheduleTime } = event + const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date()) + const monthKey = buildMonthKey(context.rangeStart) + const now = admin.firestore.Timestamp.now() const statsSnapshot = await db - .collectionGroup("monthlyListens") - .where("monthKey", "==", monthKey) - .orderBy("streams", "desc") - .get(); + .collectionGroup('monthlyListens') + .where('monthKey', '==', monthKey) + .orderBy('streams', 'desc') + .get() - const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey); - const totalsSnapshot = await totalsDocRef.get(); + const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey) + const totalsSnapshot = await totalsDocRef.get() const entries = _.chain(statsSnapshot.docs) .map((doc) => { - const data = doc.data() || {}; + const data = doc.data() || {} return { - projectId: - _.get(data, "projectId") || doc.ref.parent.parent?.id || null, - userId: _.get(data, "userId", null), - streams: _.toFinite(_.get(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) - .orderBy(["streams"], ["desc"]) - .value(); + .orderBy(['streams'], ['desc']) + .value() - const userEligibilityMap = {}; - const userIds = _.uniq( - entries.map((entry) => entry.userId).filter((userId) => !!userId), - ); + const userEligibilityMap = {} + const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId)) if (userIds.length) { - const chunkSize = 300; + const chunkSize = 300 for (let index = 0; index < userIds.length; index += chunkSize) { - const chunk = userIds.slice(index, index + chunkSize); + const chunk = userIds.slice(index, index + chunkSize) const snapshots = await Promise.all( chunk.map(async (userId) => { try { - return await refList.users.doc(userId).get(); + return await refList.users.doc(userId).get() } catch (error) { - console.warn( - "[distributeMonthlyPayouts] Unable to load user profile", - { - userId, - error: error?.message || String(error), - }, - ); - return null; + console.warn('[distributeMonthlyPayouts] Unable to load user profile', { + userId, + error: error?.message || String(error), + }) + return null } - }), - ); + }) + ) snapshots.forEach((snapshot, snapshotIndex) => { - const userId = chunk[snapshotIndex]; + const userId = chunk[snapshotIndex] if (snapshot?.exists) { - userEligibilityMap[userId] = hasActiveSubscription( - snapshot.data(), - ); + userEligibilityMap[userId] = hasActiveSubscription(snapshot.data()) } else { - userEligibilityMap[userId] = false; + userEligibilityMap[userId] = false } - }); + }) } } const eligibleEntries = entries.filter( - (entry) => - !!entry.userId && userEligibilityMap[entry.userId] === true, - ); - const eligibleTotalStreams = _.sumBy(eligibleEntries, "streams"); + (entry) => !!entry.userId && userEligibilityMap[entry.userId] === true + ) + const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams') - const payoutsTotalStreamsFromDocs = _.sumBy(entries, "streams"); - const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null; - let totalStreams = _.toFinite(_.get(totalsData, "totalStreams", 0)); + 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; + totalStreams = payoutsTotalStreamsFromDocs } - const payoutPool = _.round( - DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, - 2, - ); + const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2) let allocations = _.map(eligibleEntries, (entry) => { - if (!eligibleTotalStreams) return 0; - const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams; - return _.round(rawAmount, 2); - }); + if (!eligibleTotalStreams) return 0 + const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams + return _.round(rawAmount, 2) + }) if (allocations.length > 0) { - const allocatedTotal = _.round(_.sum(allocations), 2); - const remainder = _.round(payoutPool - allocatedTotal, 2); + const allocatedTotal = _.round(_.sum(allocations), 2) + const remainder = _.round(payoutPool - allocatedTotal, 2) if (remainder !== 0) { - allocations[0] = _.round(allocations[0] + remainder, 2); + allocations[0] = _.round(allocations[0] + remainder, 2) } } @@ -154,40 +137,36 @@ exports.distributeMonthlyPayouts = onSchedule( projectId: entry.projectId, userId: entry.userId, streams: entry.streams, - share: eligibleTotalStreams - ? _.round(entry.streams / eligibleTotalStreams, 6) - : 0, + share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0, amount: allocations[idx], statsDocPath: entry.statsDocPath, - })); + })) const userDocs = _(payouts) .filter((payout) => !!payout.userId) - .groupBy("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, - }), - ); + 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"), + totalAmount: _.round(_.sumBy(projects, 'amount'), 2), + totalStreams: _.sumBy(projects, 'streams'), projects: sortedProjects, - }; + } }) - .value(); + .value() if (userDocs.length) { const docs = userDocs.map((userData) => { - const docId = `${monthKey}_${userData.userId}`; + const docId = `${monthKey}_${userData.userId}` return { ref: refList.monthlyPayoutEntries.doc(docId), data: { @@ -200,35 +179,33 @@ exports.distributeMonthlyPayouts = onSchedule( computedAt: now, updatedAt: FieldValue.serverTimestamp(), }, - }; - }); + } + }) await batchFirestore({ docs, type: BATCH_TYPE.UPDATE, - }); + }) - const payoutLabel = `${String(context.month).padStart(2, "0")}/${ - context.year - }`; + 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() + typeof userData.userId === 'string' && userData.userId.trim() ? userData.userId.trim() - : null; - const amount = Number(userData.totalAmount) || 0; + : null + const amount = Number(userData.totalAmount) || 0 if (!receiverId || amount <= 0) { - return null; + return null } - const amountLabel = amount.toFixed(2); - const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.`; + const amountLabel = amount.toFixed(2) + const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.` try { await sendNotification({ - sender: "SYSTEM", + sender: 'SYSTEM', receiver: receiverId, - receiverCollection: "users", - title: "Revenus disponibles", + receiverCollection: 'users', + title: 'Revenus disponibles', message, data: { type: ALERT_TYPE?.PAYOUT_AVAILABLE, @@ -239,19 +216,16 @@ exports.distributeMonthlyPayouts = onSchedule( totalStreams: userData.totalStreams, projects: userData.projects, }, - }); + }) } catch (notifError) { - console.log( - "[distributeMonthlyPayouts] Failed to send payout notification:", - { - userId: receiverId, - error: notifError?.message || String(notifError), - }, - ); + console.log('[distributeMonthlyPayouts] Failed to send payout notification:', { + userId: receiverId, + error: notifError?.message || String(notifError), + }) } - return null; - }), - ); + return null + }) + ) } const summary = { @@ -270,14 +244,14 @@ exports.distributeMonthlyPayouts = onSchedule( totalRecipients: payouts.length, totalEntries: entries.length, eligibleEntries: eligibleEntries.length, - totalAllocated: _.round(_.sumBy(payouts, "amount"), 2), + totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2), payouts, - status: payouts.length ? "computed" : "no-data", + status: payouts.length ? 'computed' : 'no-data', updatedAt: FieldValue.serverTimestamp(), computedAt: now, - }; + } - const docRef = refList.monthlyPayouts.doc(monthKey); - await docRef.set(summary, { merge: true }); - }, -); + const docRef = refList.monthlyPayouts.doc(monthKey) + await docRef.set(summary, { merge: true }) + } +) diff --git a/functions/src/project.js b/functions/src/project.js index b164ea4..0e63730 100644 --- a/functions/src/project.js +++ b/functions/src/project.js @@ -1,115 +1,104 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { onDocumentWritten } = require("firebase-functions/firestore"); -const _ = require("lodash"); -const { refList, db } = require("../index"); -const { getSunoTimestamps } = require("./lyrics"); -const { deleteFolder } = require("../helpers/firebase"); -const { buildMonthKey } = require("../helpers/stats"); +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { onDocumentWritten } = require('firebase-functions/firestore') +const _ = require('lodash') +const { refList, db } = require('../index') +const { getSunoTimestamps } = require('./lyrics') +const { deleteFolder } = require('../helpers/firebase') +const { buildMonthKey } = require('../helpers/stats') -exports.onProjectWritten = onDocumentWritten( - "projects/{projectId}", - async (projectSnap) => { - try { - const { projectId } = projectSnap.params; - const beforeData = projectSnap?.data?.before?.data() || null; - const afterData = projectSnap?.data?.after?.data() || null; +exports.onProjectWritten = 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) { - const { userId } = beforeData || {}; + if (!afterData) { + const { userId } = beforeData || {} - 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 }); - } + if (userId) { + await deleteFolder(`users/${userId}/projects/${projectId}/`) } - 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: 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: FieldValue.increment(delta), - }; - const totalsHasFirstStream = - totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt; - if (!totalsHasFirstStream) { - totalsUpdatePayload.firstStreamAt = now; - } - transaction.set(totalsDocRef, totalsUpdatePayload, { 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; - } catch (error) { - console.log("onProjectViewsIncrement error", { - message: error?.message || String(error || ""), - }); - return null; + 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: 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: 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) { + console.log('onProjectViewsIncrement error', { + message: error?.message || String(error || ''), + }) + return null + } +}) diff --git a/functions/src/rankings.js b/functions/src/rankings.js index 1d5cb33..6bc797f 100644 --- a/functions/src/rankings.js +++ b/functions/src/rankings.js @@ -1,22 +1,22 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { onSchedule } = require("firebase-functions/v2/scheduler"); -const { refList } = require("../index"); -const firestore = refList.projects.firestore; +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { onSchedule } = require('firebase-functions/v2/scheduler') +const { refList } = require('../index') +const firestore = refList.projects.firestore function buildMonthContext(referenceDate) { - const current = referenceDate ? new Date(referenceDate) : new Date(); - current.setHours(0, 0, 0, 0); - current.setDate(1); + const current = referenceDate ? new Date(referenceDate) : new Date() + current.setHours(0, 0, 0, 0) + current.setDate(1) - const target = new Date(current); - target.setMonth(target.getMonth() - 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); + 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, @@ -24,27 +24,22 @@ function buildMonthContext(referenceDate) { monthKey, rangeStart, rangeEnd, - }; + } } exports.snapshotMonthlyTopSongs = onSchedule( { - schedule: "5 0 1 * *", - timeZone: "Europe/Paris", + schedule: '5 0 1 * *', + timeZone: 'Europe/Paris', }, async (event) => { - const { scheduleTime } = event; - const context = buildMonthContext( - scheduleTime ? new Date(scheduleTime) : new Date() - ); + const { scheduleTime } = event + const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date()) - const topProjectsSnap = await refList.projects - .orderBy("views", "desc") - .limit(3) - .get(); + const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get() const topProjects = topProjectsSnap.docs.map((doc, index) => { - const data = doc.data() || {}; + const data = doc.data() || {} return { rank: index + 1, projectId: doc.id, @@ -54,14 +49,12 @@ exports.snapshotMonthlyTopSongs = onSchedule( coverUrl: data.coverUrl || null, songUrl: data.songUrl || null, views: data.views || 0, - }; - }); + } + }) - const docRef = firestore - .collection("monthlyTopSongs") - .doc(context.monthKey); + const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey) - const existingSnapshot = await docRef.get(); + const existingSnapshot = await docRef.get() const payload = { monthKey: context.monthKey, month: context.month, @@ -73,12 +66,12 @@ exports.snapshotMonthlyTopSongs = onSchedule( topProjects, totalProjects: topProjects.length, updatedAt: FieldValue.serverTimestamp(), - }; - - if (!existingSnapshot.exists) { - payload.createdAt = FieldValue.serverTimestamp(); } - await docRef.set(payload, { merge: true }); + if (!existingSnapshot.exists) { + payload.createdAt = FieldValue.serverTimestamp() + } + + await docRef.set(payload, { merge: true }) } -); +) diff --git a/functions/src/stripe.js b/functions/src/stripe.js index f2b54ab..edaceb9 100644 --- a/functions/src/stripe.js +++ b/functions/src/stripe.js @@ -1,15 +1,15 @@ -const admin = require("firebase-admin"); -const { HttpsError, onCall } = require("firebase-functions/https"); +const admin = require('firebase-admin') +const { HttpsError, onCall } = require('firebase-functions/https') -let FieldValue = null; +let FieldValue = null try { - ({ FieldValue } = require("firebase-admin/firestore")); + ;({ FieldValue } = require('firebase-admin/firestore')) } catch (error) { - console.warn("[stripe] FieldValue import failed", error?.message); + console.warn('[stripe] FieldValue import failed', error?.message) } -const { REGION, refsList } = require("../index"); -const STRIPE_MODE = "test"; +const { REGION, refsList } = require('../index') +const STRIPE_MODE = 'test' const { getStripeClient, getReturnUrls, @@ -19,23 +19,20 @@ const { formatCheckoutSessionResponse, getPortalConfigurationId, mapStripeErrorToHttps, -} = require("../helpers/stripe"); +} = require('../helpers/stripe') -const paymentsCollection = admin.firestore().collection("payments"); +const paymentsCollection = admin.firestore().collection('payments') const getServerTimestamp = () => { if (FieldValue?.serverTimestamp) { - return FieldValue.serverTimestamp(); + return FieldValue.serverTimestamp() } - const fallback = admin.firestore?.FieldValue; + const fallback = admin.firestore?.FieldValue if (fallback?.serverTimestamp) { - return fallback.serverTimestamp(); + return fallback.serverTimestamp() } - throw new HttpsError( - "failed-precondition", - "Firestore FieldValue.serverTimestamp indisponible.", - ); -}; + throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.') +} const normalizePaymentIntent = (paymentIntent) => ({ id: paymentIntent.id, @@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({ created: paymentIntent.created, latest_charge: paymentIntent.latest_charge, metadata: paymentIntent.metadata, -}); +}) const createCheckoutSession = onCall({ region: REGION }, async (request) => { try { - const uid = request?.auth?.uid; + const uid = request?.auth?.uid if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour créer une session Stripe.", - ); + throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.') } const requestedUserId = - typeof request?.data?.userID === "string" - ? request.data.userID.trim() - : null; + typeof request?.data?.userID === 'string' ? request.data.userID.trim() : null if (requestedUserId && requestedUserId !== uid) { throw new HttpsError( - "permission-denied", - "Tu ne peux créer une session que pour ton propre compte.", - ); + 'permission-denied', + 'Tu ne peux créer une session que pour ton propre compte.' + ) } - const productList = Array.isArray(request?.data?.productList) - ? request.data.productList - : []; + const productList = Array.isArray(request?.data?.productList) ? request.data.productList : [] - const stripe = getStripeClient(); - const { lineItems, summary, hasSubscription } = - await buildCheckoutLineItems(productList, { stripe }); + const stripe = getStripeClient() + const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, { + stripe, + }) - const mode = hasSubscription ? "subscription" : "payment"; - const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls); + const mode = hasSubscription ? 'subscription' : 'payment' + const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls) const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: true, - }); + }) if (!customerId) { - throw new HttpsError( - "internal", - "Impossible de retrouver le client Stripe associé.", - ); + throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.') } const session = await stripe.checkout.sessions.create({ @@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => { metadata: { firebaseUID: uid, }, - }); + }) await paymentsCollection.doc(session.id).set({ userId: uid, customerId, - status: session.status || "created", + status: session.status || 'created', mode, createdAt: getServerTimestamp(), updatedAt: getServerTimestamp(), @@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => { amountTotal: session.amount_total, currency: session.currency, paymentStatus: session.payment_status, - }); + }) - return formatCheckoutSessionResponse(session); + return formatCheckoutSessionResponse(session) } catch (error) { - console.error("[createCheckoutSession] error", error); + console.error('[createCheckoutSession] error', error) if (error instanceof HttpsError) { - throw error; + throw error } - throw mapStripeErrorToHttps( - error, - "Création de la session Stripe impossible.", - ); + throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.') } -}); +}) const getPremiumStatus = onCall({ region: REGION }, async (request) => { try { - const uid = request?.auth?.uid; + const uid = request?.auth?.uid if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour consulter ton statut Stripe.", - ); + throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.') } - const stripe = getStripeClient(); + const stripe = getStripeClient() const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: false, - }); + }) if (!customerId) { return { customerId: null, subscriptions: [], invoices: [], - }; + } } const [subscriptions, invoices] = await Promise.all([ stripe.subscriptions.list({ customer: customerId, - status: "all", - expand: ["data.items.data.price"], + status: 'all', + expand: ['data.items.data.price'], limit: 20, }), stripe.invoices.list({ customer: customerId, limit: 20, }), - ]); + ]) const formattedSubscriptions = subscriptions.data.map((subscription) => ({ id: subscription.id, @@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => { currency: item.price?.currency, recurring: item.price?.recurring, })), - })); + })) const formattedInvoices = invoices.data.map((invoice) => ({ id: invoice.id, @@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => { hosted_invoice_url: invoice.hosted_invoice_url, invoice_pdf: invoice.invoice_pdf, created: invoice.created, - })); + })) return { customerId, subscriptions: formattedSubscriptions, invoices: formattedInvoices, - }; + } } catch (error) { - console.error("[getPremiumStatus] error", error); + console.error('[getPremiumStatus] error', error) if (error instanceof HttpsError) { - throw error; + throw error } - throw mapStripeErrorToHttps( - error, - "Impossible de récupérer le statut Stripe.", - ); + throw mapStripeErrorToHttps(error, 'Impossible de récupérer le statut Stripe.') } -}); +}) -const createStripeCustomerPortalSession = onCall( - { region: REGION }, - async (request) => { - try { - const uid = request?.auth?.uid; - if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour ouvrir le portail client Stripe.", - ); - } - - const stripe = getStripeClient(); - const { customerId } = await ensureStripeCustomer({ - uid, - stripe, - refsList, - createIfMissing: false, - }); - - if (!customerId) { - throw new HttpsError( - "failed-precondition", - "Aucun client Stripe associé à cet utilisateur.", - ); - } - - const portalConfigurationId = await getPortalConfigurationId(stripe); - if (!portalConfigurationId) { - const modeLabel = STRIPE_MODE === "prod" ? "production" : "test"; - const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST"; - throw new HttpsError( - "failed-precondition", - `Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`, - ); - } - - const { successUrl } = getReturnUrls(); - - const portalSession = await stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: successUrl, - configuration: portalConfigurationId, - }); - - return { - id: portalSession.id, - url: portalSession.url, - created: portalSession.created, - }; - } catch (error) { - console.error("[createStripeCustomerPortalSession] error", error); - if (error instanceof HttpsError) { - throw error; - } - throw mapStripeErrorToHttps( - error, - "Ouverture du portail Stripe impossible.", - ); +const createStripeCustomerPortalSession = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir le portail client Stripe.') } - }, -); + + const stripe = getStripeClient() + const { customerId } = await ensureStripeCustomer({ + uid, + stripe, + refsList, + createIfMissing: false, + }) + + if (!customerId) { + throw new HttpsError('failed-precondition', 'Aucun client Stripe associé à cet utilisateur.') + } + + const portalConfigurationId = await getPortalConfigurationId(stripe) + if (!portalConfigurationId) { + const modeLabel = STRIPE_MODE === 'prod' ? 'production' : 'test' + const envKeySuffix = STRIPE_MODE === 'prod' ? 'PROD' : 'TEST' + throw new HttpsError( + 'failed-precondition', + `Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.` + ) + } + + const { successUrl } = getReturnUrls() + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: successUrl, + configuration: portalConfigurationId, + }) + + return { + id: portalSession.id, + url: portalSession.url, + created: portalSession.created, + } + } catch (error) { + console.error('[createStripeCustomerPortalSession] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, 'Ouverture du portail Stripe impossible.') + } +}) const resolveStripeConnectAccountId = (userData) => { - if (!userData || typeof userData !== "object") { - return null; + if (!userData || typeof userData !== 'object') { + return null } const candidatePaths = [ - ["stripeConnectAccountId"], - ["stripeConnectId"], - ["stripeAccountId"], - ["stripeConnectAccount"], - ["stripeAccount"], - ["stripe", "connectAccountId"], - ["stripe", "accountId"], - ["providers", "stripeConnect", "accountId"], - ]; + ['stripeConnectAccountId'], + ['stripeConnectId'], + ['stripeAccountId'], + ['stripeConnectAccount'], + ['stripeAccount'], + ['stripe', 'connectAccountId'], + ['stripe', 'accountId'], + ['providers', 'stripeConnect', 'accountId'], + ] for (const path of candidatePaths) { - let current = userData; + let current = userData for (const key of path) { - if (!current || typeof current !== "object") { - current = null; - break; + if (!current || typeof current !== 'object') { + current = null + break } - current = current[key]; + current = current[key] } - if (typeof current === "string" && current.trim()) { - return current.trim(); + if (typeof current === 'string' && current.trim()) { + return current.trim() } } - return null; -}; + return null +} -const ensureStripeConnectAccount = async ({ - uid, - stripe, - userRef, - userData, -}) => { +const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => { if (!uid || !stripe) { - return { connectAccountId: null, userData, createdAccount: false }; + return { connectAccountId: null, userData, createdAccount: false } } - let connectAccountId = resolveStripeConnectAccountId(userData); + let connectAccountId = resolveStripeConnectAccountId(userData) if (connectAccountId) { - return { connectAccountId, userData, createdAccount: false }; + return { connectAccountId, userData, createdAccount: false } } - let authRecord = null; + let authRecord = null try { - authRecord = await admin.auth().getUser(uid); + authRecord = await admin.auth().getUser(uid) } catch (error) { - console.warn( - "[ensureStripeConnectAccount] Impossible de récupérer auth user", - error, - ); + console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error) } - const email = userData?.email || authRecord?.email || undefined; + const email = userData?.email || authRecord?.email || undefined const accountParams = { - type: "express", + type: 'express', capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, metadata: { firebaseUID: uid, - appMode: STRIPE_MODE || "test", + appMode: STRIPE_MODE || 'test', }, - }; - - if (email) { - accountParams.email = email; } - const account = await stripe.accounts.create(accountParams); - connectAccountId = account?.id; + if (email) { + accountParams.email = email + } + + const account = await stripe.accounts.create(accountParams) + connectAccountId = account?.id if (!connectAccountId) { - throw new HttpsError( - "internal", - "Stripe n’a pas renvoyé d’identifiant de compte Connect.", - ); + throw new HttpsError('internal', 'Stripe n’a pas renvoyé d’identifiant de compte Connect.') } const providersConnect = { ...(userData?.providers?.stripeConnect || {}), accountId: connectAccountId, - }; + } if (userRef) { await userRef.set( @@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({ }, }, }, - { merge: true }, - ); + { merge: true } + ) } return { @@ -406,170 +362,140 @@ const ensureStripeConnectAccount = async ({ }, }, createdAccount: true, - }; -}; + } +} -const createStripeConnectLoginLink = onCall( - { region: REGION }, - async (request) => { - try { - const uid = request?.auth?.uid; - if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour ouvrir Stripe Connect.", - ); - } - - const userRef = refsList?.users?.doc(uid); - const snapshot = userRef ? await userRef.get() : null; - let userData = snapshot?.exists ? snapshot.data() : null; - - const stripe = getStripeClient(); - const ensureResult = await ensureStripeConnectAccount({ - uid, - stripe, - userRef, - userData, - }); - - const connectAccountId = ensureResult.connectAccountId; - userData = ensureResult.userData; - - if (!connectAccountId) { - throw new HttpsError( - "failed-precondition", - "Impossible de créer un compte Stripe Connect pour cet utilisateur.", - ); - } - - const redirectUrl = getReturnBaseUrl(); - - const loginLink = await stripe.accounts.createLoginLink( - connectAccountId, - redirectUrl - ? { - redirect_url: redirectUrl, - } - : undefined, - ); - - if (!loginLink?.url) { - throw new HttpsError( - "internal", - "Stripe Connect n’a pas renvoyé de lien de connexion.", - ); - } - - return { - id: loginLink.id, - url: loginLink.url, - created: loginLink.created, - connectAccountId, - createdAccount: ensureResult.createdAccount, - }; - } catch (error) { - console.error("[createStripeConnectLoginLink] error", error); - if (error instanceof HttpsError) { - throw error; - } - throw mapStripeErrorToHttps( - error, - "Ouverture de Stripe Connect impossible.", - ); +const createStripeConnectLoginLink = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour ouvrir Stripe Connect.') } - }, -); + + const userRef = refsList?.users?.doc(uid) + const snapshot = userRef ? await userRef.get() : null + let userData = snapshot?.exists ? snapshot.data() : null + + const stripe = getStripeClient() + const ensureResult = await ensureStripeConnectAccount({ + uid, + stripe, + userRef, + userData, + }) + + const connectAccountId = ensureResult.connectAccountId + userData = ensureResult.userData + + if (!connectAccountId) { + throw new HttpsError( + 'failed-precondition', + 'Impossible de créer un compte Stripe Connect pour cet utilisateur.' + ) + } + + const redirectUrl = getReturnBaseUrl() + + const loginLink = await stripe.accounts.createLoginLink( + connectAccountId, + redirectUrl + ? { + redirect_url: redirectUrl, + } + : undefined + ) + + if (!loginLink?.url) { + throw new HttpsError('internal', 'Stripe Connect n’a pas renvoyé de lien de connexion.') + } + + return { + id: loginLink.id, + url: loginLink.url, + created: loginLink.created, + connectAccountId, + createdAccount: ensureResult.createdAccount, + } + } catch (error) { + console.error('[createStripeConnectLoginLink] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, 'Ouverture de Stripe Connect impossible.') + } +}) const verifyStripePayment = onCall({ region: REGION }, async (request) => { try { - const uid = request?.auth?.uid; + const uid = request?.auth?.uid if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour vérifier un paiement Stripe.", - ); + throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.') } const rawPaymentId = - typeof request?.data?.paymentId === "string" - ? request.data.paymentId.trim() - : ""; + typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : '' if (!rawPaymentId) { - throw new HttpsError( - "invalid-argument", - "Fournis un identifiant de paiement Stripe.", - ); + throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.') } - const stripe = getStripeClient(); + const stripe = getStripeClient() const fetchPaymentIntent = async (paymentIntentId) => stripe.paymentIntents.retrieve(paymentIntentId, { - expand: ["latest_charge"], - }); + expand: ['latest_charge'], + }) const fetchCheckoutSession = async (sessionId) => stripe.checkout.sessions.retrieve(sessionId, { - expand: ["payment_intent"], - }); + expand: ['payment_intent'], + }) - let paymentIntent = null; - let checkoutSession = null; - let paymentType = null; + let paymentIntent = null + let checkoutSession = null + let paymentType = null - if (rawPaymentId.startsWith("pi_")) { - paymentIntent = await fetchPaymentIntent(rawPaymentId); - paymentType = "payment_intent"; - } else if (rawPaymentId.startsWith("cs_")) { - checkoutSession = await fetchCheckoutSession(rawPaymentId); + if (rawPaymentId.startsWith('pi_')) { + paymentIntent = await fetchPaymentIntent(rawPaymentId) + paymentType = 'payment_intent' + } else if (rawPaymentId.startsWith('cs_')) { + checkoutSession = await fetchCheckoutSession(rawPaymentId) paymentIntent = - checkoutSession?.payment_intent && - typeof checkoutSession.payment_intent === "object" + checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object' ? checkoutSession.payment_intent : checkoutSession?.payment_intent ? await fetchPaymentIntent(checkoutSession.payment_intent) - : null; - paymentType = "checkout_session"; + : null + paymentType = 'checkout_session' } else { try { - paymentIntent = await fetchPaymentIntent(rawPaymentId); - paymentType = "payment_intent"; + paymentIntent = await fetchPaymentIntent(rawPaymentId) + paymentType = 'payment_intent' } catch (intentError) { try { - checkoutSession = await fetchCheckoutSession(rawPaymentId); - paymentType = "checkout_session"; + checkoutSession = await fetchCheckoutSession(rawPaymentId) + paymentType = 'checkout_session' paymentIntent = - checkoutSession?.payment_intent && - typeof checkoutSession.payment_intent === "object" + checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object' ? checkoutSession.payment_intent : checkoutSession?.payment_intent ? await fetchPaymentIntent(checkoutSession.payment_intent) - : null; + : null } catch (sessionError) { - console.error("[verifyStripePayment] lookup failure", { + console.error('[verifyStripePayment] lookup failure', { intentError, sessionError, - }); - throw new HttpsError( - "not-found", - "Aucun paiement Stripe trouvé avec cet identifiant.", - ); + }) + throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.') } } } if (!paymentIntent && !checkoutSession) { - throw new HttpsError( - "not-found", - "Aucun paiement Stripe trouvé avec cet identifiant.", - ); + throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.') } - const normalizedIntent = paymentIntent - ? normalizePaymentIntent(paymentIntent) - : null; + const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null const response = { type: paymentType, @@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => { checkoutSession?.status ?? null, currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null, - }; + } const paymentDocId = - paymentType === "checkout_session" - ? checkoutSession?.id - : normalizedIntent?.id; + paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id if (paymentDocId) { - const paymentDocRef = paymentsCollection.doc(paymentDocId); - const existing = await paymentDocRef.get(); + const paymentDocRef = paymentsCollection.doc(paymentDocId) + const existing = await paymentDocRef.get() if (existing.exists) { await paymentDocRef.set( { status: response.status, paymentStatus: checkoutSession?.payment_status, - amountTotal: - checkoutSession?.amount_total ?? normalizedIntent?.amount, + amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount, amountReceived: normalizedIntent?.amount_received, currency: response.currency, updatedAt: getServerTimestamp(), }, - { merge: true }, - ); + { merge: true } + ) } } - return response; + return response } catch (error) { - console.error("[verifyStripePayment] error", error); + console.error('[verifyStripePayment] error', error) if (error instanceof HttpsError) { - throw error; + throw error } - throw mapStripeErrorToHttps(error, "Vérification du paiement impossible."); + throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.') } -}); +}) module.exports = { createCheckoutSession, @@ -636,4 +559,4 @@ module.exports = { createStripeCustomerPortalSession, createStripeConnectLoginLink, verifyStripePayment, -}; +} diff --git a/functions/src/subscription/catalog.js b/functions/src/subscription/catalog.js index 76573a7..91939d3 100644 --- a/functions/src/subscription/catalog.js +++ b/functions/src/subscription/catalog.js @@ -1,29 +1,26 @@ -const { HttpsError, onCall } = require("firebase-functions/https"); +const { HttpsError, onCall } = require('firebase-functions/https') -const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe"); -const { REGION } = require("./config"); +const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe') +const { REGION } = require('./config') const { SUBSCRIPTION_PRICE_IDS, SUBSCRIPTION_PRICE_METADATA, COIN_PACK_PRODUCTS, -} = require("./constants"); -const { parseCoinsPerMonth, formatCoinPack } = require("./shared"); +} = require('./constants') +const { parseCoinsPerMonth, formatCoinPack } = require('./shared') const formatPlan = (price, priceId) => { - if (!price || typeof price !== "object") { - return null; + if (!price || typeof price !== 'object') { + return null } - const product = - typeof price.product === "object" && price.product !== null - ? price.product - : {}; + const product = typeof price.product === 'object' && price.product !== null ? price.product : {} return { id: price.id || priceId, priceId: price.id || priceId, active: price.active !== false, - currency: price.currency || "eur", + currency: price.currency || 'eur', unitAmount: price.unit_amount, unitAmountDecimal: price.unit_amount_decimal, transformQuantity: price.transform_quantity || null, @@ -36,16 +33,16 @@ const formatPlan = (price, priceId) => { metadata: price.metadata || {}, product: { id: product.id || null, - name: product.name || "", - description: product.description || "", + name: product.name || '', + description: product.description || '', metadata: product.metadata || {}, }, - }; -}; + } +} const listSubscriptionPlans = onCall({ region: REGION }, async () => { try { - const stripe = getStripeClient(); + const stripe = getStripeClient() const entries = await Promise.all( Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => { @@ -53,88 +50,79 @@ const listSubscriptionPlans = onCall({ region: REGION }, async () => { priceIds.map(async (priceId) => { try { const price = await stripe.prices.retrieve(priceId, { - expand: ["product"], - }); - return formatPlan(price, priceId); + expand: ['product'], + }) + return formatPlan(price, priceId) } catch (error) { console.error( `[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`, - error?.message || error, - ); - return null; + error?.message || error + ) + return null } - }), - ); + }) + ) - return [period, periodPlans.filter(Boolean)]; - }), - ); + return [period, periodPlans.filter(Boolean)] + }) + ) return { plans: Object.fromEntries(entries), - }; + } } catch (error) { - console.error("[subscription-listSubscriptionPlans] error", error); - throw mapStripeErrorToHttps( - error, - "Impossible de récupérer les abonnements Stripe.", - ); + console.error('[subscription-listSubscriptionPlans] error', error) + throw mapStripeErrorToHttps(error, 'Impossible de récupérer les abonnements Stripe.') } -}); +}) const listCoinPacks = onCall({ region: REGION }, async () => { try { - const stripe = getStripeClient(); + const stripe = getStripeClient() const packs = await Promise.all( COIN_PACK_PRODUCTS.map(async (pack) => { try { const product = await stripe.products.retrieve(pack.productId, { - expand: ["default_price"], - }); + expand: ['default_price'], + }) - let resolvedPrice = null; - if (typeof product?.default_price === "string") { - resolvedPrice = await stripe.prices.retrieve(product.default_price); - } else if ( - product?.default_price && - typeof product.default_price === "object" - ) { - resolvedPrice = product.default_price; + let resolvedPrice = null + if (typeof product?.default_price === 'string') { + resolvedPrice = await stripe.prices.retrieve(product.default_price) + } else if (product?.default_price && typeof product.default_price === 'object') { + resolvedPrice = product.default_price } const formatted = formatCoinPack({ product, price: resolvedPrice, - }); + }) return { ...formatted, coinPackKey: pack.key || null, - }; + } } catch (error) { console.error( - "[subscription-listCoinPacks] Unable to retrieve product", + '[subscription-listCoinPacks] Unable to retrieve product', pack.productId, - error?.message || error, - ); - return null; + error?.message || error + ) + return null } - }), - ); + }) + ) return { packs: packs.filter(Boolean), - }; + } } catch (error) { - console.error("[subscription-listCoinPacks] error", error); - throw mapStripeErrorToHttps( - error, - "Impossible de récupérer les packs de pièces.", - ); + console.error('[subscription-listCoinPacks] error', error) + throw mapStripeErrorToHttps(error, 'Impossible de récupérer les packs de pièces.') } -}); +}) module.exports = { listSubscriptionPlans, listCoinPacks, -}; +} diff --git a/functions/src/subscription/checkout.js b/functions/src/subscription/checkout.js index 356aaad..46395e8 100644 --- a/functions/src/subscription/checkout.js +++ b/functions/src/subscription/checkout.js @@ -1,4 +1,4 @@ -const { HttpsError, onCall } = require("firebase-functions/https"); +const { HttpsError, onCall } = require('firebase-functions/https') const { getStripeClient, @@ -7,312 +7,271 @@ const { getReturnUrls, formatCheckoutSessionResponse, mapStripeErrorToHttps, -} = require("../../helpers/stripe"); -const { REGION } = require("./config"); +} = require('../../helpers/stripe') +const { REGION } = require('./config') const { ALL_SUBSCRIPTION_PRICE_IDS, COIN_PACK_PRODUCT_IDS, COIN_PACK_PRODUCT_MAP, -} = require("./constants"); -const { - refsList, - formatCoinPack, - getSubscriptionMetaFromPrice, -} = require("./shared"); +} = require('./constants') +const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared') -const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]); +const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded']) const sanitizePriceId = (value) => { - if (typeof value !== "string") { - return ""; + if (typeof value !== 'string') { + return '' } - return value.trim(); -}; + return value.trim() +} const resolveCheckoutUiMode = (request) => { - if (!request || !request.data || typeof request.data.uiMode === "undefined") { - return "hosted"; + if (!request || !request.data || typeof request.data.uiMode === 'undefined') { + return 'hosted' } - if (typeof request.data.uiMode !== "string") { + if (typeof request.data.uiMode !== 'string') { throw new HttpsError( - "invalid-argument", - 'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").', - ); + 'invalid-argument', + 'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").' + ) } - const normalizedUiMode = request.data.uiMode.trim().toLowerCase(); + const normalizedUiMode = request.data.uiMode.trim().toLowerCase() if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) { throw new HttpsError( - "invalid-argument", - `uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`, - ); + 'invalid-argument', + `uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.` + ) } - return normalizedUiMode; -}; + return normalizedUiMode +} -const withCheckoutNavigationParams = ( - baseParams, - { uiMode, successUrl, cancelUrl }, -) => { - if (uiMode === "embedded") { +const withCheckoutNavigationParams = (baseParams, { uiMode, successUrl, cancelUrl }) => { + if (uiMode === 'embedded') { return { ...baseParams, - ui_mode: "embedded", + ui_mode: 'embedded', return_url: undefined, - redirect_on_completion: "never", - }; + redirect_on_completion: 'never', + } } return { ...baseParams, success_url: successUrl, cancel_url: cancelUrl, - }; -}; + } +} -const createSubscriptionCheckoutSession = onCall( - { region: REGION }, - async (request) => { +const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour souscrire un abonnement.') + } + + const rawPriceId = request?.data?.priceId + const priceId = sanitizePriceId(rawPriceId) + if (!priceId) { + throw new HttpsError('invalid-argument', 'Un identifiant de prix Stripe est requis.') + } + + if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) { + throw new HttpsError( + 'invalid-argument', + `L'identifiant de prix ${priceId} n'est pas pris en charge.` + ) + } + + const stripe = getStripeClient() + const priceMeta = getSubscriptionMetaFromPrice(priceId) || {} + + const { lineItems, summary } = await buildCheckoutLineItems( + [ + { + priceID: priceId, + quantity: 1, + isRenewable: true, + }, + ], + { stripe } + ) + + const uiMode = resolveCheckoutUiMode(request) + const shouldProvideReturnUrls = uiMode !== 'embedded' + const { successUrl, cancelUrl } = shouldProvideReturnUrls + ? getReturnUrls(request?.data?.returnUrls) + : { successUrl: null, cancelUrl: null } + + const { customerId } = await ensureStripeCustomer({ + uid, + stripe, + refsList, + createIfMissing: true, + }) + + if (!customerId) { + throw new HttpsError( + 'failed-precondition', + 'Impossible de retrouver le client Stripe associé.' + ) + } + + const session = await stripe.checkout.sessions.create( + withCheckoutNavigationParams( + { + mode: 'subscription', + customer: customerId, + line_items: lineItems, + allow_promotion_codes: true, + metadata: { + firebaseUID: uid, + priceId, + purchaseType: 'SUBSCRIPTION', + subscriptionLevel: priceMeta.level || null, + subscriptionBillingPeriod: priceMeta.billingPeriod || null, + }, + }, + { + uiMode, + successUrl, + cancelUrl, + } + ) + ) + + return formatCheckoutSessionResponse(session) + } catch (error) { + console.error('[subscription-createSubscriptionCheckoutSession] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, "Impossible de créer la session d'abonnement Stripe.") + } +}) + +const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour acheter un pack de pièces.') + } + + const rawProductId = request?.data?.productId + const productId = typeof rawProductId === 'string' ? rawProductId.trim() : '' + + if (!productId) { + throw new HttpsError('invalid-argument', 'Un identifiant de produit Stripe est requis.') + } + + if (!COIN_PACK_PRODUCT_IDS.includes(productId)) { + throw new HttpsError( + 'invalid-argument', + `Le produit ${productId} n'est pas un pack de pièces autorisé` + ) + } + + const stripe = getStripeClient() + + const product = await stripe.products.retrieve(productId, { + expand: ['default_price'], + }) + + let resolvedPrice = null + if (typeof product?.default_price === 'string') { + resolvedPrice = await stripe.prices.retrieve(product.default_price) + } else if (product?.default_price && typeof product.default_price === 'object') { + resolvedPrice = product.default_price + } + + let coinPack = null try { - const uid = request?.auth?.uid; - if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour souscrire un abonnement.", - ); - } - - const rawPriceId = request?.data?.priceId; - const priceId = sanitizePriceId(rawPriceId); - if (!priceId) { - throw new HttpsError( - "invalid-argument", - "Un identifiant de prix Stripe est requis.", - ); - } - - if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) { - throw new HttpsError( - "invalid-argument", - `L'identifiant de prix ${priceId} n'est pas pris en charge.`, - ); - } - - const stripe = getStripeClient(); - const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; - - const { lineItems, summary } = await buildCheckoutLineItems( - [ - { - priceID: priceId, - quantity: 1, - isRenewable: true, - }, - ], - { stripe }, - ); - - const uiMode = resolveCheckoutUiMode(request); - const shouldProvideReturnUrls = uiMode !== "embedded"; - const { successUrl, cancelUrl } = shouldProvideReturnUrls - ? getReturnUrls(request?.data?.returnUrls) - : { successUrl: null, cancelUrl: null }; - - const { customerId } = await ensureStripeCustomer({ - uid, - stripe, - refsList, - createIfMissing: true, - }); - - if (!customerId) { - throw new HttpsError( - "failed-precondition", - "Impossible de retrouver le client Stripe associé.", - ); - } - - const session = await stripe.checkout.sessions.create( - withCheckoutNavigationParams( - { - mode: "subscription", - customer: customerId, - line_items: lineItems, - allow_promotion_codes: true, - metadata: { - firebaseUID: uid, - priceId, - purchaseType: "SUBSCRIPTION", - subscriptionLevel: priceMeta.level || null, - subscriptionBillingPeriod: priceMeta.billingPeriod || null, - }, - }, - { - uiMode, - successUrl, - cancelUrl, - }, - ), - ); - - return formatCheckoutSessionResponse(session); + coinPack = formatCoinPack({ + product, + price: resolvedPrice, + }) } catch (error) { console.error( - "[subscription-createSubscriptionCheckoutSession] error", - error, - ); - if (error instanceof HttpsError) { - throw error; - } - throw mapStripeErrorToHttps( - error, - "Impossible de créer la session d'abonnement Stripe.", - ); + '[subscription-createCoinPackCheckoutSession] invalid coin pack metadata', + productId, + error?.message || error + ) + throw new HttpsError( + 'failed-precondition', + 'Le pack Stripe est mal configuré (metadata.coins manquant).' + ) } - }, -); -const createCoinPackCheckoutSession = onCall( - { region: REGION }, - async (request) => { - try { - const uid = request?.auth?.uid; - if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour acheter un pack de pièces.", - ); - } + if (!coinPack?.priceId) { + throw new HttpsError( + 'failed-precondition', + 'Impossible de déterminer le prix Stripe pour ce pack.' + ) + } - const rawProductId = request?.data?.productId; - const productId = - typeof rawProductId === "string" ? rawProductId.trim() : ""; + const uiMode = resolveCheckoutUiMode(request) + const shouldProvideReturnUrls = uiMode !== 'embedded' + const { successUrl, cancelUrl } = shouldProvideReturnUrls + ? getReturnUrls(request?.data?.returnUrls) + : { successUrl: null, cancelUrl: null } - if (!productId) { - throw new HttpsError( - "invalid-argument", - "Un identifiant de produit Stripe est requis.", - ); - } + const { customerId } = await ensureStripeCustomer({ + uid, + stripe, + refsList, + createIfMissing: true, + }) - if (!COIN_PACK_PRODUCT_IDS.includes(productId)) { - throw new HttpsError( - "invalid-argument", - `Le produit ${productId} n'est pas un pack de pièces autorisé`, - ); - } + if (!customerId) { + throw new HttpsError( + 'failed-precondition', + 'Impossible de retrouver le client Stripe associé.' + ) + } - const stripe = getStripeClient(); - - const product = await stripe.products.retrieve(productId, { - expand: ["default_price"], - }); - - let resolvedPrice = null; - if (typeof product?.default_price === "string") { - resolvedPrice = await stripe.prices.retrieve(product.default_price); - } else if ( - product?.default_price && - typeof product.default_price === "object" - ) { - resolvedPrice = product.default_price; - } - - let coinPack = null; - try { - coinPack = formatCoinPack({ - product, - price: resolvedPrice, - }); - } catch (error) { - console.error( - "[subscription-createCoinPackCheckoutSession] invalid coin pack metadata", - productId, - error?.message || error, - ); - throw new HttpsError( - "failed-precondition", - "Le pack Stripe est mal configuré (metadata.coins manquant).", - ); - } - - if (!coinPack?.priceId) { - throw new HttpsError( - "failed-precondition", - "Impossible de déterminer le prix Stripe pour ce pack.", - ); - } - - const uiMode = resolveCheckoutUiMode(request); - const shouldProvideReturnUrls = uiMode !== "embedded"; - const { successUrl, cancelUrl } = shouldProvideReturnUrls - ? getReturnUrls(request?.data?.returnUrls) - : { successUrl: null, cancelUrl: null }; - - const { customerId } = await ensureStripeCustomer({ - uid, - stripe, - refsList, - createIfMissing: true, - }); - - if (!customerId) { - throw new HttpsError( - "failed-precondition", - "Impossible de retrouver le client Stripe associé.", - ); - } - - const session = await stripe.checkout.sessions.create( - withCheckoutNavigationParams( - { - mode: "payment", - customer: customerId, - line_items: [ - { - price: coinPack.priceId, - quantity: 1, - }, - ], - allow_promotion_codes: false, - metadata: { - firebaseUID: uid, - purchaseType: "COIN_PACK", - coinPackProductId: coinPack.productId, - coinPackPriceId: coinPack.priceId, - coinAmount: coinPack.coinAmount, - coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null, + const session = await stripe.checkout.sessions.create( + withCheckoutNavigationParams( + { + mode: 'payment', + customer: customerId, + line_items: [ + { + price: coinPack.priceId, + quantity: 1, }, + ], + allow_promotion_codes: false, + metadata: { + firebaseUID: uid, + purchaseType: 'COIN_PACK', + coinPackProductId: coinPack.productId, + coinPackPriceId: coinPack.priceId, + coinAmount: coinPack.coinAmount, + coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null, }, - { - uiMode, - successUrl, - cancelUrl, - }, - ), - ); + }, + { + uiMode, + successUrl, + cancelUrl, + } + ) + ) - return formatCheckoutSessionResponse(session); - } catch (error) { - console.error( - "[subscription-createCoinPackCheckoutSession] error", - error, - ); - if (error instanceof HttpsError) { - throw error; - } - throw mapStripeErrorToHttps( - error, - "Impossible de créer la session d'achat de pièces.", - ); + return formatCheckoutSessionResponse(session) + } catch (error) { + console.error('[subscription-createCoinPackCheckoutSession] error', error) + if (error instanceof HttpsError) { + throw error } - }, -); + throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat de pièces.") + } +}) module.exports = { createSubscriptionCheckoutSession, createCoinPackCheckoutSession, resolveCheckoutUiMode, -}; +} diff --git a/functions/src/subscription/config.js b/functions/src/subscription/config.js index 553e148..c1c6576 100644 --- a/functions/src/subscription/config.js +++ b/functions/src/subscription/config.js @@ -1,5 +1,5 @@ -const REGION = process.env.FIREBASE_REGION || "europe-west1"; +const REGION = process.env.FIREBASE_REGION || 'europe-west1' module.exports = { REGION, -}; +} diff --git a/functions/src/subscription/constants.js b/functions/src/subscription/constants.js index 8217309..41602fa 100644 --- a/functions/src/subscription/constants.js +++ b/functions/src/subscription/constants.js @@ -1,86 +1,76 @@ const SUBSCRIPTION_PRICE_IDS = { monthly: [ - "price_1SPgitCzf2o5bDRdbnhLFx6f", - "price_1SPgjCCzf2o5bDRdr08Xzp8u", - "price_1SPgjaCzf2o5bDRdd9Xo2u26", + 'price_1SPgitCzf2o5bDRdbnhLFx6f', + 'price_1SPgjCCzf2o5bDRdr08Xzp8u', + 'price_1SPgjaCzf2o5bDRdd9Xo2u26', ], annual: [ - "price_1SPgkDCzf2o5bDRdNGLVNeQ3", - "price_1SPgkXCzf2o5bDRdejBVxEBY", - "price_1SPgkqCzf2o5bDRdIcUwTDrm", + 'price_1SPgkDCzf2o5bDRdNGLVNeQ3', + 'price_1SPgkXCzf2o5bDRdejBVxEBY', + 'price_1SPgkqCzf2o5bDRdIcUwTDrm', ], -}; +} -const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat(); +const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat() const SUBSCRIPTION_LEVEL_ALLOWANCES = { starter: 10, pro: 40, premium: 60, -}; +} const SUBSCRIPTION_PRICE_METADATA = { price_1SPgitCzf2o5bDRdbnhLFx6f: { - level: "starter", - billingPeriod: "monthly", + level: 'starter', + billingPeriod: 'monthly', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter, }, price_1SPgjCCzf2o5bDRdr08Xzp8u: { - level: "pro", - billingPeriod: "monthly", + level: 'pro', + billingPeriod: 'monthly', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro, }, price_1SPgjaCzf2o5bDRdd9Xo2u26: { - level: "premium", - billingPeriod: "monthly", + level: 'premium', + billingPeriod: 'monthly', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium, }, price_1SPgkDCzf2o5bDRdNGLVNeQ3: { - level: "starter", - billingPeriod: "annual", + level: 'starter', + billingPeriod: 'annual', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter, }, price_1SPgkXCzf2o5bDRdejBVxEBY: { - level: "pro", - billingPeriod: "annual", + level: 'pro', + billingPeriod: 'annual', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro, }, price_1SPgkqCzf2o5bDRdIcUwTDrm: { - level: "premium", - billingPeriod: "annual", + level: 'premium', + billingPeriod: 'annual', coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium, }, -}; +} const COIN_PACK_PRODUCTS = [ - { productId: "prod_TMPPEXZ1wGk2cS", key: "starter" }, - { productId: "prod_TMPQ5SNdS47gY6", key: "pro" }, - { productId: "prod_TMPRpA0qQSHXj5", key: "premium" }, -]; + { productId: 'prod_TMPPEXZ1wGk2cS', key: 'starter' }, + { productId: 'prod_TMPQ5SNdS47gY6', key: 'pro' }, + { productId: 'prod_TMPRpA0qQSHXj5', key: 'premium' }, +] -const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId); +const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId) const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce( (acc, pack) => ({ ...acc, [pack.productId]: pack, }), - {}, -); + {} +) -const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); -const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([ - "trialing", - "active", - "past_due", - "unpaid", -]); -const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ - "trialing", - "active", - "past_due", - "unpaid", -]); +const PREMIUM_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing']) +const CANCELABLE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid']) +const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid']) module.exports = { SUBSCRIPTION_PRICE_IDS, @@ -93,4 +83,4 @@ module.exports = { PREMIUM_SUBSCRIPTION_STATUSES, CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES, -}; +} diff --git a/functions/src/subscription/index.js b/functions/src/subscription/index.js index b009356..7af745c 100644 --- a/functions/src/subscription/index.js +++ b/functions/src/subscription/index.js @@ -1,14 +1,8 @@ -const { listSubscriptionPlans, listCoinPacks } = require("./catalog"); -const { - createSubscriptionCheckoutSession, - createCoinPackCheckoutSession, -} = require("./checkout"); -const { - cancelActiveSubscription, - getActiveSubscription, -} = require("./management"); -const { handleStripeWebhook } = require("./webhooks"); -const { processAnnualSubscriptionAllowances } = require("./schedule"); +const { listSubscriptionPlans, listCoinPacks } = require('./catalog') +const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout') +const { cancelActiveSubscription, getActiveSubscription } = require('./management') +const { handleStripeWebhook } = require('./webhooks') +const { processAnnualSubscriptionAllowances } = require('./schedule') module.exports = { listSubscriptionPlans, @@ -19,4 +13,4 @@ module.exports = { createCoinPackCheckoutSession, handleStripeWebhook, processAnnualSubscriptionAllowances, -}; +} diff --git a/functions/src/subscription/management.js b/functions/src/subscription/management.js index bdf79d8..aa0bcf5 100644 --- a/functions/src/subscription/management.js +++ b/functions/src/subscription/management.js @@ -1,91 +1,74 @@ -const { HttpsError, onCall } = require("firebase-functions/https"); +const { HttpsError, onCall } = require('firebase-functions/https') -const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe"); -const { REGION } = require("./config"); -const { - CANCELABLE_SUBSCRIPTION_STATUSES, - ACTIVE_SUBSCRIPTION_STATUSES, -} = require("./constants"); -const { - refsList, - formatSubscriptionForClient, - resolveUserContext, -} = require("./shared"); +const { getStripeClient, mapStripeErrorToHttps } = require('../../helpers/stripe') +const { REGION } = require('./config') +const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants') +const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared') const cancelActiveSubscription = onCall({ region: REGION }, async (request) => { try { - const uid = request?.auth?.uid; + const uid = request?.auth?.uid if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour gérer ton abonnement.", - ); + throw new HttpsError('unauthenticated', 'Connecte-toi pour gérer ton abonnement.') } - const stripe = getStripeClient(); + const stripe = getStripeClient() - const userRef = refsList?.users?.doc(uid) || null; - const snapshot = userRef ? await userRef.get() : null; - const userData = snapshot?.exists ? snapshot.data() || {} : {}; + const userRef = refsList?.users?.doc(uid) || null + const snapshot = userRef ? await userRef.get() : null + const userData = snapshot?.exists ? snapshot.data() || {} : {} const inputSubscriptionId = - typeof request?.data?.subscriptionId === "string" - ? request.data.subscriptionId.trim() - : ""; + typeof request?.data?.subscriptionId === 'string' ? request.data.subscriptionId.trim() : '' let subscriptionId = inputSubscriptionId || userData?.stripeSubscription?.id || userData?.stripeSubscription?.subscriptionId || - null; + null - const customerId = userData?.stripeCustomerId || null; + const customerId = userData?.stripeCustomerId || null if (!subscriptionId && customerId) { try { const response = await stripe.subscriptions.list({ customer: customerId, - status: "all", + status: 'all', limit: 5, - }); - const { data: subscriptionList = [] } = response || {}; + }) + const { data: subscriptionList = [] } = response || {} const activeSubscription = subscriptionList.find( - (candidate) => - candidate?.status && - CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status), - ); + (candidate) => candidate?.status && CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status) + ) if (activeSubscription?.id) { - subscriptionId = activeSubscription.id; + subscriptionId = activeSubscription.id } } catch (error) { console.warn( - "[subscription-cancelActiveSubscription] Unable to list subscriptions", + '[subscription-cancelActiveSubscription] Unable to list subscriptions', customerId, - error?.message || error, - ); + error?.message || error + ) } } if (!subscriptionId) { - throw new HttpsError( - "failed-precondition", - "Aucun abonnement actif à annuler.", - ); + throw new HttpsError('failed-precondition', 'Aucun abonnement actif à annuler.') } - const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const subscription = await stripe.subscriptions.retrieve(subscriptionId) if (!subscription) { - throw new HttpsError("not-found", "Abonnement introuvable côté Stripe."); + throw new HttpsError('not-found', 'Abonnement introuvable côté Stripe.') } - if (subscription.status === "canceled") { + if (subscription.status === 'canceled') { return { subscriptionId: subscription.id, status: subscription.status, cancelAtPeriodEnd: subscription.cancel_at_period_end === true, currentPeriodEnd: subscription.current_period_end || null, alreadyCanceled: true, - }; + } } if (subscription.cancel_at_period_end === true) { @@ -95,15 +78,12 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => { cancelAtPeriodEnd: true, currentPeriodEnd: subscription.current_period_end || null, alreadyCanceled: false, - }; + } } - const updatedSubscription = await stripe.subscriptions.update( - subscriptionId, - { - cancel_at_period_end: true, - }, - ); + const updatedSubscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }) return { subscriptionId: updatedSubscription.id, @@ -111,30 +91,24 @@ const cancelActiveSubscription = onCall({ region: REGION }, async (request) => { cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true, currentPeriodEnd: updatedSubscription.current_period_end || null, alreadyCanceled: false, - }; - } catch (error) { - console.error("[subscription-cancelActiveSubscription] error", error); - if (error instanceof HttpsError) { - throw error; } - throw mapStripeErrorToHttps( - error, - "Impossible d'annuler l'abonnement Stripe.", - ); + } catch (error) { + console.error('[subscription-cancelActiveSubscription] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, "Impossible d'annuler l'abonnement Stripe.") } -}); +}) const getActiveSubscription = onCall({ region: REGION }, async (request) => { try { - const uid = request?.auth?.uid; + const uid = request?.auth?.uid if (!uid) { - throw new HttpsError( - "unauthenticated", - "Connecte-toi pour récupérer ton abonnement.", - ); + throw new HttpsError('unauthenticated', 'Connecte-toi pour récupérer ton abonnement.') } - const stripe = getStripeClient(); + const stripe = getStripeClient() const { uid: resolvedUid, @@ -143,71 +117,66 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => { } = await resolveUserContext({ metadata: request?.data?.metadata || {}, customerId: null, - }); + }) - const lookupUid = resolvedUid || uid; - const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null; - const snapshot = lookupRef ? await lookupRef.get() : null; - const data = snapshot?.exists ? snapshot.data() || {} : userData || {}; + const lookupUid = resolvedUid || uid + const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null + const snapshot = lookupRef ? await lookupRef.get() : null + const data = snapshot?.exists ? snapshot.data() || {} : userData || {} const subscriptionId = - data?.stripeSubscription?.id || - data?.stripeSubscription?.subscriptionId || - null; - const customerId = data?.stripeCustomerId || null; + data?.stripeSubscription?.id || data?.stripeSubscription?.subscriptionId || null + const customerId = data?.stripeCustomerId || null if (!subscriptionId && !customerId) { return { subscription: null, customerId: null, - }; + } } if (subscriptionId) { const subscription = await stripe.subscriptions.retrieve(subscriptionId, { - expand: ["items.data.price.product"], - }); + expand: ['items.data.price.product'], + }) if (subscription) { return { subscription: formatSubscriptionForClient(subscription), customerId: subscription.customer || customerId || null, - }; + } } } if (customerId) { const response = await stripe.subscriptions.list({ customer: customerId, - status: "all", + status: 'all', limit: 5, - expand: ["data.items.data.price.product"], - }); - const [subscription] = response?.data || []; + expand: ['data.items.data.price.product'], + }) + const [subscription] = response?.data || [] if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) { return { subscription: formatSubscriptionForClient(subscription), customerId, - }; + } } } return { subscription: null, customerId, - }; - } catch (error) { - console.error("[subscription-getActiveSubscription] error", error); - if (error instanceof HttpsError) { - throw error; } - throw mapStripeErrorToHttps( - error, - "Impossible de récupérer l'abonnement Stripe.", - ); + } catch (error) { + console.error('[subscription-getActiveSubscription] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, "Impossible de récupérer l'abonnement Stripe.") } -}); +}) module.exports = { cancelActiveSubscription, getActiveSubscription, -}; +} diff --git a/functions/src/subscription/schedule.js b/functions/src/subscription/schedule.js index f0dae19..83359a7 100644 --- a/functions/src/subscription/schedule.js +++ b/functions/src/subscription/schedule.js @@ -1,106 +1,97 @@ -const { onSchedule } = require("firebase-functions/v2/scheduler"); +const { onSchedule } = require('firebase-functions/v2/scheduler') -const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders"); -const { batchFirestore } = require("../../helpers/firebase"); -const { BATCH_TYPE } = require("../../config/types"); -const { - admin, - refsList, - computeNextGrantTimestamp, - getServerTimestamp, -} = require("./shared"); -const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants"); +const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders') +const { batchFirestore } = require('../../helpers/firebase') +const { BATCH_TYPE } = require('../../config/types') +const { admin, refsList, computeNextGrantTimestamp, getServerTimestamp } = require('./shared') +const { ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants') // Toggle to stop monthly grants for annual subscriptions while keeping logic handy. -const ENABLE_ANNUAL_GRANT_SCHEDULER = true; +const ENABLE_ANNUAL_GRANT_SCHEDULER = true const processAnnualSubscriptionAllowances = onSchedule( { - schedule: "30 3 * * *", - timeZone: "Europe/Paris", + schedule: '30 3 * * *', + timeZone: 'Europe/Paris', }, async () => { if (!ENABLE_ANNUAL_GRANT_SCHEDULER) { - console.log( - "[subscription-processAnnualSubscriptionAllowances] skipped (disabled)", - ); - return; + console.log('[subscription-processAnnualSubscriptionAllowances] skipped (disabled)') + return } - const nowTimestamp = admin.firestore.Timestamp.now(); - const pageSize = 200; - let lastDoc = null; - let processedUsers = 0; - let grantsCreated = 0; - let docsToUpdate = []; + const nowTimestamp = admin.firestore.Timestamp.now() + const pageSize = 200 + let lastDoc = null + let processedUsers = 0 + let grantsCreated = 0 + let docsToUpdate = [] const flushUpdates = async () => { if (!docsToUpdate.length) { - return; + return } await batchFirestore({ docs: docsToUpdate, type: BATCH_TYPE.UPDATE, - }); - docsToUpdate = []; - }; + }) + docsToUpdate = [] + } try { while (true) { let query = refsList.users - .where("premiumBillingPeriod", "==", "annual") - .where("subscriptionGrantInterval", "==", "monthly") - .where("subscriptionNextGrantAt", "<=", nowTimestamp) - .orderBy("subscriptionNextGrantAt") - .limit(pageSize); + .where('premiumBillingPeriod', '==', 'annual') + .where('subscriptionGrantInterval', '==', 'monthly') + .where('subscriptionNextGrantAt', '<=', nowTimestamp) + .orderBy('subscriptionNextGrantAt') + .limit(pageSize) if (lastDoc) { - query = query.startAfter(lastDoc); + query = query.startAfter(lastDoc) } - const snapshot = await query.get(); + const snapshot = await query.get() if (snapshot.empty) { - break; + break } for (const doc of snapshot.docs) { - processedUsers += 1; - const data = doc.data() || {}; + processedUsers += 1 + const data = doc.data() || {} - const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0); + const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0) if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) { - continue; + continue } const status = - typeof data.stripeSubscriptionStatus === "string" + typeof data.stripeSubscriptionStatus === 'string' ? data.stripeSubscriptionStatus.toLowerCase() - : null; + : null if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { - continue; + continue } - const nextGrantAt = data.subscriptionNextGrantAt; - if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") { - continue; + const nextGrantAt = data.subscriptionNextGrantAt + if (!nextGrantAt || typeof nextGrantAt.toDate !== 'function') { + continue } - const nextGrantDate = nextGrantAt.toDate(); + const nextGrantDate = nextGrantAt.toDate() if (!nextGrantDate || nextGrantDate > new Date()) { - continue; + continue } const subscriptionInfo = - data.stripeSubscription && - typeof data.stripeSubscription === "object" + data.stripeSubscription && typeof data.stripeSubscription === 'object' ? data.stripeSubscription - : {}; - const subscriptionId = - subscriptionInfo.id || data.stripeSubscriptionId || null; + : {} + const subscriptionId = subscriptionInfo.id || data.stripeSubscriptionId || null const orderId = subscriptionId ? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}` - : `subscription_${doc.id}_sched_${nextGrantAt.seconds}`; + : `subscription_${doc.id}_sched_${nextGrantAt.seconds}` try { const { orderId: processedOrderId } = await createOrderDocument({ @@ -108,25 +99,25 @@ const processAnnualSubscriptionAllowances = onSchedule( type: ORDER_TYPES.SUBSCRIPTION, amount: coinsPerMonth, metadata: { - source: "STRIPE_SUBSCRIPTION", - schedule: "annual_scheduler", + source: 'STRIPE_SUBSCRIPTION', + schedule: 'annual_scheduler', subscriptionId, scheduledGrantAt: nextGrantDate.toISOString(), }, orderId, - }); + }) - let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1); - const currentPeriodEnd = subscriptionInfo.currentPeriodEnd; + let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1) + const currentPeriodEnd = subscriptionInfo.currentPeriodEnd if ( nextGrantTimestamp && currentPeriodEnd && - typeof currentPeriodEnd.toDate === "function" + typeof currentPeriodEnd.toDate === 'function' ) { - const periodEndDate = currentPeriodEnd.toDate(); - const nextGrantFutureDate = nextGrantTimestamp.toDate(); + const periodEndDate = currentPeriodEnd.toDate() + const nextGrantFutureDate = nextGrantTimestamp.toDate() if (periodEndDate && nextGrantFutureDate > periodEndDate) { - nextGrantTimestamp = null; + nextGrantTimestamp = null } } @@ -136,53 +127,47 @@ const processAnnualSubscriptionAllowances = onSchedule( subscriptionLastGrantAt: getServerTimestamp(), subscriptionLastGrantAmount: coinsPerMonth, subscriptionLastGrantOrderId: processedOrderId, - subscriptionLastGrantSource: "annual_scheduler", + subscriptionLastGrantSource: 'annual_scheduler', subscriptionNextGrantAt: nextGrantTimestamp || null, - subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null, + subscriptionGrantInterval: nextGrantTimestamp ? 'monthly' : null, }, - }); - grantsCreated += 1; + }) + grantsCreated += 1 if (docsToUpdate.length >= 450) { - await flushUpdates(); + await flushUpdates() } } catch (error) { console.error( - "[subscription-processAnnualSubscriptionAllowances] Unable to create order", + '[subscription-processAnnualSubscriptionAllowances] Unable to create order', { userId: doc.id, subscriptionId, error: error?.message || error, - }, - ); + } + ) } } - lastDoc = snapshot.docs[snapshot.docs.length - 1]; + lastDoc = snapshot.docs[snapshot.docs.length - 1] if (snapshot.size < pageSize) { - break; + break } } - await flushUpdates(); + await flushUpdates() - console.log( - "[subscription-processAnnualSubscriptionAllowances] completed", - { - processedUsers, - grantsCreated, - }, - ); + console.log('[subscription-processAnnualSubscriptionAllowances] completed', { + processedUsers, + grantsCreated, + }) } catch (error) { - console.error( - "[subscription-processAnnualSubscriptionAllowances] error", - error, - ); - throw error; + console.error('[subscription-processAnnualSubscriptionAllowances] error', error) + throw error } - }, -); + } +) module.exports = { processAnnualSubscriptionAllowances, -}; +} diff --git a/functions/src/subscription/shared.js b/functions/src/subscription/shared.js index f9459fc..8f7024f 100644 --- a/functions/src/subscription/shared.js +++ b/functions/src/subscription/shared.js @@ -1,242 +1,226 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') -const { refList } = require("../../index"); -const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys"); +const { refList } = require('../../index') +const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys') const { SUBSCRIPTION_LEVEL_ALLOWANCES, SUBSCRIPTION_PRICE_METADATA, COIN_PACK_PRODUCT_MAP, -} = require("./constants"); +} = require('./constants') -const refsList = refList; -const paymentsCollection = admin.firestore().collection("payments"); -let cachedStripeWebhookSecret = null; +const refsList = refList +const paymentsCollection = admin.firestore().collection('payments') +let cachedStripeWebhookSecret = null const toFiniteNumber = (value) => { - if (typeof value === "number" && Number.isFinite(value)) { - return value; + if (typeof value === 'number' && Number.isFinite(value)) { + return value } - if (typeof value === "string") { - const normalized = value.trim().replace(",", "."); + if (typeof value === 'string') { + const normalized = value.trim().replace(',', '.') if (!normalized) { - return null; + return null } - const parsed = Number(normalized); - return Number.isFinite(parsed) ? parsed : null; + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null } - return null; -}; + return null +} const parseCoinsPerMonth = (metadata) => { - if (!metadata || typeof metadata !== "object") { - return null; + if (!metadata || typeof metadata !== 'object') { + return null } - if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) { - return null; + if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) { + return null } - const candidateValue = toFiniteNumber(metadata.coinsPerMonth); + const candidateValue = toFiniteNumber(metadata.coinsPerMonth) if (candidateValue === null || candidateValue <= 0) { - return null; + return null } - return Math.round(candidateValue); -}; + return Math.round(candidateValue) +} const parseCoinAmount = (metadata) => { - if (!metadata || typeof metadata !== "object") { - return null; + if (!metadata || typeof metadata !== 'object') { + return null } - if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) { - return null; + if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) { + return null } - const candidateValue = toFiniteNumber(metadata.coins); + const candidateValue = toFiniteNumber(metadata.coins) if (candidateValue === null || candidateValue <= 0) { - return null; + return null } - return Math.round(candidateValue); -}; + return Math.round(candidateValue) +} const toDateSafe = (value) => { if (!value) { - return null; + return null } if (value instanceof Date) { - return value; + return value } - if (typeof value?.toDate === "function") { + if (typeof value?.toDate === 'function') { try { - return value.toDate(); + return value.toDate() } catch (_error) { - return null; + return null } } - if (typeof value === "number" && Number.isFinite(value)) { + if (typeof value === 'number' && Number.isFinite(value)) { if (value > 1e12) { - return new Date(value); + return new Date(value) } - return new Date(value * 1000); + return new Date(value * 1000) } - return null; -}; + return null +} const addMonths = (date, months = 1) => { if (!(date instanceof Date) || !Number.isFinite(months)) { - return null; + return null } - const result = new Date(date.getTime()); - const initialDay = result.getDate(); - result.setMonth(result.getMonth() + months); + const result = new Date(date.getTime()) + const initialDay = result.getDate() + result.setMonth(result.getMonth() + months) if (result.getDate() !== initialDay) { - result.setDate(0); + result.setDate(0) } - return result; -}; + return result +} const computeNextGrantTimestamp = (base, months = 1) => { - const baseDate = toDateSafe(base); + const baseDate = toDateSafe(base) if (!baseDate) { - return null; + return null } - const nextDate = addMonths(baseDate, months); + const nextDate = addMonths(baseDate, months) if (!nextDate) { - return null; + return null } - return admin.firestore.Timestamp.fromDate(nextDate); -}; + return admin.firestore.Timestamp.fromDate(nextDate) +} const getServerTimestamp = () => { - if (typeof FieldValue?.serverTimestamp === "function") { - return FieldValue.serverTimestamp(); + if (typeof FieldValue?.serverTimestamp === 'function') { + return FieldValue.serverTimestamp() } - const fallback = admin.firestore?.FieldValue; - if (typeof fallback?.serverTimestamp === "function") { - return fallback.serverTimestamp(); + const fallback = admin.firestore?.FieldValue + if (typeof fallback?.serverTimestamp === 'function') { + return fallback.serverTimestamp() } - throw new Error("Firestore FieldValue.serverTimestamp indisponible."); -}; + throw new Error('Firestore FieldValue.serverTimestamp indisponible.') +} const resolveStripeWebhookSecret = () => { if (cachedStripeWebhookSecret) { - return cachedStripeWebhookSecret; + return cachedStripeWebhookSecret } const envSecret = - typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string" + typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string' ? process.env.STRIPE_WEBHOOK_SECRET.trim() - : ""; - const inlineSecret = - typeof STRIPE_WEBHOOK_SECRET === "string" - ? STRIPE_WEBHOOK_SECRET.trim() - : ""; + : '' + const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : '' - const secret = envSecret || inlineSecret; + const secret = envSecret || inlineSecret if (!secret) { - throw new Error("STRIPE_WEBHOOK_SECRET not configured"); + throw new Error('STRIPE_WEBHOOK_SECRET not configured') } - cachedStripeWebhookSecret = secret; - return cachedStripeWebhookSecret; -}; + cachedStripeWebhookSecret = secret + return cachedStripeWebhookSecret +} const toFirestoreTimestamp = (unixSeconds) => { - if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) { - return null; + if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) { + return null } try { - return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000); + return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000) } catch (error) { - console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error); - return null; + console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error) + return null } -}; +} const extractFirebaseUid = (metadata) => { - if (!metadata || typeof metadata !== "object") { - return null; + if (!metadata || typeof metadata !== 'object') { + return null } - const candidates = [ - metadata.firebaseUID, - metadata.firebaseUid, - metadata.uid, - metadata.userId, - ]; + const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId] for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); + const candidate = candidates[index] + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim() } } - return null; -}; + return null +} const formatCoinPack = ({ product, price }) => { - if (!product || typeof product !== "object") { - return null; + if (!product || typeof product !== 'object') { + return null } const resolvedPrice = - price || - (typeof product.default_price === "object" && product.default_price) || - null; + price || (typeof product.default_price === 'object' && product.default_price) || null const priceId = (resolvedPrice && resolvedPrice.id) || - (typeof product.default_price === "string" ? product.default_price : null); + (typeof product.default_price === 'string' ? product.default_price : null) - const coinAmount = parseCoinAmount(product?.metadata || {}); + const coinAmount = parseCoinAmount(product?.metadata || {}) if (coinAmount === null) { - throw new Error( - `[formatCoinPack] Missing metadata.coins on product ${product.id}`, - ); + throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`) } return { productId: product.id, priceId, - name: product.name || "", - description: product.description || "", + name: product.name || '', + description: product.description || '', coinAmount, currency: resolvedPrice?.currency || - (typeof resolvedPrice?.currency === "string" - ? resolvedPrice.currency.toLowerCase() - : "eur"), + (typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'), unitAmount: resolvedPrice?.unit_amount ?? null, metadata: product.metadata || {}, - }; -}; + } +} const getSubscriptionMetaFromPrice = (priceId) => { - if (typeof priceId !== "string") { - return null; + if (typeof priceId !== 'string') { + return null } - return SUBSCRIPTION_PRICE_METADATA[priceId] || null; -}; + return SUBSCRIPTION_PRICE_METADATA[priceId] || null +} const buildEventSnapshot = (eventType, entityId) => { - const now = admin.firestore.Timestamp.now(); + const now = admin.firestore.Timestamp.now() return { eventType: eventType || null, entityId: entityId || null, syncedAt: now, - }; -}; + } +} const formatSubscriptionForClient = (subscription) => { - if (!subscription || typeof subscription !== "object") { - return null; + if (!subscription || typeof subscription !== 'object') { + return null } return { id: subscription.id, status: subscription.status, customer: subscription.customer, - currentPeriodStart: toFirestoreTimestamp( - subscription.current_period_start, - ), + currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start), currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end), cancelAtPeriodEnd: subscription.cancel_at_period_end === true, created: toFirestoreTimestamp(subscription.created), @@ -248,12 +232,12 @@ const formatSubscriptionForClient = (subscription) => { })) : [], metadata: subscription.metadata || {}, - }; -}; + } +} const buildSubscriptionPayload = (subscription) => { - if (!subscription || typeof subscription !== "object") { - return null; + if (!subscription || typeof subscription !== 'object') { + return null } const items = Array.isArray(subscription.items?.data) @@ -264,20 +248,19 @@ const buildSubscriptionPayload = (subscription) => { quantity: item.quantity || 0, price: item.price || null, })) - : []; + : [] - const primaryItem = items[0] || null; - const productId = primaryItem?.productId || null; - const priceId = primaryItem?.priceId || null; + const primaryItem = items[0] || null + const productId = primaryItem?.productId || null + const priceId = primaryItem?.priceId || null - const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; - const metadataLevel = subscription.metadata?.subscriptionLevel || null; - const resolvedLevel = metadataLevel || priceMeta?.level || null; - const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null; - const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null; + const priceMeta = getSubscriptionMetaFromPrice(priceId) || {} + const metadataLevel = subscription.metadata?.subscriptionLevel || null + const resolvedLevel = metadataLevel || priceMeta?.level || null + const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null + const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null - const customerId = - typeof subscription.customer === "string" ? subscription.customer : null; + const customerId = typeof subscription.customer === 'string' ? subscription.customer : null return { id: subscription.id, @@ -289,34 +272,32 @@ const buildSubscriptionPayload = (subscription) => { billingPeriod: resolvedPeriod, status: subscription.status || null, cancelAtPeriodEnd: subscription.cancel_at_period_end === true, - currentPeriodStart: toFirestoreTimestamp( - subscription.current_period_start, - ), + currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start), currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end), - }; -}; + } +} const resolveUserContext = async ({ metadata, customerId }) => { - const firebaseUid = extractFirebaseUid(metadata); + const firebaseUid = extractFirebaseUid(metadata) if (firebaseUid) { - const userRef = refsList?.users?.doc(firebaseUid) || null; + const userRef = refsList?.users?.doc(firebaseUid) || null if (userRef) { try { - const snapshot = await userRef.get(); + const snapshot = await userRef.get() if (snapshot.exists) { return { uid: firebaseUid, userRef, userData: snapshot.data() || null, - }; + } } } catch (error) { console.warn( - "[subscription-resolveUserContext] Unable to read user", + '[subscription-resolveUserContext] Unable to read user', firebaseUid, - error?.message || error, - ); + error?.message || error + ) } } } @@ -324,23 +305,23 @@ const resolveUserContext = async ({ metadata, customerId }) => { if (customerId) { try { const snapshot = await refsList.users - .where("stripeCustomerId", "==", customerId) + .where('stripeCustomerId', '==', customerId) .limit(1) - .get(); + .get() if (!snapshot.empty) { - const doc = snapshot.docs[0]; + const doc = snapshot.docs[0] return { uid: doc.id, userRef: doc.ref, userData: doc.data() || null, - }; + } } } catch (error) { console.warn( - "[subscription-resolveUserContext] Unable to query by customer", + '[subscription-resolveUserContext] Unable to query by customer', customerId, - error?.message || error, - ); + error?.message || error + ) } } @@ -348,15 +329,15 @@ const resolveUserContext = async ({ metadata, customerId }) => { uid: firebaseUid || null, userRef: null, userData: null, - }; -}; + } +} const upsertPaymentDocument = async (docId, data = {}) => { if (!docId) { - return null; + return null } - const docRef = paymentsCollection.doc(docId); + const docRef = paymentsCollection.doc(docId) try { await docRef.set( { @@ -364,17 +345,13 @@ const upsertPaymentDocument = async (docId, data = {}) => { updatedAt: getServerTimestamp(), createdAt: getServerTimestamp(), }, - { merge: true }, - ); + { merge: true } + ) } catch (error) { - console.error( - "[subscription-upsertPaymentDocument] Failed to persist payment", - docId, - error, - ); + console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error) } - return docRef; -}; + return docRef +} module.exports = { admin, @@ -398,4 +375,4 @@ module.exports = { resolveUserContext, upsertPaymentDocument, COIN_PACK_PRODUCT_MAP, -}; +} diff --git a/functions/src/subscription/webhooks.js b/functions/src/subscription/webhooks.js index ce7519d..48cca8b 100644 --- a/functions/src/subscription/webhooks.js +++ b/functions/src/subscription/webhooks.js @@ -1,9 +1,9 @@ -const { onRequest } = require("firebase-functions/v2/https"); -const { HttpsError } = require("firebase-functions/https"); +const { onRequest } = require('firebase-functions/v2/https') +const { HttpsError } = require('firebase-functions/https') -const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders"); -const { getStripeClient } = require("../../helpers/stripe"); -const { REGION } = require("./config"); +const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders') +const { getStripeClient } = require('../../helpers/stripe') +const { REGION } = require('./config') const { paymentsCollection, resolveStripeWebhookSecret, @@ -17,25 +17,21 @@ const { buildSubscriptionPayload, resolveUserContext, upsertPaymentDocument, -} = require("./shared"); -const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants"); +} = require('./shared') +const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants') -const handleCheckoutSessionCompleted = async ( - session, - event, - { stripe } = {}, -) => { - if (!session || typeof session !== "object") { - return; +const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => { + if (!session || typeof session !== 'object') { + return } - const firebaseUid = extractFirebaseUid(session.metadata); + const firebaseUid = extractFirebaseUid(session.metadata) const paymentDocRef = await upsertPaymentDocument(session.id, { userId: firebaseUid || null, customerId: session.customer || null, subscriptionId: session.subscription || null, invoiceId: session.invoice || null, - status: session.status || "completed", + status: session.status || 'completed', paymentStatus: session.payment_status || null, mode: session.mode || null, amountSubtotal: session.amount_subtotal ?? null, @@ -44,109 +40,103 @@ const handleCheckoutSessionCompleted = async ( metadata: session.metadata || {}, completedAt: toFirestoreTimestamp(session.created), expiresAt: toFirestoreTimestamp(session.expires_at), - paymentIntentId: - typeof session.payment_intent === "string" - ? session.payment_intent - : null, + paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), - }); + }) const { uid, userRef } = await resolveUserContext({ metadata: session.metadata, customerId: session.customer, - }); + }) if (userRef) { - const lastEvent = buildEventSnapshot(event?.type, session.id); + const lastEvent = buildEventSnapshot(event?.type, session.id) if (firebaseUid) { - lastEvent.uid = firebaseUid; + lastEvent.uid = firebaseUid } const userUpdate = { lastStripeWebhookEvent: lastEvent, - }; + } if (session.customer) { - userUpdate.stripeCustomerId = session.customer; + userUpdate.stripeCustomerId = session.customer } if (session.metadata?.subscriptionLevel) { - userUpdate.premiumLevel = session.metadata.subscriptionLevel; + userUpdate.premiumLevel = session.metadata.subscriptionLevel } if (session.metadata?.subscriptionBillingPeriod) { - userUpdate.premiumBillingPeriod = - session.metadata.subscriptionBillingPeriod; + userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod } - await userRef.set(userUpdate, { merge: true }); + await userRef.set(userUpdate, { merge: true }) } if ( stripe && - session.mode === "subscription" && - typeof session.subscription === "string" && + session.mode === 'subscription' && + typeof session.subscription === 'string' && session.subscription ) { try { - const subscription = await stripe.subscriptions.retrieve( - session.subscription, - { expand: ["items.data.price.product"] }, - ); + const subscription = await stripe.subscriptions.retrieve(session.subscription, { + expand: ['items.data.price.product'], + }) if (subscription) { - await handleCustomerSubscriptionEvent(subscription, event, { stripe }); + await handleCustomerSubscriptionEvent(subscription, event, { stripe }) } } catch (error) { console.error( - "[subscription-handleCheckoutSessionCompleted] Unable to sync subscription", + '[subscription-handleCheckoutSessionCompleted] Unable to sync subscription', session.subscription, - error, - ); + error + ) } } if ( - session.mode === "payment" && - (session.payment_status === "paid" || - session.payment_status === "no_payment_required") && - session.metadata?.purchaseType === "COIN_PACK" && + session.mode === 'payment' && + (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && + session.metadata?.purchaseType === 'COIN_PACK' && userRef ) { - const coinAmountRaw = Number(session.metadata?.coinAmount || 0); - const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0; + const coinAmountRaw = Number(session.metadata?.coinAmount || 0) + const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0 if (coinAmount > 0 && paymentDocRef) { - let paymentSnapshot = null; + let paymentSnapshot = null try { - paymentSnapshot = await paymentDocRef.get(); + paymentSnapshot = await paymentDocRef.get() } catch (error) { console.warn( - "[subscription-handleCheckoutSessionCompleted] Unable to read payment doc", + '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc', session.id, - error?.message || error, - ); + error?.message || error + ) } const alreadyGranted = Boolean( - paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt, - ); + paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt + ) if (!alreadyGranted) { - const targetUserId = uid || firebaseUid || userRef.id; + const targetUserId = uid || firebaseUid || userRef.id await createOrderDocument({ userId: targetUserId, type: ORDER_TYPES.COINS, amount: coinAmount, metadata: { - source: "STRIPE_CHECKOUT", + source: 'STRIPE_CHECKOUT', paymentId: session.id || null, coinPackKey: session.metadata?.coinPackKey || null, }, orderId: `stripe_${session.id}`, - }); + }) await paymentDocRef.set( { @@ -154,25 +144,21 @@ const handleCheckoutSessionCompleted = async ( coinPackGrantedAmount: coinAmount, coinPackGrantedKey: session.metadata?.coinPackKey || null, }, - { merge: true }, - ); + { merge: true } + ) } } } -}; +} -const handleCustomerSubscriptionEvent = async ( - subscription, - event, - { stripe } = {}, -) => { - if (!subscription || typeof subscription !== "object") { - return; +const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => { + if (!subscription || typeof subscription !== 'object') { + return } - const subscriptionPayload = buildSubscriptionPayload(subscription); + const subscriptionPayload = buildSubscriptionPayload(subscription) if (!subscriptionPayload) { - return; + return } const { @@ -182,36 +168,36 @@ const handleCustomerSubscriptionEvent = async ( } = await resolveUserContext({ metadata: subscription.metadata, customerId: subscription.customer, - }); + }) - let userData = resolvedUserData || null; + let userData = resolvedUserData || null if (!userData && userRef) { try { - const snapshot = await userRef.get(); - userData = snapshot.exists ? snapshot.data() || null : null; + const snapshot = await userRef.get() + userData = snapshot.exists ? snapshot.data() || null : null } catch (error) { console.warn( - "[subscription-handleCustomerSubscriptionEvent] Unable to read user", + '[subscription-handleCustomerSubscriptionEvent] Unable to read user', subscription.customer, - error?.message || error, - ); + error?.message || error + ) } } - let resolvedCustomerId = subscriptionPayload?.customerId || null; + let resolvedCustomerId = subscriptionPayload?.customerId || null if (!resolvedCustomerId && subscription.customer) { - resolvedCustomerId = subscription.customer; + resolvedCustomerId = subscription.customer } - const fallbackUid = extractFirebaseUid(subscription.metadata); - const resolvedUid = uid || fallbackUid || null; + const fallbackUid = extractFirebaseUid(subscription.metadata) + const resolvedUid = uid || fallbackUid || null await upsertPaymentDocument(subscription.id, { userId: resolvedUid, customerId: resolvedCustomerId, subscriptionId: subscription.id || null, status: subscription.status || null, - mode: "subscription", + mode: 'subscription', priceId: subscriptionPayload?.priceId || null, productId: subscriptionPayload?.productId || null, cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null, @@ -224,76 +210,66 @@ const handleCustomerSubscriptionEvent = async ( lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), - }); + }) if (!userRef) { - console.warn( - "[subscription-handleCustomerSubscriptionEvent] User not resolved", - { - subscriptionId: subscription?.id || null, - customerId: subscription?.customer || null, - metadataKeys: Object.keys(subscription?.metadata || {}), - eventType: event?.type || null, - }, - ); - return; + console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', { + subscriptionId: subscription?.id || null, + customerId: subscription?.customer || null, + metadataKeys: Object.keys(subscription?.metadata || {}), + eventType: event?.type || null, + }) + return } - const lastEvent = buildEventSnapshot(event?.type, subscription.id); + const lastEvent = buildEventSnapshot(event?.type, subscription.id) if (resolvedUid) { - lastEvent.uid = resolvedUid; + lastEvent.uid = resolvedUid } - const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId); - const metadataLevel = subscription.metadata?.subscriptionLevel || null; - const metadataPeriod = - subscription.metadata?.subscriptionBillingPeriod || null; - const resolvedLevel = metadataLevel || priceMeta?.level || null; - const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null; + const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId) + const metadataLevel = subscription.metadata?.subscriptionLevel || null + const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null + const resolvedLevel = metadataLevel || priceMeta?.level || null + const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null const isPremium = subscription.status ? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status) - : false; + : false const primaryItem = Array.isArray(subscriptionPayload?.items) ? subscriptionPayload.items[0] - : null; + : null - let coinsPerMonth = null; + let coinsPerMonth = null const productMetadata = primaryItem?.price && - typeof primaryItem.price === "object" && + typeof primaryItem.price === 'object' && primaryItem.price.product && - typeof primaryItem.price.product === "object" + typeof primaryItem.price.product === 'object' ? primaryItem.price.product.metadata - : null; + : null - coinsPerMonth = parseCoinsPerMonth(productMetadata || {}); + coinsPerMonth = parseCoinsPerMonth(productMetadata || {}) if (coinsPerMonth === null && stripe && primaryItem?.price?.id) { try { - const priceWithProduct = await stripe.prices.retrieve( - primaryItem.price.id, - { expand: ["product"] }, - ); - coinsPerMonth = parseCoinsPerMonth( - priceWithProduct?.product?.metadata || {}, - ); + const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, { + expand: ['product'], + }) + coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {}) } catch (error) { console.warn( - "[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata", + '[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata', primaryItem.price.id, - error?.message || error, - ); + error?.message || error + ) } } - let subscriptionNextGrantAt = null; + let subscriptionNextGrantAt = null if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) { - subscriptionNextGrantAt = computeNextGrantTimestamp( - subscriptionPayload.currentPeriodEnd, - 1, - ); + subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1) } const userUpdate = { @@ -313,53 +289,46 @@ const handleCustomerSubscriptionEvent = async ( premiumLevel: isPremium ? resolvedLevel : null, premiumBillingPeriod: isPremium ? resolvedPeriod : null, subscriptionNextGrantAt, - }; + } if (!isPremium) { - userUpdate.subscriptionNextGrantAt = null; + userUpdate.subscriptionNextGrantAt = null } - await userRef.set(userUpdate, { merge: true }); -}; + await userRef.set(userUpdate, { merge: true }) +} const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { - if (!invoice || typeof invoice !== "object") { - return; + if (!invoice || typeof invoice !== 'object') { + return } - const firebaseUid = extractFirebaseUid(invoice.metadata); - const eventType = event?.type || null; - const paymentDocRef = paymentsCollection.doc(invoice.id); + const firebaseUid = extractFirebaseUid(invoice.metadata) + const eventType = event?.type || null + const paymentDocRef = paymentsCollection.doc(invoice.id) let resolvedSubscriptionId = - typeof invoice.subscription === "string" && invoice.subscription - ? invoice.subscription - : null; + typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null if (!resolvedSubscriptionId) { const lineSubscriptionId = Array.isArray(invoice?.lines?.data) ? invoice.lines.data .map((line) => - typeof line?.subscription === "string" && line.subscription - ? line.subscription - : null, + typeof line?.subscription === 'string' && line.subscription ? line.subscription : null ) .find((value) => value) - : null; + : null if (lineSubscriptionId) { - resolvedSubscriptionId = lineSubscriptionId; - console.log( - "[subscription-handleInvoiceEvent] Subscription resolved from invoice line", - { - invoiceId: invoice?.id || null, - subscriptionId: resolvedSubscriptionId, - }, - ); + resolvedSubscriptionId = lineSubscriptionId + console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', { + invoiceId: invoice?.id || null, + subscriptionId: resolvedSubscriptionId, + }) } } - console.log("[subscription-handleInvoiceEvent] Received invoice webhook", { + console.log('[subscription-handleInvoiceEvent] Received invoice webhook', { eventType, invoiceId: invoice?.id || null, subscriptionId: invoice?.subscription || null, @@ -368,17 +337,14 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { status: invoice?.status || null, billingReason: invoice?.billing_reason || null, attemptCount: invoice?.attempt_count ?? null, - }); + }) await upsertPaymentDocument(invoice.id, { userId: firebaseUid || null, customerId: invoice.customer || null, subscriptionId: resolvedSubscriptionId, status: invoice.status || null, - paymentStatus: - eventType === "invoice.payment_failed" - ? "failed" - : invoice.status || null, + paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null, amountDue: invoice.amount_due ?? null, amountPaid: invoice.amount_paid ?? null, amountRemaining: invoice.amount_remaining ?? null, @@ -394,8 +360,8 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { lastEventType: eventType, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), - mode: "invoice", - }); + mode: 'invoice', + }) const { uid, @@ -404,124 +370,109 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { } = await resolveUserContext({ metadata: invoice.metadata, customerId: invoice.customer, - }); + }) if (!userRef) { - console.warn( - "[subscription-handleInvoiceEvent] User context not resolved", - { - invoiceId: invoice?.id || null, - customerId: invoice?.customer || null, - firebaseUid: firebaseUid || null, - metadataKeys: Object.keys(invoice?.metadata || {}), - }, - ); - return; + console.warn('[subscription-handleInvoiceEvent] User context not resolved', { + invoiceId: invoice?.id || null, + customerId: invoice?.customer || null, + firebaseUid: firebaseUid || null, + metadataKeys: Object.keys(invoice?.metadata || {}), + }) + return } - let userData = resolvedUserData || null; + let userData = resolvedUserData || null if (!userData) { try { - const snapshot = await userRef.get(); - userData = snapshot.exists ? snapshot.data() || null : null; + const snapshot = await userRef.get() + userData = snapshot.exists ? snapshot.data() || null : null } catch (error) { console.warn( - "[subscription-handleInvoiceEvent] Unable to read user", + '[subscription-handleInvoiceEvent] Unable to read user', invoice.customer, - error?.message || error, - ); + error?.message || error + ) } } - const lastEvent = buildEventSnapshot(eventType, invoice.id); + const lastEvent = buildEventSnapshot(eventType, invoice.id) if (firebaseUid) { - lastEvent.uid = firebaseUid; + lastEvent.uid = firebaseUid } const userUpdate = { lastStripeWebhookEvent: lastEvent, - }; + } - const billingReason = invoice.billing_reason || null; - const isInvoicePaid = invoice.status === "paid"; - if ( - billingReason === "subscription_create" || - billingReason === "subscription_cycle" - ) { + const billingReason = invoice.billing_reason || null + const isInvoicePaid = invoice.status === 'paid' + if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') { if (isInvoicePaid && invoice.subscription) { - userUpdate.subscriptionLastInvoiceAt = getServerTimestamp(); + userUpdate.subscriptionLastInvoiceAt = getServerTimestamp() } } - await userRef.set(userUpdate, { merge: true }); + await userRef.set(userUpdate, { merge: true }) const subscriptionLine = Array.isArray(invoice?.lines?.data) ? invoice.lines.data.find( - (line) => - line && - typeof line === "object" && - (line.type === "subscription" || line.price), + (line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price) ) - : null; + : null const priceId = - typeof subscriptionLine?.price?.id === "string" + typeof subscriptionLine?.price?.id === 'string' ? subscriptionLine.price.id - : typeof subscriptionLine?.price === "string" + : typeof subscriptionLine?.price === 'string' ? subscriptionLine.price - : null; + : null - const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; + const priceMeta = getSubscriptionMetaFromPrice(priceId) || {} const billingPeriod = priceMeta.billingPeriod || - (subscriptionLine?.price?.recurring?.interval === "year" - ? "annual" - : subscriptionLine?.price?.recurring?.interval === "month" - ? "monthly" - : null); + (subscriptionLine?.price?.recurring?.interval === 'year' + ? 'annual' + : subscriptionLine?.price?.recurring?.interval === 'month' + ? 'monthly' + : null) const coinsPerMonth = priceMeta.coinsPerMonth ?? parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ?? - null; + null const coinsToGrant = - billingPeriod === "annual" && typeof coinsPerMonth === "number" - ? coinsPerMonth * 12 - : null; + billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null const targetSubscriptionId = resolvedSubscriptionId || - (typeof invoice.subscription === "string" ? invoice.subscription : null) || - (typeof subscriptionLine?.subscription === "string" - ? subscriptionLine.subscription - : null); + (typeof invoice.subscription === 'string' ? invoice.subscription : null) || + (typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null) const shouldGrantUpfront = isInvoicePaid && coinsToGrant && - (billingReason === "subscription_create" || - billingReason === "subscription_cycle"); + (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') if (shouldGrantUpfront && targetSubscriptionId) { - let grantSnapshot = null; + let grantSnapshot = null try { - grantSnapshot = await paymentDocRef.get(); + grantSnapshot = await paymentDocRef.get() } catch (error) { console.warn( - "[subscription-handleInvoiceEvent] Unable to read payment doc before grant", + '[subscription-handleInvoiceEvent] Unable to read payment doc before grant', invoice?.id || null, - error?.message || error, - ); + error?.message || error + ) } const alreadyGranted = - grantSnapshot?.exists && - Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt); + grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt) if (!alreadyGranted) { - const targetUserId = uid || firebaseUid || userRef.id; - const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`; + const targetUserId = uid || firebaseUid || userRef.id + const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}` try { const { orderId: processedOrderId } = await createOrderDocument({ @@ -529,139 +480,129 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { type: ORDER_TYPES.SUBSCRIPTION, amount: coinsToGrant, metadata: { - source: "STRIPE_INVOICE", + source: 'STRIPE_INVOICE', billingPeriod, coinsPerMonth, invoiceId: invoice.id || null, subscriptionId: targetSubscriptionId, - grantStrategy: "upfront", + grantStrategy: 'upfront', }, orderId, - }); + }) await paymentDocRef.set( { subscriptionCoinsGrantedAt: getServerTimestamp(), subscriptionCoinsGrantAmount: coinsToGrant, subscriptionCoinsGrantOrderId: processedOrderId, - subscriptionCoinsGrantSource: "invoice_upfront", + subscriptionCoinsGrantSource: 'invoice_upfront', }, - { merge: true }, - ); + { merge: true } + ) await userRef.set( { subscriptionLastGrantAt: getServerTimestamp(), subscriptionLastGrantAmount: coinsToGrant, subscriptionLastGrantOrderId: processedOrderId, - subscriptionLastGrantSource: "invoice_upfront", + subscriptionLastGrantSource: 'invoice_upfront', subscriptionNextGrantAt: null, subscriptionGrantInterval: null, - subscriptionGrantStrategy: "upfront", + subscriptionGrantStrategy: 'upfront', subscriptionCoinsPerMonth: coinsPerMonth, }, - { merge: true }, - ); + { merge: true } + ) } catch (error) { console.error( - "[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins", + '[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins', { invoiceId: invoice?.id || null, subscriptionId: targetSubscriptionId, error: error?.message || error, - }, - ); + } + ) } } } -}; +} const handleStripeWebhookEvent = async ({ event, stripe }) => { - if (!event || typeof event !== "object") { - return; + if (!event || typeof event !== 'object') { + return } - const eventType = event.type; + const eventType = event.type switch (eventType) { - case "checkout.session.completed": + case 'checkout.session.completed': await handleCheckoutSessionCompleted(event.data?.object, event, { stripe, - }); - break; - case "customer.subscription.created": - case "customer.subscription.updated": - case "customer.subscription.deleted": + }) + break + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': await handleCustomerSubscriptionEvent(event.data?.object, event, { stripe, - }); - break; - case "invoice.payment_succeeded": - case "invoice.payment_failed": - case "invoice.finalized": - await handleInvoiceEvent(event.data?.object, event, { stripe }); - break; + }) + break + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + case 'invoice.finalized': + await handleInvoiceEvent(event.data?.object, event, { stripe }) + break default: - console.log( - "[subscription-handleStripeWebhookEvent] Unhandled event type", - eventType, - ); + console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType) } -}; +} const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => { - if (req.method !== "POST") { - res.status(405).send("Method Not Allowed"); - return; + if (req.method !== 'POST') { + res.status(405).send('Method Not Allowed') + return } - const signature = req.headers["stripe-signature"]; + const signature = req.headers['stripe-signature'] if (!signature) { - res.status(400).send("Missing Stripe signature"); - return; + res.status(400).send('Missing Stripe signature') + return } - let rawBody = req.rawBody; + let rawBody = req.rawBody if (!rawBody && req.body) { - rawBody = Buffer.from(JSON.stringify(req.body)); + rawBody = Buffer.from(JSON.stringify(req.body)) } if (!rawBody) { - res.status(400).send("Missing request body"); - return; + res.status(400).send('Missing request body') + return } - let stripe = null; + let stripe = null try { - stripe = getStripeClient(); + stripe = getStripeClient() } catch (error) { - console.error( - "[subscription-handleStripeWebhook] Stripe client error", - error, - ); - res.status(500).send("Client Stripe indisponible"); - return; + console.error('[subscription-handleStripeWebhook] Stripe client error', error) + res.status(500).send('Client Stripe indisponible') + return } try { - const event = stripe.webhooks.constructEvent( - rawBody, - signature, - resolveStripeWebhookSecret(), - ); + const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret()) - await handleStripeWebhookEvent({ event, stripe }); + await handleStripeWebhookEvent({ event, stripe }) - res.status(200).send({ received: true }); + res.status(200).send({ received: true }) } catch (error) { - console.error("[subscription-handleStripeWebhook] error", error); + console.error('[subscription-handleStripeWebhook] error', error) if (error instanceof HttpsError) { - res.status(400).send(error.message); - return; + res.status(400).send(error.message) + return } - res.status(500).send("Erreur lors du traitement du webhook"); + res.status(500).send('Erreur lors du traitement du webhook') } -}); +}) module.exports = { handleStripeWebhook, -}; +} diff --git a/functions/src/thumbnail.js b/functions/src/thumbnail.js index b9399df..6abbbd1 100644 --- a/functions/src/thumbnail.js +++ b/functions/src/thumbnail.js @@ -1,151 +1,140 @@ -const { onObjectFinalized } = require("firebase-functions/v2/storage"); -const logger = require("firebase-functions/logger"); -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const ffmpeg = require("fluent-ffmpeg"); -const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); -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"); +const { onObjectFinalized } = require('firebase-functions/v2/storage') +const logger = require('firebase-functions/logger') +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const ffmpeg = require('fluent-ffmpeg') +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg') +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); +ffmpeg.setFfmpegPath(ffmpegInstaller.path) exports.generateVideoThumbnail = onObjectFinalized( { - region: "europe-west1", + region: 'europe-west1', timeoutSeconds: 180, - memory: "1GiB", + memory: '1GiB', cpu: 1, }, async (event) => { - const file = event.data || {}; - const bucketName = file.bucket; - const objectName = file.name || ""; - const contentType = file.contentType || ""; + const file = event.data || {} + const bucketName = file.bucket + const objectName = file.name || '' + const contentType = file.contentType || '' // Basic guards + helpful logs for debugging why events might be ignored if (!bucketName || !objectName) { - logger.info("[Thumbnail] Ignored: missing bucket or object name", { + logger.info('[Thumbnail] Ignored: missing bucket or object name', { bucketName, objectName, contentType, - }); - return; + }) + return } // Avoid processing our own generated thumbnails if (/_thumb9x16\.jpg$/i.test(objectName)) { - logger.info("[Thumbnail] Ignored: already a thumbnail", { objectName }); - return; + logger.info('[Thumbnail] Ignored: already a thumbnail', { objectName }) + return } // Accept if contentType says it's a video OR fallback to extension-based check - const isVideoContentType = - typeof contentType === "string" && contentType.startsWith("video/"); - const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test( - objectName.toLowerCase() - ); + const isVideoContentType = typeof contentType === 'string' && contentType.startsWith('video/') + const isVideoLikeName = /\.(mp4|mov|webm|m4v|avi|mkv)$/i.test(objectName.toLowerCase()) if (!isVideoContentType && !isVideoLikeName) { - logger.info("[Thumbnail] Ignored: not a video", { + logger.info('[Thumbnail] Ignored: not a video', { objectName, contentType, - }); - return; + }) + return } - logger.info("[Thumbnail] Event accepted", { + logger.info('[Thumbnail] Event accepted', { bucketName, objectName, contentType, - }); + }) - const bucket = admin.storage().bucket(bucketName); - const playbackMatch = objectName.match( - /^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i - ); - const projectId = playbackMatch ? playbackMatch[1] : null; + const bucket = admin.storage().bucket(bucketName) + const playbackMatch = objectName.match(/^users\/[^/]+\/projects\/([^/]+)\/playback\.mp4$/i) + const projectId = playbackMatch ? playbackMatch[1] : null // Use unique folder under /tmp to avoid name collisions - const tmpDir = path.join( - os.tmpdir(), - `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}` - ); - const baseName = path.basename(objectName); - const dirName = path.posix.dirname(objectName); - const localVideoPath = path.join(tmpDir, baseName); + const tmpDir = path.join(os.tmpdir(), `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`) + const baseName = path.basename(objectName) + const dirName = path.posix.dirname(objectName) + const localVideoPath = path.join(tmpDir, baseName) - const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg"; - const localThumbPath = path.join(tmpDir, thumbBase); + const thumbBase = baseName.replace(/\.[^.]+$/, '') + '_thumb9x16.jpg' + const localThumbPath = path.join(tmpDir, thumbBase) const remoteThumbPath = - dirName && dirName !== "." - ? path.posix.join(dirName, thumbBase) - : thumbBase; + dirName && dirName !== '.' ? path.posix.join(dirName, thumbBase) : thumbBase try { - await fs.mkdir(tmpDir, { recursive: true }); + await fs.mkdir(tmpDir, { recursive: true }) // Télécharger la vidéo depuis le bucket - await bucket.file(objectName).download({ destination: localVideoPath }); + await bucket.file(objectName).download({ destination: localVideoPath }) // Extraire 1 frame en 9:16 (1080x1920) de manière robuste - const vfCoverCrop = - "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920"; + const vfCoverCrop = 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920' const vfPad = - "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2"; + 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2' const extractFrame = (ssSeconds, vf) => new Promise((resolve, reject) => { ffmpeg(localVideoPath) .inputOptions([`-ss ${ssSeconds}`]) .frames(1) - .outputOptions(["-vf", vf, "-q:v", "2"]) + .outputOptions(['-vf', vf, '-q:v', '2']) .output(localThumbPath) - .on("end", resolve) - .on("error", reject) - .run(); - }); + .on('end', resolve) + .on('error', reject) + .run() + }) try { // 1) Essai principal: 1s + cover/crop - await extractFrame(1, vfCoverCrop); + await extractFrame(1, vfCoverCrop) } catch (e1) { - logger.warn("[Thumbnail] First attempt failed, retrying at 0s", { + logger.warn('[Thumbnail] First attempt failed, retrying at 0s', { objectName, error: e1?.message || String(e1), - }); + }) try { // 2) Deuxième essai: 0s + cover/crop (si vidéo très courte) - await extractFrame(0, vfCoverCrop); + await extractFrame(0, vfCoverCrop) } catch (e2) { - logger.warn("[Thumbnail] Second attempt failed, fallback to pad", { + logger.warn('[Thumbnail] Second attempt failed, fallback to pad', { objectName, error: e2?.message || String(e2), - }); + }) // 3) Fallback: 0s + pad (aucun crop, bandes latérales si besoin) - await extractFrame(0, vfPad); + await extractFrame(0, vfPad) } } // Upload du thumbnail avec un token de téléchargement public Firebase - const downloadToken = crypto.randomUUID(); + const downloadToken = crypto.randomUUID() await bucket.upload(localThumbPath, { destination: remoteThumbPath, metadata: { - contentType: "image/jpeg", - cacheControl: "public, max-age=86400", + contentType: 'image/jpeg', + cacheControl: 'public, max-age=86400', metadata: { original: objectName, - aspect: "9:16", - t: "1s", + aspect: '9:16', + t: '1s', firebaseStorageDownloadTokens: downloadToken, }, }, - }); + }) - const encodedPath = encodeURIComponent(remoteThumbPath); - const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`; + const encodedPath = encodeURIComponent(remoteThumbPath) + const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}` if (projectId) { await refList.projects.doc(projectId).set( @@ -154,23 +143,23 @@ exports.generateVideoThumbnail = onObjectFinalized( updatedAt: FieldValue.serverTimestamp(), }, { merge: true } - ); + ) } - logger.info("✅ [Thumbnail] Uploaded", { + logger.info('✅ [Thumbnail] Uploaded', { objectName, remoteThumbPath, projectId, thumbnailUrl, - }); + }) } catch (error) { - logger.error("❌ [Thumbnail] Failed", { + logger.error('❌ [Thumbnail] Failed', { objectName, error: error?.message || String(error), - }); - throw error; + }) + throw error } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true }) } } -); +) diff --git a/functions/src/upload.js b/functions/src/upload.js index 0b54d99..d40af73 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -1,35 +1,35 @@ // functions/mergeVideoAndAudio.js (ou dans index.js) -const { onCall, HttpsError } = require("firebase-functions/v2/https"); -const admin = require("firebase-admin"); -const logger = require("firebase-functions/logger"); -const axios = require("axios"); -const fs = require("node:fs/promises"); -const os = require("node:os"); -const path = require("node:path"); -const crypto = require("node:crypto"); -const ffmpeg = require("fluent-ffmpeg"); -const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); -const { Buffer } = require("node:buffer"); -const { FieldValue } = require("firebase-admin/firestore"); +const { onCall, HttpsError } = require('firebase-functions/v2/https') +const admin = require('firebase-admin') +const logger = require('firebase-functions/logger') +const axios = require('axios') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const crypto = require('node:crypto') +const ffmpeg = require('fluent-ffmpeg') +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg') +const { Buffer } = require('node:buffer') +const { FieldValue } = require('firebase-admin/firestore') -if (!admin.apps.length) admin.initializeApp(); -ffmpeg.setFfmpegPath(ffmpegInstaller.path); +if (!admin.apps.length) admin.initializeApp() +ffmpeg.setFfmpegPath(ffmpegInstaller.path) -const db = admin.firestore(); -const PLAYBACK_CODEC_TAG = "h264-v1"; +const db = admin.firestore() +const PLAYBACK_CODEC_TAG = 'h264-v1' async function downloadToFile(url, destPath) { - if (!/^https?:\/\//i.test(url || "")) { - throw new HttpsError("invalid-argument", `URL non supportée: ${url}`); + if (!/^https?:\/\//i.test(url || '')) { + throw new HttpsError('invalid-argument', `URL non supportée: ${url}`) } - const res = await axios.get(url, { responseType: "arraybuffer" }); - await fs.writeFile(destPath, Buffer.from(res.data)); - return res.headers?.["content-type"] || ""; + const res = await axios.get(url, { responseType: 'arraybuffer' }) + await fs.writeFile(destPath, Buffer.from(res.data)) + return res.headers?.['content-type'] || '' } const SCALE_FILTER = - "scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"; + "scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease" async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { return new Promise((resolve, reject) => { @@ -37,92 +37,92 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) { .input(videoPath) // 0:v .input(audioPath) // 1:a .outputOptions([ - "-map", - "0:v:0", // garder la 1re piste vidéo de l'entrée 0 - "-map", - "1:a:0", // prendre la 1re piste audio de l'entrée 1 + '-map', + '0:v:0', // garder la 1re piste vidéo de l'entrée 0 + '-map', + '1:a:0', // prendre la 1re piste audio de l'entrée 1 // Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème) - "-vf", + '-vf', SCALE_FILTER, - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "22", - "-pix_fmt", - "yuv420p", - "-profile:v", - "high", - "-level:v", - "4.1", - "-c:a", - "aac", - "-b:a", - "192k", - "-movflags", - "+faststart", - "-shortest", // couper à la plus courte des 2 sources - "-tag:v", - "avc1", + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '22', + '-pix_fmt', + 'yuv420p', + '-profile:v', + 'high', + '-level:v', + '4.1', + '-c:a', + 'aac', + '-b:a', + '192k', + '-movflags', + '+faststart', + '-shortest', // couper à la plus courte des 2 sources + '-tag:v', + 'avc1', ]) - .on("error", reject) - .on("end", resolve) - .save(outPath); - }); + .on('error', reject) + .on('end', resolve) + .save(outPath) + }) } async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-")); - const videoPath = path.join(tmpDir, "video.mp4"); - const audioPath = path.join(tmpDir, "audio.mp3"); - const outPath = path.join(tmpDir, "output.mp4"); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-')) + const videoPath = path.join(tmpDir, 'video.mp4') + const audioPath = path.join(tmpDir, 'audio.mp3') + const outPath = path.join(tmpDir, 'output.mp4') try { - logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl }); + logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl }) - await downloadToFile(videoUrl, videoPath); - await downloadToFile(audioUrl, audioPath); + await downloadToFile(videoUrl, videoPath) + await downloadToFile(audioUrl, audioPath) - logger.info("[merge] transcodage/mux ffmpeg"); - await muxAudioIntoVideo({ videoPath, audioPath, outPath }); + logger.info('[merge] transcodage/mux ffmpeg') + await muxAudioIntoVideo({ videoPath, audioPath, outPath }) - const bucket = admin.storage().bucket(); - const downloadToken = crypto.randomUUID(); + const bucket = admin.storage().bucket() + const downloadToken = crypto.randomUUID() await bucket.upload(outPath, { destination: storagePath, metadata: { - contentType: "video/mp4", - cacheControl: "public,max-age=86400", + contentType: 'video/mp4', + cacheControl: 'public,max-age=86400', metadata: { firebaseStorageDownloadTokens: downloadToken }, }, - }); + }) const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( storagePath - )}?alt=media&token=${downloadToken}`; + )}?alt=media&token=${downloadToken}` - logger.info("[merge] upload terminé", { storagePath }); + logger.info('[merge] upload terminé', { storagePath }) return { success: true, url: fileUrl, - contentType: "video/mp4", + contentType: 'video/mp4', storagePath, - }; + } } catch (err) { - logger.error("[merge] échec", { error: err?.message || String(err) }); - if (err instanceof HttpsError) throw err; - throw new HttpsError("internal", err?.message || "Fusion échouée"); + logger.error('[merge] échec', { error: err?.message || String(err) }) + if (err instanceof HttpsError) throw err + throw new HttpsError('internal', err?.message || 'Fusion échouée') } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(tmpDir, { recursive: true, force: true }) } } async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { - if (!projectId) return; - const docRef = db.collection("projects").doc(projectId); + if (!projectId) return + const docRef = db.collection('projects').doc(projectId) await docRef.set( { playbackCompatibility: { @@ -132,89 +132,75 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { }, }, { merge: true } - ); + ) } exports.mergeVideoAndAudio = onCall( - { timeoutSeconds: 540, memory: "1GiB" }, + { timeoutSeconds: 540, memory: '1GiB' }, async ({ data = {}, auth }) => { - const uid = auth?.uid; - if (!uid) - throw new HttpsError("unauthenticated", "Authentification requise"); + const uid = auth?.uid + if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise') - const { videoUrl, audioUrl, storagePath, projectId } = data || {}; + const { videoUrl, audioUrl, storagePath, projectId } = data || {} if (!videoUrl || !audioUrl || !storagePath) { - throw new HttpsError( - "invalid-argument", - "Requis: { videoUrl, audioUrl, storagePath }" - ); + throw new HttpsError('invalid-argument', 'Requis: { videoUrl, audioUrl, storagePath }') } - const expectedPrefix = `users/${uid}/`; + const expectedPrefix = `users/${uid}/` if (!storagePath.startsWith(expectedPrefix)) { - throw new HttpsError( - "permission-denied", - `storagePath doit commencer par ${expectedPrefix}` - ); + throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`) } - const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); + const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) if (projectId) { - await markPlaybackCompatibility({ projectId, initiatorUid: uid }); + await markPlaybackCompatibility({ projectId, initiatorUid: uid }) } - return result; + return result } -); +) exports.reencodePlayback = onCall( - { timeoutSeconds: 540, memory: "1GiB" }, + { timeoutSeconds: 540, memory: '1GiB' }, async ({ data = {}, auth }) => { - const uid = auth?.uid; - if (!uid) - throw new HttpsError("unauthenticated", "Authentification requise"); + const uid = auth?.uid + if (!uid) throw new HttpsError('unauthenticated', 'Authentification requise') - const projectId = data?.projectId; + const projectId = data?.projectId if (!projectId) { - throw new HttpsError( - "invalid-argument", - "Requis: { projectId } pour relancer le transcodage" - ); + throw new HttpsError('invalid-argument', 'Requis: { projectId } pour relancer le transcodage') } - logger.info("[reencodePlayback] request received", { + logger.info('[reencodePlayback] request received', { projectId, initiator: uid, - }); - const projectRef = db.collection("projects").doc(projectId); - const projectSnap = await projectRef.get(); + }) + const projectRef = db.collection('projects').doc(projectId) + const projectSnap = await projectRef.get() if (!projectSnap.exists) { - logger.warn("[reencodePlayback] project not found", { + logger.warn('[reencodePlayback] project not found', { projectId, initiator: uid, - }); - throw new HttpsError("not-found", "Projet introuvable"); + }) + throw new HttpsError('not-found', 'Projet introuvable') } - const project = projectSnap.data() || {}; - const videoUrl = project.playbackUrl; - const audioUrl = project.songUrl; - const ownerId = project.userId; + const project = projectSnap.data() || {} + const videoUrl = project.playbackUrl + const audioUrl = project.songUrl + const ownerId = project.userId if (!videoUrl || !audioUrl || !ownerId) { - logger.warn("[reencodePlayback] missing fields", { + logger.warn('[reencodePlayback] missing fields', { projectId, hasPlaybackUrl: !!videoUrl, hasSongUrl: !!audioUrl, ownerId, - }); - throw new HttpsError( - "failed-precondition", - "playbackUrl, songUrl ou userId manquant" - ); + }) + throw new HttpsError('failed-precondition', 'playbackUrl, songUrl ou userId manquant') } - const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`; - const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }); + const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4` + const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) await projectRef.set( { @@ -222,13 +208,13 @@ exports.reencodePlayback = onCall( updatedAt: FieldValue.serverTimestamp(), }, { merge: true } - ); - await markPlaybackCompatibility({ projectId, initiatorUid: uid }); - logger.info("[reencodePlayback] success", { + ) + await markPlaybackCompatibility({ projectId, initiatorUid: uid }) + logger.info('[reencodePlayback] success', { projectId, initiator: uid, storagePath, - }); - return result; + }) + return result } -); +) diff --git a/functions/src/users.js b/functions/src/users.js index 32ceef8..c4bd1c4 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -1,137 +1,123 @@ -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const { - onDocumentDeleted, - onDocumentCreated, -} = require("firebase-functions/firestore"); -const { refList } = require("../index"); -const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders"); -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 admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const { onDocumentDeleted, onDocumentCreated } = require('firebase-functions/firestore') +const { refList } = require('../index') +const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders') +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"; +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" }); + 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 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('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 }); + 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() }); + 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 { - const { - email = "", - firstName = "", - lastName = "", - } = event?.data?.data() || {}; + const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {} - const userId = event?.params?.userID; + const userId = event?.params?.userID try { await createOrderDocument({ userId, type: ORDER_TYPES.GIFT, amount: 10, - metadata: { reason: "WELCOME_BONUS" }, + metadata: { reason: 'WELCOME_BONUS' }, orderId: `welcome_${userId}`, - }); + }) } catch (coinError) { console.warn( - "[users-onUserCreated] Unable to grant welcome coins", + '[users-onUserCreated] Unable to grant welcome coins', event?.params?.userID, - coinError?.message || coinError, - ); + coinError?.message || coinError + ) } if (email) { if (!resendClient) { - console.warn("Resend API key not configured; skipping welcome email."); - return; + console.warn('Resend API key not configured; skipping welcome email.') + return } try { - console.log(`Sending welcome email to ${email}`); + console.log(`Sending welcome email to ${email}`) const { data, error } = await resendClient.emails.send({ from: WELCOME_EMAIL_FROM, to: [email], subject: WELCOME_EMAIL_SUBJECT, html: welcomeTemplate({ firstName, lastName }), - }); + }) if (error) { - console.log("Failed to send welcome email:", error); - return; + console.log('Failed to send welcome email:', error) + return } - console.log("Welcome email sent:", data); + console.log('Welcome email sent:', data) } catch (error) { - console.log("Failed to send welcome email:", error); + console.log('Failed to send welcome email:', error) } } else { - throw new Error("User created with empty email"); + throw new Error('User created with empty email') } } catch (e) { - console.log(e); + console.log(e) } -}); +}) -exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => { +exports.onUserDelete = onDocumentDeleted('users/{userID}', async (event) => { try { - const userID = event?.params?.userID; - await clearAllUserData(userID); + const userID = event?.params?.userID + await clearAllUserData(userID) - await deleteFolder(`users/${userID}/`); + await deleteFolder(`users/${userID}/`) - await admin.auth().deleteUser(userID); - console.log(`User ${userID} deleted successfully`); + await admin.auth().deleteUser(userID) + console.log(`User ${userID} deleted successfully`) } catch (e) { - console.log(e); + console.log(e) } -}); +}) async function clearAllUserData(userID) { - const deleteAll = async (ref, key, operator = "==") => { - const snapshot = await ref.where(key, operator, userID).get(); - snapshot.forEach((item) => item.ref.delete()); - }; + const deleteAll = async (ref, key, operator = '==') => { + const snapshot = await ref.where(key, operator, userID).get() + snapshot.forEach((item) => item.ref.delete()) + } const removeFromArray = async (ref, arrayName) => { - const snapshot = await ref.where(arrayName, "array-contains", userID).get(); + const snapshot = await ref.where(arrayName, 'array-contains', userID).get() snapshot.forEach((item) => item.ref.update({ [arrayName]: FieldValue.arrayRemove(userID), - }), - ); - }; + }) + ) + } - await deleteAll(refList.projects, "userId"); - await deleteAll(refList.playlists, "createdBy"); + await deleteAll(refList.projects, 'userId') + await deleteAll(refList.playlists, 'createdBy') } diff --git a/functions/src/youtube.js b/functions/src/youtube.js index 2b3a3c7..22f0d2c 100644 --- a/functions/src/youtube.js +++ b/functions/src/youtube.js @@ -1,34 +1,29 @@ -const { onCall, HttpsError } = require("firebase-functions/v2/https"); -const { defineSecret } = require("firebase-functions/params"); -const logger = require("firebase-functions/logger"); -const functions = require("firebase-functions"); -const admin = require("firebase-admin"); -const { FieldValue } = require("firebase-admin/firestore"); -const axios = require("axios"); -const fs = require("node:fs"); -const fsp = require("node:fs/promises"); -const os = require("node:os"); -const path = require("node:path"); -const { google } = require("googleapis"); +const { onCall, HttpsError } = require('firebase-functions/v2/https') +const { defineSecret } = require('firebase-functions/params') +const logger = require('firebase-functions/logger') +const functions = require('firebase-functions') +const admin = require('firebase-admin') +const { FieldValue } = require('firebase-admin/firestore') +const axios = require('axios') +const fs = require('node:fs') +const fsp = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { google } = require('googleapis') // ---- Secrets déclarés (gen2 + Secret Manager) -const S_YT_CLIENT_ID = defineSecret("YOUTUBE_CLIENT_ID"); -const S_YT_CLIENT_SECRET = defineSecret("YOUTUBE_CLIENT_SECRET"); -const S_YT_REFRESH_TOKEN = defineSecret("YOUTUBE_REFRESH_TOKEN"); -const S_YT_REDIRECT_URI = defineSecret("YOUTUBE_REDIRECT_URI"); -const S_YT_PRIVACY_STATUS = defineSecret("YOUTUBE_PRIVACY_STATUS"); -const S_YT_CATEGORY_ID = defineSecret("YOUTUBE_CATEGORY_ID"); +const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID') +const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET') +const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN') +const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI') +const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS') +const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID') // Firestore -const firestore = admin.firestore(); -const projectsRef = firestore.collection("projects"); +const firestore = admin.firestore() +const projectsRef = firestore.collection('projects') -const YOUTUBE_IN_PROGRESS_STATUSES = [ - "PUBLISHING", - "UPLOADING", - "PROCESSING", - "QUEUED", -]; +const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED'] // Lecture des secrets (recommandé en v2) const getSecretsYoutubeConfig = () => @@ -40,44 +35,44 @@ const getSecretsYoutubeConfig = () => redirect_uri: S_YT_REDIRECT_URI.value(), privacy_status: S_YT_PRIVACY_STATUS.value(), category_id: S_YT_CATEGORY_ID.value(), - }).filter(([, value]) => value !== undefined && value !== "") - ); + }).filter(([, value]) => value !== undefined && value !== '') + ) // Compat facultative v1 -> renverra {} en v2 (et on log un warn propre) const getLegacyYoutubeConfig = () => { - if (typeof functions.config !== "function") { - return {}; + if (typeof functions.config !== 'function') { + return {} } try { - return functions.config()?.youtube || {}; + return functions.config()?.youtube || {} } catch (error) { if ( - typeof error?.message === "string" && - error.message.includes("functions.config() is no longer available") + typeof error?.message === 'string' && + error.message.includes('functions.config() is no longer available') ) { logger.warn( - "[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)" - ); - return {}; + '[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)' + ) + return {} } - throw error; + throw error } -}; +} const ensureYoutubeConfig = () => { // Fusionne (par prudence) l’ancienne config et les secrets actuels - const firebaseConfig = getLegacyYoutubeConfig(); - const secretConfig = getSecretsYoutubeConfig(); - const cfg = { ...firebaseConfig, ...secretConfig }; + const firebaseConfig = getLegacyYoutubeConfig() + const secretConfig = getSecretsYoutubeConfig() + const cfg = { ...firebaseConfig, ...secretConfig } - const requiredKeys = ["client_id", "client_secret", "refresh_token"]; - const missing = requiredKeys.filter((key) => !cfg[key]); + const requiredKeys = ['client_id', 'client_secret', 'refresh_token'] + const missing = requiredKeys.filter((key) => !cfg[key]) if (missing.length) { throw new HttpsError( - "failed-precondition", - `Configuration YouTube manquante: ${missing.join(", ")}` - ); + 'failed-precondition', + `Configuration YouTube manquante: ${missing.join(', ')}` + ) } return { @@ -87,63 +82,54 @@ const ensureYoutubeConfig = () => { redirectUri: cfg.redirect_uri, defaultPrivacyStatus: cfg.privacy_status, defaultCategoryId: cfg.category_id, - }; -}; + } +} -const createYoutubeClient = ({ - clientId, - clientSecret, - refreshToken, - redirectUri, -}) => { - const oauth2Client = new google.auth.OAuth2( - clientId, - clientSecret, - redirectUri - ); - oauth2Client.setCredentials({ refresh_token: refreshToken }); +const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => { + const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri) + oauth2Client.setCredentials({ refresh_token: refreshToken }) const youtube = google.youtube({ - version: "v3", + version: 'v3', auth: oauth2Client, - }); - return { youtube, oauth2Client }; -}; + }) + return { youtube, oauth2Client } +} const downloadFile = async (url, destinationPath) => { - if (!/^https?:\/\//i.test(url || "")) { - throw new HttpsError("invalid-argument", `URL non valide: ${url}`); + if (!/^https?:\/\//i.test(url || '')) { + throw new HttpsError('invalid-argument', `URL non valide: ${url}`) } - await fsp.mkdir(path.dirname(destinationPath), { recursive: true }); + await fsp.mkdir(path.dirname(destinationPath), { recursive: true }) - const response = await axios.get(url, { responseType: "stream" }); + const response = await axios.get(url, { responseType: 'stream' }) await new Promise((resolve, reject) => { - const writer = fs.createWriteStream(destinationPath); - response.data.pipe(writer); - writer.on("finish", resolve); - writer.on("error", reject); - }); + const writer = fs.createWriteStream(destinationPath) + response.data.pipe(writer) + writer.on('finish', resolve) + writer.on('error', reject) + }) - return destinationPath; -}; + return destinationPath +} const buildVideoMetadata = (project, defaults) => { - const baseTitle = project?.title || "Création MusicLand"; - const youtubeTitle = `${baseTitle} | MusicLand`; - const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`; + const baseTitle = project?.title || 'Création MusicLand' + const youtubeTitle = `${baseTitle} | MusicLand` + const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !` const tags = Array.isArray(project?.youtubeTags) ? project.youtubeTags.filter(Boolean).slice(0, 500) - : undefined; + : undefined const snippet = { title: youtubeTitle, description, categoryId: defaults.defaultCategoryId, - }; + } if (tags && tags.length) { - snippet.tags = tags; + snippet.tags = tags } return { @@ -153,17 +139,17 @@ const buildVideoMetadata = (project, defaults) => { embeddable: true, selfDeclaredMadeForKids: false, }, - }; -}; + } +} exports.publishPlaybackToYoutube = onCall( { timeoutSeconds: 540, - memory: "1GiB", + memory: '1GiB', cors: [ - "http://localhost:8081", - "https://musicland-one.vercel.app/", - "https://musicland-d33f9.firebaseapp.com", + 'http://localhost:8081', + 'https://musicland-one.vercel.app/', + 'https://musicland-d33f9.firebaseapp.com', ], // Secrets requis pour l’exécution (v2) secrets: [ @@ -176,98 +162,86 @@ exports.publishPlaybackToYoutube = onCall( ], }, async ({ data = {}, auth }) => { - const uid = auth?.uid; + const uid = auth?.uid if (!uid) { - throw new HttpsError("unauthenticated", "Authentification requise"); + throw new HttpsError('unauthenticated', 'Authentification requise') } - const projectId = data?.projectId; - if (!projectId || typeof projectId !== "string") { - throw new HttpsError("invalid-argument", "Paramètre projectId requis"); + const projectId = data?.projectId + if (!projectId || typeof projectId !== 'string') { + throw new HttpsError('invalid-argument', 'Paramètre projectId requis') } - const projectSnap = await projectsRef.doc(projectId).get(); + const projectSnap = await projectsRef.doc(projectId).get() if (!projectSnap.exists) { - throw new HttpsError("not-found", "Projet introuvable"); + throw new HttpsError('not-found', 'Projet introuvable') } - const project = projectSnap.data(); + const project = projectSnap.data() if (!project || project.userId !== uid) { - throw new HttpsError( - "permission-denied", - "Vous n'avez pas les droits sur ce projet" - ); + throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet") } if (!project.playbackUrl) { - throw new HttpsError( - "failed-precondition", - "Aucun playback disponible pour la publication" - ); + throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication') } - if ( - project.youtubeStatus && - YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus) - ) { + if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) { throw new HttpsError( - "failed-precondition", - "Une publication est déjà en cours pour ce projet" - ); + 'failed-precondition', + 'Une publication est déjà en cours pour ce projet' + ) } - let youtubeDefaults; - let youtubeClient; + let youtubeDefaults + let youtubeClient try { - youtubeDefaults = ensureYoutubeConfig(); - youtubeClient = createYoutubeClient(youtubeDefaults); - await youtubeClient.oauth2Client.getAccessToken(); + youtubeDefaults = ensureYoutubeConfig() + youtubeClient = createYoutubeClient(youtubeDefaults) + await youtubeClient.oauth2Client.getAccessToken() } catch (error) { - logger.error("[publishPlaybackToYoutube] configuration invalide", { + logger.error('[publishPlaybackToYoutube] configuration invalide', { error: error?.message, - }); - throw new HttpsError( - "failed-precondition", - "Configuration YouTube invalide ou incomplète" - ); + }) + throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète') } - const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "yt-upload-")); - const videoPath = path.join(tmpDir, "playback.mp4"); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-')) + const videoPath = path.join(tmpDir, 'playback.mp4') try { await projectsRef.doc(projectId).set( { - youtubeStatus: "PUBLISHING", + youtubeStatus: 'PUBLISHING', youtubePublished: false, youtubeError: null, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } - ); + ) - await downloadFile(project.playbackUrl, videoPath); + await downloadFile(project.playbackUrl, videoPath) - const metadata = buildVideoMetadata(project, youtubeDefaults); + const metadata = buildVideoMetadata(project, youtubeDefaults) const uploadResponse = await youtubeClient.youtube.videos.insert({ - part: ["snippet", "status"].join(","), + part: ['snippet', 'status'].join(','), requestBody: metadata, media: { body: fs.createReadStream(videoPath), }, - }); + }) - const videoId = uploadResponse?.data?.id; + const videoId = uploadResponse?.data?.id if (!videoId) { - throw new Error("ID de vidéo introuvable dans la réponse YouTube"); + throw new Error('ID de vidéo introuvable dans la réponse YouTube') } - const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`; + const youtubeLink = `https://www.youtube.com/watch?v=${videoId}` await projectsRef.doc(projectId).set( { - youtubeStatus: "PUBLISHED", + youtubeStatus: 'PUBLISHED', youtubePublished: true, youtubeUrl: youtubeLink, youtubeVideoId: videoId, @@ -276,45 +250,45 @@ exports.publishPlaybackToYoutube = onCall( updatedAt: FieldValue.serverTimestamp(), }, { merge: true } - ); + ) - logger.info("[publishPlaybackToYoutube] publication réussie", { + logger.info('[publishPlaybackToYoutube] publication réussie', { projectId, videoId, - }); + }) return { videoId, youtubeUrl: youtubeLink, - }; + } } catch (error) { - logger.error("[publishPlaybackToYoutube] échec de publication", { + logger.error('[publishPlaybackToYoutube] échec de publication', { projectId, error: error?.message, - }); + }) const errorMessage = error instanceof HttpsError ? error.message - : error?.message || "Publication YouTube échouée"; + : error?.message || 'Publication YouTube échouée' await projectsRef.doc(projectId).set( { - youtubeStatus: "FAILED", + youtubeStatus: 'FAILED', youtubePublished: false, youtubeError: errorMessage, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } - ); + ) if (error instanceof HttpsError) { - throw error; + throw error } - throw new HttpsError("internal", errorMessage); + throw new HttpsError('internal', errorMessage) } finally { - await fsp.rm(tmpDir, { recursive: true, force: true }); + await fsp.rm(tmpDir, { recursive: true, force: true }) } } -); +) diff --git a/index.js b/index.js index 433b8a5..7e8099c 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -import { registerRootComponent } from "expo"; -import App from "./App"; +import { registerRootComponent } from 'expo' +import App from './App' // registerRootComponent calls AppRegistry.registerComponent('main', () => App); // It also ensures that whether you load the app in Expo Go or in a native build, @@ -8,10 +8,10 @@ import App from "./App"; function HeadlessCheck({ isHeadless }) { if (isHeadless) { // App has been launched in the background by iOS, ignore - return null; + return null } - return ; + return } -registerRootComponent(App, () => HeadlessCheck); +registerRootComponent(App, () => HeadlessCheck) diff --git a/index.web.js b/index.web.js index 739e2e8..06725a9 100644 --- a/index.web.js +++ b/index.web.js @@ -1,37 +1,37 @@ -import "@expo/metro-runtime"; -import { registerRootComponent } from "expo"; +import '@expo/metro-runtime' +import { registerRootComponent } from 'expo' -import App from "./App"; +import App from './App' // 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"); +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"); + const style = document.createElement('style') + style.setAttribute('data-inline-global', 'true') style.innerHTML = ` html, body, #root { height: 100%; } body { margin: 0; } *:focus { outline: none; } * { scrollbar-width: none; -ms-overflow-style: none; } *::-webkit-scrollbar { display: none; } - `; - document.head.appendChild(style); + ` + document.head.appendChild(style) - const proto = window.HTMLElement && window.HTMLElement.prototype; - if (proto && typeof proto.setNativeProps !== "function") { + const proto = window.HTMLElement && window.HTMLElement.prototype + if (proto && typeof proto.setNativeProps !== 'function') { proto.setNativeProps = function (nativeProps = {}) { try { - const { style: s, pointerEvents, ...rest } = nativeProps || {}; + const { style: s, pointerEvents, ...rest } = nativeProps || {} if (pointerEvents != null) { - this.style.pointerEvents = pointerEvents; + this.style.pointerEvents = pointerEvents } - if (s && typeof s === "object") { + if (s && typeof s === 'object') { for (const k in s) { if (Object.prototype.hasOwnProperty.call(s, k)) { try { - this.style[k] = s[k]; + this.style[k] = s[k] } catch {} } } @@ -39,13 +39,13 @@ if (typeof document !== "undefined") { for (const k in rest) { if (Object.prototype.hasOwnProperty.call(rest, k)) { try { - this.style[k] = rest[k]; + this.style[k] = rest[k] } catch {} } } } catch {} - }; + } } } -registerRootComponent(App); +registerRootComponent(App) diff --git a/metro.config.js b/metro.config.js index 1a4263a..cc1faf4 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,18 +1,18 @@ -const { getDefaultConfig } = require("@expo/metro-config"); +const { getDefaultConfig } = require('@expo/metro-config') -const config = getDefaultConfig(__dirname); +const config = getDefaultConfig(__dirname) // Vérifier que .js est bien présent -if (!config.resolver.sourceExts.includes("js")) { - config.resolver.sourceExts.push("js"); +if (!config.resolver.sourceExts.includes('js')) { + config.resolver.sourceExts.push('js') } // Ajouter ce dont tu as besoin -config.resolver.sourceExts.push("cjs", "mjs"); +config.resolver.sourceExts.push('cjs', 'mjs') // Assurer que la plateforme web est bien prise en compte en priorité config.resolver.platforms = Array.from( - new Set(["web", ...((config.resolver && config.resolver.platforms) || [])]) -); + new Set(['web', ...((config.resolver && config.resolver.platforms) || [])]) +) -module.exports = config; +module.exports = config diff --git a/package.json b/package.json index 7aab72f..9052740 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "start": "expo start --dev-client", "android": "expo run:android", "ios": "expo run:ios -d", + "format": "prettier --write \"src/**/*.js\"", "web": "expo start --web", "xcode": "open ios/musicland.xcworkspace", "clean": "npx expo prebuild --clean", @@ -111,7 +112,7 @@ "imagemin-jpegtran": "~7.0.0", "imagemin-optipng": "~8.0.0", "imagemin-svgo": "~11.0.1", - "prettier": "~3.2.5", + "prettier": "^3.7.4", "typescript": "~5.3.3" }, "resolutions": { diff --git a/src/actions/dateActions.js b/src/actions/dateActions.js index 8e2b92b..adb34f0 100644 --- a/src/actions/dateActions.js +++ b/src/actions/dateActions.js @@ -1,18 +1,18 @@ -import moment from 'moment'; +import moment from 'moment' export const validateDate = (date = null) => { if (!date) { - return null; + return null } if (moment(date).isValid()) { - return date; + return date } if (typeof date?.toDate === 'function') { - return date?.toDate(); + return date?.toDate() } - return null; -}; + return null +} export function getAge(birthDate) { - return moment().diff(birthDate.toDate(), 'years', false); + return moment().diff(birthDate.toDate(), 'years', false) } diff --git a/src/actions/responsiveSizes.js b/src/actions/responsiveSizes.js index b31bdd8..8c4e680 100644 --- a/src/actions/responsiveSizes.js +++ b/src/actions/responsiveSizes.js @@ -2,7 +2,7 @@ import { responsiveHeight as _responsiveHeight, responsiveWidth as _responsiveWidth, responsiveFontSize as _responsiveFontSize, -} from "react-native-responsive-dimensions"; +} from 'react-native-responsive-dimensions' import { isWeb, @@ -10,31 +10,31 @@ import { isLargeDesktop, isSmallDesktop, isSuperSmallDesktop, -} from "../hooks/useLayoutType"; +} from '../hooks/useLayoutType' export const reductionCoeff = (bypass) => - bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1; + bypass ? 1 : isLargeDesktop ? 3 : isSmallDesktop ? 2.2 : isDesktop ? 2.8 : 1 export const responsiveHeight = (height, bypass = false) => { - return _responsiveHeight(height) / reductionCoeff(bypass); -}; + return _responsiveHeight(height) / reductionCoeff(bypass) +} export const responsiveWidth = (width, bypass = false) => { - return _responsiveWidth(width) / reductionCoeff(bypass); -}; + return _responsiveWidth(width) / reductionCoeff(bypass) +} export const responsiveFontSize = (fontSize, bypass = false) => { if (isWeb) { if (isSuperSmallDesktop) { - return fontSize * 6; + return fontSize * 6 } else if (isSmallDesktop) { - return fontSize * 7; + return fontSize * 7 } else { - return fontSize * 7.5; + return fontSize * 7.5 } } else { - return _responsiveFontSize(fontSize) / reductionCoeff(bypass); + return _responsiveFontSize(fontSize) / reductionCoeff(bypass) } -}; +} // test diff --git a/src/actions/signupActions.js b/src/actions/signupActions.js index 81d8efd..d1ed529 100644 --- a/src/actions/signupActions.js +++ b/src/actions/signupActions.js @@ -1,86 +1,82 @@ export function checkIfEmailIsValid({ email }) { let regex = new RegExp( "([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|\"([]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(.[!#-'*+/-9=?A-Z^-~-]+)*|[[\t -Z^-~]*])" - ); - return regex.test(email); + ) + return regex.test(email) } export function checkIfPasswordIsStrongEnough({ password }) { - const reg = - /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/; - return reg.test(password); + const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{6,})/ + return reg.test(password) } export const formatPhoneNumber = ({ phoneNumber = null }) => { if (!phoneNumber) { - return null; + return null } - let newPhoneNumber = phoneNumber; + let newPhoneNumber = phoneNumber newPhoneNumber = newPhoneNumber .trim() - .replace(/\s+/g, "") // remove spaces - .replace(/\D/g, ""); // remove non digits + .replace(/\s+/g, '') // remove spaces + .replace(/\D/g, '') // remove non digits - if (newPhoneNumber.startsWith("330")) { - newPhoneNumber = newPhoneNumber.substring(2); + if (newPhoneNumber.startsWith('330')) { + newPhoneNumber = newPhoneNumber.substring(2) } - if (newPhoneNumber.startsWith("06") || newPhoneNumber.startsWith("07")) { - newPhoneNumber = `+33${newPhoneNumber.slice(1)}`; - } else if ( - newPhoneNumber.startsWith("336") || - newPhoneNumber.startsWith("337") - ) { - newPhoneNumber = `+${newPhoneNumber}`; + if (newPhoneNumber.startsWith('06') || newPhoneNumber.startsWith('07')) { + newPhoneNumber = `+33${newPhoneNumber.slice(1)}` + } else if (newPhoneNumber.startsWith('336') || newPhoneNumber.startsWith('337')) { + newPhoneNumber = `+${newPhoneNumber}` } else { - newPhoneNumber = null; + newPhoneNumber = null } - return newPhoneNumber; -}; + return newPhoneNumber +} -export function handleFirebaseError(code = "") { +export function handleFirebaseError(code = '') { switch (code) { - case "auth/user-not-found": - return "Ce compte n'existe pas."; - case "auth/user-disabled": - return "Ce compte est désactivé. Contacte le support si besoin."; - case "auth/invalid-verification-code": - return "Ton code de validation est incorrect."; - case "auth/provider-already-linked": - return "Ce compte est déjà lié à un utilisateur."; - case "auth/invalid-credential": - case "auth/invalid-login-credential": - case "auth/invalid-login-credentials": - return "Identifiants incorrects."; - case "auth/credential-already-in-use": - return "Ce compte existe déjà ou est déjà lié."; - case "auth/operation-not-allowed": - return "Le fournisseur d'identité n'est pas disponible."; - case "auth/invalid-email": - return "Adresse e-mail invalide."; - case "auth/wrong-password": - return "Mot de passe incorrect."; - case "auth/invalid-verification-id": - return "Impossible de t'authentifier, réessaie dans quelques secondes."; - case "auth/invalid-phone-number": - return "Numéro de téléphone incorrect."; - case "auth/too-many-requests": - return "Trop de tentatives. Réessaie dans quelques minutes."; - case "auth/email-already-in-use": - return "Un compte avec cette adresse mail existe déjà."; - case "auth/missing-password": - return "Renseigne ton mot de passe."; - case "auth/weak-password": - return "Ton mot de passe est trop faible."; - case "auth/network-request-failed": - return "Problème de connexion réseau. Vérifie ta connexion et réessaie."; - case "auth/invalid-action-code": - case "auth/expired-action-code": - case "auth/missing-oob-code": - return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe."; - case "auth/missing-email": - return "Renseigne ton adresse e-mail."; + case 'auth/user-not-found': + return "Ce compte n'existe pas." + case 'auth/user-disabled': + return 'Ce compte est désactivé. Contacte le support si besoin.' + case 'auth/invalid-verification-code': + return 'Ton code de validation est incorrect.' + case 'auth/provider-already-linked': + return 'Ce compte est déjà lié à un utilisateur.' + case 'auth/invalid-credential': + case 'auth/invalid-login-credential': + case 'auth/invalid-login-credentials': + return 'Identifiants incorrects.' + case 'auth/credential-already-in-use': + return 'Ce compte existe déjà ou est déjà lié.' + case 'auth/operation-not-allowed': + return "Le fournisseur d'identité n'est pas disponible." + case 'auth/invalid-email': + return 'Adresse e-mail invalide.' + case 'auth/wrong-password': + return 'Mot de passe incorrect.' + case 'auth/invalid-verification-id': + return "Impossible de t'authentifier, réessaie dans quelques secondes." + case 'auth/invalid-phone-number': + return 'Numéro de téléphone incorrect.' + case 'auth/too-many-requests': + return 'Trop de tentatives. Réessaie dans quelques minutes.' + case 'auth/email-already-in-use': + return 'Un compte avec cette adresse mail existe déjà.' + case 'auth/missing-password': + return 'Renseigne ton mot de passe.' + case 'auth/weak-password': + return 'Ton mot de passe est trop faible.' + case 'auth/network-request-failed': + return 'Problème de connexion réseau. Vérifie ta connexion et réessaie.' + case 'auth/invalid-action-code': + case 'auth/expired-action-code': + case 'auth/missing-oob-code': + return "Ce lien de réinitialisation n'est plus valide. Demande un nouveau mot de passe." + case 'auth/missing-email': + return 'Renseigne ton adresse e-mail.' default: - return "Une erreur est survenue. Réessaie dans quelques instants."; + return 'Une erreur est survenue. Réessaie dans quelques instants.' } } diff --git a/src/assets/UI/EyeSVG.js b/src/assets/UI/EyeSVG.js index 82c77ad..33b1dbf 100644 --- a/src/assets/UI/EyeSVG.js +++ b/src/assets/UI/EyeSVG.js @@ -1,5 +1,5 @@ -import * as React from "react"; -import Svg, { Circle, Path } from "react-native-svg"; +import * as React from 'react' +import Svg, { Circle, Path } from 'react-native-svg' function EyeSVG(props) { return ( @@ -19,7 +19,7 @@ function EyeSVG(props) { - ); + ) } -export default EyeSVG; +export default EyeSVG diff --git a/src/assets/UI/EyeSlashSVG.js b/src/assets/UI/EyeSlashSVG.js index bf5cc87..4de59df 100644 --- a/src/assets/UI/EyeSlashSVG.js +++ b/src/assets/UI/EyeSlashSVG.js @@ -1,5 +1,5 @@ -import * as React from "react"; -import Svg, { Path } from "react-native-svg"; +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' function EyeSlashSVG(props) { return ( @@ -21,7 +21,7 @@ function EyeSlashSVG(props) { - ); + ) } -export default EyeSlashSVG; +export default EyeSlashSVG diff --git a/src/assets/UI/RestartSpinnerIcon.js b/src/assets/UI/RestartSpinnerIcon.js index a84af69..9ceb33f 100644 --- a/src/assets/UI/RestartSpinnerIcon.js +++ b/src/assets/UI/RestartSpinnerIcon.js @@ -1,11 +1,11 @@ -import * as React from "react"; -import { View } from "react-native"; -import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg"; +import * as React from 'react' +import { View } from 'react-native' +import Svg, { ClipPath, Defs, G, Path, Rect } from 'react-native-svg' const RestartSpinnerIcon = ({ size = 30, - color = "#F94697", - background = "transparent", + color = '#F94697', + background = 'transparent', style, }) => { return ( @@ -15,8 +15,8 @@ const RestartSpinnerIcon = ({ width: size + 8, height: size + 8, borderRadius: size, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', backgroundColor: background, }, style, @@ -55,7 +55,7 @@ const RestartSpinnerIcon = ({ - ); -}; + ) +} -export default RestartSpinnerIcon; +export default RestartSpinnerIcon diff --git a/src/assets/index.js b/src/assets/index.js index 2ae8bbc..c033ab5 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -1,110 +1,110 @@ -import addTab from "./UI/tabs/add.png"; -import albums from "./UI/tabs/albums.png"; -import chat from "./UI/tabs/chat.png"; -import design from "./UI/tabs/design.png"; -import home from "./UI/tabs/home.png"; -import mic from "./UI/tabs/mic.png"; -import person from "./UI/tabs/person.png"; -import quotes from "./UI/tabs/quotes.png"; -import ribbon from "./UI/tabs/ribbon.png"; -import settings from "./UI/tabs/settings.png"; -import tasks from "./UI/tabs/tasks.png"; +import addTab from './UI/tabs/add.png' +import albums from './UI/tabs/albums.png' +import chat from './UI/tabs/chat.png' +import design from './UI/tabs/design.png' +import home from './UI/tabs/home.png' +import mic from './UI/tabs/mic.png' +import person from './UI/tabs/person.png' +import quotes from './UI/tabs/quotes.png' +import ribbon from './UI/tabs/ribbon.png' +import settings from './UI/tabs/settings.png' +import tasks from './UI/tabs/tasks.png' -import bell from "./UI/bell.png"; -import sort from "./UI/sort.png"; +import bell from './UI/bell.png' +import sort from './UI/sort.png' -import arrowRight from "./UI/arrowRight.png"; -import chevronDown from "./UI/chevronDown.png"; -import threeDots from "./UI/threeDots.png"; +import arrowRight from './UI/arrowRight.png' +import chevronDown from './UI/chevronDown.png' +import threeDots from './UI/threeDots.png' -import thumbDown from "./UI/thumbDown.png"; -import thumbUp from "./UI/thumbUp.png"; +import thumbDown from './UI/thumbDown.png' +import thumbUp from './UI/thumbUp.png' -import add from "./UI/add.png"; -import addFile from "./UI/addFile.png"; -import check from "./UI/check.png"; -import checkCircle from "./UI/checkCircle.png"; -import deliveryTime from "./UI/deliveryTime.png"; -import edit from "./UI/edit.png"; -import eye from "./UI/eye.png"; -import lock from "./UI/lock.png"; -import message from "./UI/message.png"; -import search from "./UI/search.png"; -import send from "./UI/send.png"; -import shoppingBag from "./UI/shoppingBag.png"; -import tools from "./UI/tools.png"; -import trash from "./UI/trash.png"; -import undo from "./UI/undo.png"; +import add from './UI/add.png' +import addFile from './UI/addFile.png' +import check from './UI/check.png' +import checkCircle from './UI/checkCircle.png' +import deliveryTime from './UI/deliveryTime.png' +import edit from './UI/edit.png' +import eye from './UI/eye.png' +import lock from './UI/lock.png' +import message from './UI/message.png' +import search from './UI/search.png' +import send from './UI/send.png' +import shoppingBag from './UI/shoppingBag.png' +import tools from './UI/tools.png' +import trash from './UI/trash.png' +import undo from './UI/undo.png' -import focus from "./UI/focus.png"; -import picture from "./UI/picture.png"; -import screenshot from "./UI/screenshot.png"; +import focus from './UI/focus.png' +import picture from './UI/picture.png' +import screenshot from './UI/screenshot.png' -import android from "./UI/android.png"; -import ios from "./UI/ios.png"; -import web from "./UI/web.png"; +import android from './UI/android.png' +import ios from './UI/ios.png' +import web from './UI/web.png' -import law from "./UI/law.png"; -import support from "./UI/support.png"; -import user from "./UI/user.png"; +import law from './UI/law.png' +import support from './UI/support.png' +import user from './UI/user.png' -import attachment from "./UI/attachment.png"; +import attachment from './UI/attachment.png' -import cloud from "./icons/cloud.png"; -import dashboard from "./icons/dashboard.png"; -import file from "./icons/file.png"; +import cloud from './icons/cloud.png' +import dashboard from './icons/dashboard.png' +import file from './icons/file.png' -import algolia from "./icons/algolia.png"; -import calendar from "./icons/calendar.png"; -import chatBubble from "./icons/chatBubble.png"; -import close from "./icons/close.png"; -import coin from "./icons/coin.png"; -import disk from "./icons/disk.png"; -import figma from "./icons/figma.png"; -import forward from "./icons/forward.png"; -import gitlab from "./icons/gitlab.png"; -import heart from "./icons/heart.png"; -import heartOutline from "./icons/heartOutline.png"; -import hitParadeLogo from "./icons/hitParadeLogo.png"; -import more from "./icons/more.png"; -import musicLandAccueil from "./icons/musicLandAccueil.png"; -import musicLandLogo from "./icons/musicLandLogo.png"; -import musicLandProduction from "./icons/musicLandProduction.png"; -import musicLandStudio from "./icons/musicLandStudio.png"; -import musicLandVideo from "./icons/musicLandVideo.png"; -import musicLandWriting from "./icons/musicLandWriting.png"; -import pause from "./icons/pause.png"; -import play from "./icons/play.png"; -import share from "./icons/share.png"; -import stars from "./icons/stars.png"; +import algolia from './icons/algolia.png' +import calendar from './icons/calendar.png' +import chatBubble from './icons/chatBubble.png' +import close from './icons/close.png' +import coin from './icons/coin.png' +import disk from './icons/disk.png' +import figma from './icons/figma.png' +import forward from './icons/forward.png' +import gitlab from './icons/gitlab.png' +import heart from './icons/heart.png' +import heartOutline from './icons/heartOutline.png' +import hitParadeLogo from './icons/hitParadeLogo.png' +import more from './icons/more.png' +import musicLandAccueil from './icons/musicLandAccueil.png' +import musicLandLogo from './icons/musicLandLogo.png' +import musicLandProduction from './icons/musicLandProduction.png' +import musicLandStudio from './icons/musicLandStudio.png' +import musicLandVideo from './icons/musicLandVideo.png' +import musicLandWriting from './icons/musicLandWriting.png' +import pause from './icons/pause.png' +import play from './icons/play.png' +import share from './icons/share.png' +import stars from './icons/stars.png' -import hitParadeBG from "./UI/hitParadeBG.png"; -import homeBG from "./UI/homeBG.png"; -import libraryBG from "./UI/libraryBG.png"; -import libraryBG2 from "./UI/libraryBG2.png"; -import playbackBG from "./UI/playbackBG.png"; -import playbackBG2 from "./UI/playbackBG2.png"; -import productionBG from "./UI/productionBG.png"; -import productionBG2 from "./UI/productionBG2.png"; -import profileBG from "./UI/profileBG.png"; -import studioBG from "./UI/studioBG.png"; -import studioBG2 from "./UI/studioBG2.png"; -import writingBG from "./UI/writingBG.png"; +import hitParadeBG from './UI/hitParadeBG.png' +import homeBG from './UI/homeBG.png' +import libraryBG from './UI/libraryBG.png' +import libraryBG2 from './UI/libraryBG2.png' +import playbackBG from './UI/playbackBG.png' +import playbackBG2 from './UI/playbackBG2.png' +import productionBG from './UI/productionBG.png' +import productionBG2 from './UI/productionBG2.png' +import profileBG from './UI/profileBG.png' +import studioBG from './UI/studioBG.png' +import studioBG2 from './UI/studioBG2.png' +import writingBG from './UI/writingBG.png' -import bena from "./UI/bena.png"; -import john from "./UI/john.png"; -import malik from "./UI/malik.png"; -import nathalie from "./UI/nathalie.png"; -import theo from "./UI/theo.png"; +import bena from './UI/bena.png' +import john from './UI/john.png' +import malik from './UI/malik.png' +import nathalie from './UI/nathalie.png' +import theo from './UI/theo.png' -import { Platform } from "react-native"; -import goodVibe from "./UI/goodVibe.png"; -import placeholder from "./UI/placeholder.jpg"; -import placeholder2 from "./UI/placeholder2.jpg"; -import placeholder3 from "./UI/placeholder3.png"; -import placeholder4 from "./UI/placeholder4.jpg"; -import profile from "./UI/profile.jpg"; -import musiclandClub from "./UI/musiclandClub.png"; +import { Platform } from 'react-native' +import goodVibe from './UI/goodVibe.png' +import placeholder from './UI/placeholder.jpg' +import placeholder2 from './UI/placeholder2.jpg' +import placeholder3 from './UI/placeholder3.png' +import placeholder4 from './UI/placeholder4.jpg' +import profile from './UI/profile.jpg' +import musiclandClub from './UI/musiclandClub.png' export const tabs = { home, tasks, @@ -117,12 +117,12 @@ export const tabs = { ribbon, mic, person, -}; +} export const icons = { bell, sort, - dragDots: require("./icons/dragDots.png"), + dragDots: require('./icons/dragDots.png'), chevronDown, arrowRight, @@ -186,13 +186,13 @@ export const icons = { hitParadeLogo, calendar, coin, - club: require("./icons/club.png"), - clubIcon: require("./icons/clubIcon.png"), -}; + club: require('./icons/club.png'), + clubIcon: require('./icons/clubIcon.png'), +} export const background = { writingBG, - writingBgWeb: require("./UI/writingBgWeb.png"), + writingBgWeb: require('./UI/writingBgWeb.png'), studioBG, studioBG2, productionBG, @@ -200,21 +200,21 @@ export const background = { playbackBG, playbackBG2, libraryBG, - libraryBgWeb: require("./UI/libraryBgWeb.png"), + libraryBgWeb: require('./UI/libraryBgWeb.png'), libraryBG2, - libraryBG2Web: require("./UI/libraryBG2Web.png"), + libraryBG2Web: require('./UI/libraryBG2Web.png'), profileBG, - profileBgWeb: require("./UI/profileBgWeb.png"), + profileBgWeb: require('./UI/profileBgWeb.png'), hitParadeBG, - hitParadeBG2: require("./UI/hitparadeBG2.jpg"), - playbackWeb: require("./UI/playbackWeb.png"), - playbackMobile: require("./UI/playbackMobile.png"), + hitParadeBG2: require('./UI/hitparadeBG2.jpg'), + playbackWeb: require('./UI/playbackWeb.png'), + playbackMobile: require('./UI/playbackMobile.png'), homeBG, - homeBGWeb: require("./UI/homeBGWeb.png"), - loginBgWeb: require("./UI/loginBgWeb.png"), - profileWebBG: require("./UI/profileWebBG.jpg"), - bgTrans: require("./UI/bgTrans.png"), -}; + homeBGWeb: require('./UI/homeBGWeb.png'), + loginBgWeb: require('./UI/loginBgWeb.png'), + profileWebBG: require('./UI/profileWebBG.jpg'), + bgTrans: require('./UI/bgTrans.png'), +} export const ai = { nathalie, @@ -222,15 +222,13 @@ export const ai = { malik, bena, john, -}; +} export const videos = { test: - Platform.OS === "web" - ? require("./video/testVideoWeb.mp4") - : require("./video/testVideo.mp4"), - club: require("./video/club.mp4"), -}; + Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'), + club: require('./video/club.mp4'), +} export const img = { placeholder, @@ -240,17 +238,17 @@ export const img = { profile, goodVibe, musiclandClub, -}; +} export const cardsImg = { - writing: require("./icons/writing.png"), - studio: require("./icons/studio.png"), - video: require("./icons/video.png"), - production: require("./icons/production.png"), -}; + writing: require('./icons/writing.png'), + studio: require('./icons/studio.png'), + video: require('./icons/video.png'), + production: require('./icons/production.png'), +} export const subBadges = { - starter: require("./icons/starterBadge.png"), - pro: require("./icons/proBadge.png"), - premium: require("./icons/premiumBadge.png"), -}; + starter: require('./icons/starterBadge.png'), + pro: require('./icons/proBadge.png'), + premium: require('./icons/premiumBadge.png'), +} diff --git a/src/common/constants.js b/src/common/constants.js index 443da01..a619c4c 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -1,9 +1,8 @@ -import {Dimensions} from 'react-native'; -import {resWidth} from '../styles'; +import { Dimensions } from 'react-native' +import { resWidth } from '../styles' -const {width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT} = - Dimensions.get('screen'); +const { width: DIMENSION_WIDTH, height: DIMENSION_HEIGHT } = Dimensions.get('screen') -const BOTTOM_BAR_HEIGHT = resWidth(80); +const BOTTOM_BAR_HEIGHT = resWidth(80) -export {DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT}; +export { DIMENSION_WIDTH, DIMENSION_HEIGHT, BOTTOM_BAR_HEIGHT } diff --git a/src/common/index.js b/src/common/index.js index c94f80f..f87cf01 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -1 +1 @@ -export * from './constants'; +export * from './constants' diff --git a/src/components/ActivityLoader.js b/src/components/ActivityLoader.js index f8a283c..ddb4ae7 100644 --- a/src/components/ActivityLoader.js +++ b/src/components/ActivityLoader.js @@ -1,10 +1,10 @@ -import { View } from "react-native"; +import { View } from 'react-native' -import { LoaderIndicator } from "../providers/LoadingProvider"; +import { LoaderIndicator } from '../providers/LoadingProvider' export default ({ message, - defaultMessage = "Chargement...", + defaultMessage = 'Chargement...', containerStyle = {}, indicatorProps = {}, messageStyle = {}, @@ -13,8 +13,8 @@ export default ({ - ); -}; + ) +} diff --git a/src/components/Alert.js b/src/components/Alert.js index 237eb2b..141a3da 100644 --- a/src/components/Alert.js +++ b/src/components/Alert.js @@ -1,56 +1,48 @@ -import { PortalProvider } from "@gorhom/portal"; -import React, { useState } from "react"; -import { Alert, Platform, StyleSheet, Text, View } from "react-native"; +import { PortalProvider } from '@gorhom/portal' +import React, { useState } from 'react' +import { Alert, Platform, StyleSheet, Text, View } from 'react-native' -import { BlurView } from "expo-blur"; -import { Fonts, Palette, gutters } from "../styles"; -import GradientButton from "./GradientButton"; -import { LinearGradient } from "./LinearGradient/LinearGradient"; -import Overlay from "./Overlay"; +import { BlurView } from 'expo-blur' +import { Fonts, Palette, gutters } from '../styles' +import GradientButton from './GradientButton' +import { LinearGradient } from './LinearGradient/LinearGradient' +import Overlay from './Overlay' const WebAlertModal = ({ title, description, options }) => { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(true) - const confirmOption = options?.find(({ style }) => style !== "cancel"); - const cancelOption = options?.find(({ style }) => style === "cancel"); - const hasSecondaryAction = Boolean(cancelOption); - const buttonContainerStyle = hasSecondaryAction - ? styles.actionButton - : styles.singleActionButton; + const confirmOption = options?.find(({ style }) => style !== 'cancel') + const cancelOption = options?.find(({ style }) => style === 'cancel') + const hasSecondaryAction = Boolean(cancelOption) + const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton const onConfirm = () => { - setVisible(false); - confirmOption?.onPress(); - }; + setVisible(false) + confirmOption?.onPress() + } const onCancel = () => { - setVisible(false); - cancelOption?.onPress(); - }; + setVisible(false) + cancelOption?.onPress() + } const renderDescription = () => { - if ( - typeof description === "string" || - typeof description === "number" - ) { - return {description}; + if (typeof description === 'string' || typeof description === 'number') { + return {description} } if (!description) { - return null; + return null } - return {description}; - }; + return {description} + } return ( - + { {title} {renderDescription()} - + {cancelOption && ( )} @@ -86,16 +73,16 @@ const WebAlertModal = ({ title, description, options }) => { - ); -}; + ) +} const alertPolyfill = (title, description, options, extra) => { - const rootDiv = document.createElement("div"); - document.body.appendChild(rootDiv); + const rootDiv = document.createElement('div') + document.body.appendChild(rootDiv) const closeModal = () => { - document.body.removeChild(rootDiv); - }; + document.body.removeChild(rootDiv) + } const WebAlertComponent = () => ( { options={options} onDismiss={closeModal} /> - ); + ) // Render the React component into the div - require("react-dom").render(, rootDiv); -}; + require('react-dom').render(, rootDiv) +} const customAlert = (title, description, options, extra) => { // Utilisation de la propriété userInterfaceStyle pour le mettre en mode sombre Alert.alert(title, description, options, { ...extra, - userInterfaceStyle: "dark", - }); -}; + userInterfaceStyle: 'dark', + }) +} -const alert = Platform.OS === "web" ? alertPolyfill : customAlert; +const alert = Platform.OS === 'web' ? alertPolyfill : customAlert export const showPremiumRequiredAlert = () => alert( - "Accès premium requis", - "Vous devez payer pour créer une nouvelle musique." + 'Accès premium requis', + 'Vous devez payer pour créer une nouvelle musique.' // [{ text: "OK", style: "cancel" }] - ); + ) -export default alert; +export default alert const styles = StyleSheet.create({ overlayContent: { - justifyContent: "center", - alignItems: "center", + justifyContent: 'center', + alignItems: 'center', }, modalWrapper: { - width: "90%", + width: '90%', maxWidth: 460, paddingHorizontal: 12, }, @@ -147,49 +134,49 @@ const styles = StyleSheet.create({ borderRadius: 23, paddingHorizontal: gutters * 1.8, paddingVertical: gutters, - backgroundColor: "rgba(15, 12, 20, 0.9)", - overflow: "hidden", + backgroundColor: 'rgba(15, 12, 20, 0.9)', + overflow: 'hidden', gap: gutters * 0.75, }, title: Fonts({ - type: "mainTitle", + type: 'mainTitle', fontSize: 3, - style: { textAlign: "center" }, + style: { textAlign: 'center' }, }), description: Fonts({ - type: "default", + type: 'default', color: Palette.gray, fontSize: 2, - style: { lineHeight: 22, textAlign: "center" }, + style: { lineHeight: 22, textAlign: 'center' }, }), divider: { height: 1, - backgroundColor: "rgba(255,255,255,0.08)", + backgroundColor: 'rgba(255,255,255,0.08)', marginVertical: 0, }, customDescription: { - width: "100%", + width: '100%', }, actionsRow: { - flexDirection: "row", + flexDirection: 'row', gap: gutters, - width: "100%", + width: '100%', }, actions: { - width: "100%", + width: '100%', gap: gutters, }, actionButton: { flex: 1, }, singleActionButton: { - width: "100%", + width: '100%', }, secondaryButtonGradient: { borderWidth: 1, - borderColor: "rgba(255,255,255,0.2)", + borderColor: 'rgba(255,255,255,0.2)', }, secondaryButtonText: { color: Palette.gray, }, -}); +}) diff --git a/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js b/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js index fcf1a7e..8e72309 100644 --- a/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js +++ b/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js @@ -1,11 +1,11 @@ -import React, { useMemo } from "react"; -import { Animated, StyleSheet, useWindowDimensions, View } from "react-native"; -import { Palette } from "../../styles"; +import React, { useMemo } from 'react' +import { Animated, StyleSheet, useWindowDimensions, View } from 'react-native' +import { Palette } from '../../styles' const ORIENTATION = { - HORIZONTAL: "horizontal", - VERTICAL: "vertical", -}; + HORIZONTAL: 'horizontal', + VERTICAL: 'vertical', +} const AnimatedPaginationDot = ({ data = [], @@ -16,44 +16,32 @@ const AnimatedPaginationDot = ({ orientation = ORIENTATION.HORIZONTAL, expandingDotSize = 20, inactiveDotOpacity = 0.4, - inactiveDotColor = "rgba(255,255,255,0.4)", + inactiveDotColor = 'rgba(255,255,255,0.4)', activeDotColor = Palette.white, baseDotSize = 10, }) => { - const animatedValue = useMemo( - () => scrollValue || new Animated.Value(0), - [scrollValue] - ); + const animatedValue = useMemo(() => scrollValue || new Animated.Value(0), [scrollValue]) - const { width, height } = useWindowDimensions(); + const { width, height } = useWindowDimensions() const distance = useMemo(() => { - if (typeof itemDimension === "number" && itemDimension > 0) { - return itemDimension; + if (typeof itemDimension === 'number' && itemDimension > 0) { + return itemDimension } if (orientation === ORIENTATION.VERTICAL) { - return Math.max(height, 1); + return Math.max(height, 1) } - return Math.max(width, 1); - }, [height, itemDimension, orientation, width]); + return Math.max(width, 1) + }, [height, itemDimension, orientation, width]) - const resolvedDotStyle = useMemo( - () => StyleSheet.flatten(dotStyle) || {}, - [dotStyle] - ); - const defaultSize = Math.max(baseDotSize, 1); + const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle]) + const defaultSize = Math.max(baseDotSize, 1) const baseWidth = - typeof resolvedDotStyle?.width === "number" - ? resolvedDotStyle.width - : defaultSize; + typeof resolvedDotStyle?.width === 'number' ? resolvedDotStyle.width : defaultSize const baseHeight = - typeof resolvedDotStyle?.height === "number" - ? resolvedDotStyle.height - : defaultSize; + typeof resolvedDotStyle?.height === 'number' ? resolvedDotStyle.height : defaultSize const containerOrientationStyle = - orientation === ORIENTATION.VERTICAL - ? styles.containerVertical - : styles.containerHorizontal; + orientation === ORIENTATION.VERTICAL ? styles.containerVertical : styles.containerHorizontal return ( {data.map((_, index) => { - const inputRange = [ - (index - 1) * distance, - index * distance, - (index + 1) * distance, - ]; + const inputRange = [(index - 1) * distance, index * distance, (index + 1) * distance] const staticSizeStyle = { width: baseWidth, height: baseHeight, - }; + } const color = animatedValue.interpolate({ inputRange, outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor], - extrapolate: "clamp", - }); + extrapolate: 'clamp', + }) const opacity = animatedValue.interpolate({ inputRange, outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity], - extrapolate: "clamp", - }); + extrapolate: 'clamp', + }) const primarySize = animatedValue.interpolate({ inputRange, @@ -91,13 +75,11 @@ const AnimatedPaginationDot = ({ expandingDotSize, orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth, ], - extrapolate: "clamp", - }); + extrapolate: 'clamp', + }) const animatedSizeStyle = - orientation === ORIENTATION.VERTICAL - ? { height: primarySize } - : { width: primarySize }; + orientation === ORIENTATION.VERTICAL ? { height: primarySize } : { width: primarySize } return ( - ); + ) })} - ); -}; + ) +} -AnimatedPaginationDot.orientation = ORIENTATION; +AnimatedPaginationDot.orientation = ORIENTATION -export default AnimatedPaginationDot; +export default AnimatedPaginationDot const styles = StyleSheet.create({ containerBase: { - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, containerHorizontal: { - flexDirection: "row", + flexDirection: 'row', }, containerVertical: { - flexDirection: "column", + flexDirection: 'column', }, dotBase: { borderRadius: 999, marginHorizontal: 4, marginVertical: 4, }, -}); +}) diff --git a/src/components/AppActionSheet.js b/src/components/AppActionSheet.js index 52b00d0..9638439 100644 --- a/src/components/AppActionSheet.js +++ b/src/components/AppActionSheet.js @@ -1,31 +1,25 @@ -import { Portal } from "@gorhom/portal"; -import { BlurView } from "expo-blur"; -import { useCallback } from "react"; -import { Platform, Pressable, StyleSheet, View } from "react-native"; -import ActionSheet, { SheetManager } from "react-native-actions-sheet"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { isWeb } from "../hooks/useLayoutType"; -import { gutters, Palette } from "../styles"; +import { Portal } from '@gorhom/portal' +import { BlurView } from 'expo-blur' +import { useCallback } from 'react' +import { Platform, Pressable, StyleSheet, View } from 'react-native' +import ActionSheet, { SheetManager } from 'react-native-actions-sheet' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { isWeb } from '../hooks/useLayoutType' +import { gutters, Palette } from '../styles' -const AppActionSheet = ({ - id, - children, - webModal = false, - onClose = () => {}, - ...sheetProps -}) => { - const insets = useSafeAreaInsets(); +const AppActionSheet = ({ id, children, webModal = false, onClose = () => {}, ...sheetProps }) => { + const insets = useSafeAreaInsets() const handleRequestClose = useCallback(() => { if (id) { Promise.resolve(SheetManager.hide(id)) .catch(() => {}) .finally(() => { - onClose?.(); - }); - return; + onClose?.() + }) + return } - onClose?.(); - }, [id, onClose]); + onClose?.() + }, [id, onClose]) if (isWeb && webModal) { return ( @@ -43,7 +37,7 @@ const AppActionSheet = ({ - ); + ) } return ( @@ -62,7 +56,7 @@ const AppActionSheet = ({ {...sheetProps} > - ); -}; + ) +} -export default AppActionSheet; +export default AppActionSheet const styles = StyleSheet.create({ webOverlay: { ...StyleSheet.absoluteFillObject, - position: "fixed", + position: 'fixed', zIndex: 100, }, webBackdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0, 0, 0, 0.55)", + backgroundColor: 'rgba(0, 0, 0, 0.55)', }, webModalWrapper: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', padding: 24, }, webModalCard: { width: 480, - maxWidth: "90%", + maxWidth: '90%', borderRadius: 28, - overflow: "hidden", + overflow: 'hidden', backgroundColor: Palette.glass, padding: 32, }, -}); +}) diff --git a/src/components/AppCheckbox.js b/src/components/AppCheckbox.js index 856aadd..657121d 100644 --- a/src/components/AppCheckbox.js +++ b/src/components/AppCheckbox.js @@ -1,8 +1,8 @@ -import { View, Text, Pressable } from "react-native"; -import React from "react"; -import { Palette, Style } from "../styles"; -import { size } from "../styles/Style"; -import { FONT_FAMILY } from "../styles/Fonts"; +import { View, Text, Pressable } from 'react-native' +import React from 'react' +import { Palette, Style } from '../styles' +import { size } from '../styles/Style' +import { FONT_FAMILY } from '../styles/Fonts' const AppCheckbox = ({ onPress, selected, label }) => { return ( @@ -42,7 +42,7 @@ const AppCheckbox = ({ onPress, selected, label }) => { {label} - ); -}; + ) +} -export default AppCheckbox; +export default AppCheckbox diff --git a/src/components/AppDownloadBanner.js b/src/components/AppDownloadBanner.js index 08abfb6..af8221c 100644 --- a/src/components/AppDownloadBanner.js +++ b/src/components/AppDownloadBanner.js @@ -1,93 +1,85 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import React, { useCallback, useEffect, useState } from "react"; -import { - Image, - Linking, - Platform, - StyleSheet, - Text, - TouchableOpacity, - View, -} from "react-native"; +import AsyncStorage from '@react-native-async-storage/async-storage' +import React, { useCallback, useEffect, useState } from 'react' +import { Image, Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native' -import { icons } from "../assets"; -import { appleAppStoreUrl, googlePlayStoreUrl } from "../data"; -import useLayoutType from "../hooks/useLayoutType"; -import { Palette, gutters } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; +import { icons } from '../assets' +import { appleAppStoreUrl, googlePlayStoreUrl } from '../data' +import useLayoutType from '../hooks/useLayoutType' +import { Palette, gutters } from '../styles' +import { FONT_FAMILY } from '../styles/Fonts' -const STORAGE_KEY = "appDownloadBanner:dismissed"; +const STORAGE_KEY = 'appDownloadBanner:dismissed' const AppDownloadBanner = () => { - const { isMobileWeb } = useLayoutType(); - const [isVisible, setIsVisible] = useState(false); - const [hasHydrated, setHasHydrated] = useState(false); + const { isMobileWeb } = useLayoutType() + const [isVisible, setIsVisible] = useState(false) + const [hasHydrated, setHasHydrated] = useState(false) useEffect(() => { - let mounted = true; + let mounted = true if (!isMobileWeb) { - setIsVisible(false); - setHasHydrated(false); - return undefined; + setIsVisible(false) + setHasHydrated(false) + return undefined } AsyncStorage.getItem(STORAGE_KEY) .then((value) => { - if (!mounted) return; - setIsVisible(value !== "hidden"); - setHasHydrated(true); + if (!mounted) return + setIsVisible(value !== 'hidden') + setHasHydrated(true) }) .catch(() => { - if (!mounted) return; - setIsVisible(true); - setHasHydrated(true); - }); + if (!mounted) return + setIsVisible(true) + setHasHydrated(true) + }) return () => { - mounted = false; - }; - }, [isMobileWeb]); + mounted = false + } + }, [isMobileWeb]) const handleDismiss = useCallback(() => { - setIsVisible(false); - AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {}); - }, []); + setIsVisible(false) + AsyncStorage.setItem(STORAGE_KEY, 'hidden').catch(() => {}) + }, []) const openLink = useCallback((url) => { - if (typeof url !== "string") return; - const target = url.trim(); - if (!target) return; + if (typeof url !== 'string') return + const target = url.trim() + if (!target) return - if (Platform.OS === "web") { + if (Platform.OS === 'web') { try { - window.open(target, "_blank", "noopener,noreferrer"); - return; + window.open(target, '_blank', 'noopener,noreferrer') + return } catch (error) { - console.warn("AppDownloadBanner: failed to open link in new tab", error); + console.warn('AppDownloadBanner: failed to open link in new tab', error) } } Linking.openURL(target).catch((error) => { - console.warn("AppDownloadBanner: failed to open store link", error); - }); - }, []); + console.warn('AppDownloadBanner: failed to open store link', error) + }) + }, []) const handleOpenStore = useCallback( (store) => { - if (store === "ios") { - openLink(appleAppStoreUrl); - return; + if (store === 'ios') { + openLink(appleAppStoreUrl) + return } - if (store === "android") { - openLink(googlePlayStoreUrl); + if (store === 'android') { + openLink(googlePlayStoreUrl) } }, - [openLink], - ); + [openLink] + ) if (!isMobileWeb || !isVisible || !hasHydrated) { - return null; + return null } return ( @@ -101,8 +93,8 @@ const AppDownloadBanner = () => { Télécharge l'app MusicLand - Pour une expérience mobile plus fluide, utilise l'application - native et retrouve toutes les fonctionnalités. + Pour une expérience mobile plus fluide, utilise l'application native et retrouve + toutes les fonctionnalités. @@ -119,38 +111,38 @@ const AppDownloadBanner = () => { handleOpenStore("ios")} + onPress={() => handleOpenStore('ios')} > App Store handleOpenStore("android")} + onPress={() => handleOpenStore('android')} > Google Play - ); -}; + ) +} const styles = StyleSheet.create({ container: { - position: "fixed", + position: 'fixed', bottom: gutters, left: gutters, right: gutters, - alignItems: "center", + alignItems: 'center', zIndex: 80, }, banner: { - width: "100%", + width: '100%', maxWidth: 520, borderRadius: 18, paddingVertical: 14, paddingHorizontal: 16, - backgroundColor: "rgba(12, 10, 16, 0.95)", + backgroundColor: 'rgba(12, 10, 16, 0.95)', borderWidth: 1, borderColor: Palette.ultraLightWhite, shadowColor: Palette.black, @@ -160,29 +152,29 @@ const styles = StyleSheet.create({ elevation: 10, }, headerRow: { - flexDirection: "row", - alignItems: "flex-start", + flexDirection: 'row', + alignItems: 'flex-start', marginBottom: 12, }, titleRow: { flex: 1, - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', }, logoWrapper: { width: 50, height: 50, borderRadius: 12, - backgroundColor: "rgba(255, 255, 255, 0.06)", - alignItems: "center", - justifyContent: "center", + backgroundColor: 'rgba(255, 255, 255, 0.06)', + alignItems: 'center', + justifyContent: 'center', borderWidth: 1, borderColor: Palette.ultraLightWhite, }, logo: { - width: "80%", - height: "80%", - resizeMode: "contain", + width: '80%', + height: '80%', + resizeMode: 'contain', }, titleContent: { flex: 1, @@ -210,16 +202,16 @@ const styles = StyleSheet.create({ fontFamily: FONT_FAMILY.InterSemiBold, }, actions: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', }, cta: { flex: 1, height: 46, borderRadius: 12, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', borderWidth: 1, borderColor: Palette.ultraLightWhite, }, @@ -237,6 +229,6 @@ const styles = StyleSheet.create({ color: Palette.white, fontFamily: FONT_FAMILY.InterSemiBold, }, -}); +}) -export default AppDownloadBanner; +export default AppDownloadBanner diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 8013afa..0f3d5d0 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -1,31 +1,31 @@ -import { Text, View } from "react-native"; -import { Image } from "expo-image"; -import { responsiveWidth } from "../actions/responsiveSizes.js"; -import { formatImageURL, getInitials } from "../helpers"; -import { Fonts, Palette, Style } from "../styles"; -import Badge from "./Badge.js"; +import { Text, View } from 'react-native' +import { Image } from 'expo-image' +import { responsiveWidth } from '../actions/responsiveSizes.js' +import { formatImageURL, getInitials } from '../helpers' +import { Fonts, Palette, Style } from '../styles' +import Badge from './Badge.js' export default ({ - name = "", + name = '', url = null, size = responsiveWidth(7), containerStyle = {}, forceRawImage = false, badge = {}, }) => { - const uniqColorById = (uniqId = "test") => { + const uniqColorById = (uniqId = 'test') => { // Calculer un nombre unique à partir de l'ID de l'employé - let uniqueNumber = 0; + let uniqueNumber = 0 for (let i = 0; i < uniqId?.length; i++) { - uniqueNumber += uniqId.charCodeAt(i); + uniqueNumber += uniqId.charCodeAt(i) } // génère des couleurs pastels claires - const color = `hsl(${uniqueNumber % 360}, 100%, 90%)`; + const color = `hsl(${uniqueNumber % 360}, 100%, 90%)` - return color; - }; + return color + } return ( {url ? ( - ); -}; + ) +} diff --git a/src/components/Avatars/SubscriptionAvatar.js b/src/components/Avatars/SubscriptionAvatar.js new file mode 100644 index 0000000..fc51a80 --- /dev/null +++ b/src/components/Avatars/SubscriptionAvatar.js @@ -0,0 +1,16 @@ +import { Image, View } from 'react-native' +import { subBadges } from '../../assets' +export default function SubscriptionAvatar({ user }) { + const subscriptionLevel = user.premiumLevel + + switch (subscriptionLevel) { + case 'starter': + return + case 'pro': + return + case 'premium': + return + default: + return null + } +} diff --git a/src/components/Badge.js b/src/components/Badge.js index d8f9062..8fc3030 100644 --- a/src/components/Badge.js +++ b/src/components/Badge.js @@ -1,24 +1,19 @@ -import { Motion } from "@legendapp/motion"; +import { Motion } from '@legendapp/motion' -import { Fonts, Palette, Style } from "../styles"; +import { Fonts, Palette, Style } from '../styles' -const Badge = ({ - count = 0, - size = 20, - customContent = null, - backgroundColor = null, -} = {}) => { +const Badge = ({ count = 0, size = 20, customContent = null, backgroundColor = null } = {}) => { if (!count && !customContent) { - return null; + return null } return ( )} - ); -}; + ) +} -export default Badge; +export default Badge diff --git a/src/components/BaseHeader.js b/src/components/BaseHeader.js index 809d19e..1336d15 100644 --- a/src/components/BaseHeader.js +++ b/src/components/BaseHeader.js @@ -1,40 +1,33 @@ -import { Image, Pressable, Text, View } from "react-native"; -import React from "reactn"; -import { responsiveWidth } from "../actions/responsiveSizes.js"; +import { Image, Pressable, Text, View } from 'react-native' +import React from 'reactn' +import { responsiveWidth } from '../actions/responsiveSizes.js' -import { icons } from "../assets"; -import { Fonts, Style, gutters } from "../styles"; +import { icons } from '../assets' +import { Fonts, Style, gutters } from '../styles' -import { Routes } from "../navigation"; -import { navigate } from "../navigation/NavigationService"; +import { Routes } from '../navigation' +import { navigate } from '../navigation/NavigationService' -import useLayoutType, { sidebarWidth } from "../hooks/useLayoutType.js"; -import useNotifications from "../hooks/useNotifications"; +import useLayoutType, { sidebarWidth } from '../hooks/useLayoutType.js' +import useNotifications from '../hooks/useNotifications' -export default ({ title = "", containerStyle = {}, bell = false }) => { - const { isDesktop } = useLayoutType(); - const notificationsContext = useNotifications(); - const unreadCount = notificationsContext?.unreadCount || 0; - const hasUnread = unreadCount > 0; +export default ({ title = '', containerStyle = {}, bell = false }) => { + const { isDesktop } = useLayoutType() + const notificationsContext = useNotifications() + const unreadCount = notificationsContext?.unreadCount || 0 + const hasUnread = unreadCount > 0 return ( - + { - ); -}; + ) +} diff --git a/src/components/BlurItemButton.js b/src/components/BlurItemButton.js index 19595ed..ebea99a 100644 --- a/src/components/BlurItemButton.js +++ b/src/components/BlurItemButton.js @@ -1,23 +1,16 @@ -import { - View, - Text, - Pressable, - StyleSheet, - Image, - Platform, -} from "react-native"; -import React from "react"; -import { BlurView } from "expo-blur"; -import { Palette, Style } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; -import { icons } from "../assets"; -import { size } from "../styles/Style"; +import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native' +import React from 'react' +import { BlurView } from 'expo-blur' +import { Palette, Style } from '../styles' +import { FONT_FAMILY } from '../styles/Fonts' +import { icons } from '../assets' +import { size } from '../styles/Style' -const BlurItemButton = ({ onPress, title = "" }) => { +const BlurItemButton = ({ onPress, title = '' }) => { return ( { source={icons.chevronDown} style={{ ...size({ size: 15 }), - transform: [{ rotate: "-90deg" }], + transform: [{ rotate: '-90deg' }], }} resizeMode="contain" /> - ); -}; + ) +} -export default BlurItemButton; +export default BlurItemButton const styles = StyleSheet.create({ buttonContainer: { @@ -45,7 +38,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, height: 56, borderRadius: 14, - overflow: "hidden", + overflow: 'hidden', backgroundColor: Palette.glass, }, buttonText: { @@ -53,4 +46,4 @@ const styles = StyleSheet.create({ color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, }, -}); +}) diff --git a/src/components/BorderGradient/BorderGradient.js b/src/components/BorderGradient/BorderGradient.js index a7c5d23..96acf14 100644 --- a/src/components/BorderGradient/BorderGradient.js +++ b/src/components/BorderGradient/BorderGradient.js @@ -1,13 +1,8 @@ -import { StyleSheet, View } from "react-native"; +import { StyleSheet, View } from 'react-native' -import { GradientBorderView } from "../GradientBorderView"; +import { GradientBorderView } from '../GradientBorderView' -const BorderGradient = ({ - children, - contentContainerStyle, - gradientProps, - ...props -}) => { +const BorderGradient = ({ children, contentContainerStyle, gradientProps, ...props }) => { return ( - ); -}; + ) +} -export default BorderGradient; +export default BorderGradient const styles = StyleSheet.create({ innerContainer: { flex: 1, }, -}); +}) diff --git a/src/components/BorderGradient/BorderGradient.web.js b/src/components/BorderGradient/BorderGradient.web.js index 9a94225..3b90ad3 100644 --- a/src/components/BorderGradient/BorderGradient.web.js +++ b/src/components/BorderGradient/BorderGradient.web.js @@ -1,5 +1,5 @@ -import { StyleSheet, View } from "react-native"; -import { useState } from "react"; +import { StyleSheet, View } from 'react-native' +import { useState } from 'react' const BorderGradient = ({ children, gradientProps, ...props }) => { const defaultGradientProps = { @@ -15,10 +15,9 @@ const BorderGradient = ({ children, gradientProps, ...props }) => { colors: [], useAngle: false, angle: 0, - }; + } - const { locations, end, start, useAngle, angle, onLayout, colors } = - gradientProps; + const { locations, end, start, useAngle, angle, onLayout, colors } = gradientProps const { style, @@ -32,29 +31,29 @@ const BorderGradient = ({ children, gradientProps, ...props }) => { borderLeftWidth, borderRightWidth, borderBottomWidth, - } = props; + } = props - const propStart = start ?? defaultGradientProps?.start; - const propEnd = end ?? defaultGradientProps?.end; + const propStart = start ?? defaultGradientProps?.start + const propEnd = end ?? defaultGradientProps?.end const [state, setState] = useState({ width: 1, height: 1, - }); + }) const measure = (event) => { setState({ width: event.nativeEvent.layout.width, height: event.nativeEvent.layout.height, - }); + }) if (onLayout) { - onLayout(event); + onLayout(event) } - }; + } const getAngle = () => { if (useAngle) { - return angle + "deg"; + return angle + 'deg' } // Math.atan2 handles Infinity @@ -63,26 +62,26 @@ const BorderGradient = ({ children, gradientProps, ...props }) => { state.width * (propEnd.y - propStart.y), state.height * (propEnd.x - propStart.x) ) + - Math.PI / 2; - return _angle + "rad"; - }; + Math.PI / 2 + return _angle + 'rad' + } const getColors = () => colors .map((color, index) => { - const location = locations?.[index] ?? defaultGradientProps.locations; - let locationStyle = ""; + const location = locations?.[index] ?? defaultGradientProps.locations + let locationStyle = '' if (location) { - locationStyle = " " + location * 100 + "%"; + locationStyle = ' ' + location * 100 + '%' } - return color + locationStyle; + return color + locationStyle }) - .join(","); + .join(',') return ( { borderLeftWidth, borderRightWidth, borderBottomWidth, - borderStyle: "solid", - borderColor: "transparent", + borderStyle: 'solid', + borderColor: 'transparent', // borderImage: `linear-gradient(${getAngle()},${getColors()}) 1`, background: `linear-gradient(${getAngle()},${getColors()}) border-box`, - WebkitMask: - "linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)", - WebkitMaskComposite: "xor", - maskComposite: "exclude", - overflow: "hidden", + WebkitMask: 'linear-gradient(#fff 0 0) padding-box,linear-gradient(#fff 0 0)', + WebkitMaskComposite: 'xor', + maskComposite: 'exclude', + overflow: 'hidden', }, ]} > {children} - ); -}; + ) +} -export default BorderGradient; +export default BorderGradient const styles = StyleSheet.create({ innerContainer: { flex: 1, - position: "absolute", + position: 'absolute', top: 0, bottom: 0, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', right: 0, left: 0, - overflow: "hidden", + overflow: 'hidden', }, -}); +}) diff --git a/src/components/BorderGradientButton.js b/src/components/BorderGradientButton.js index c3d5c70..7a066bc 100644 --- a/src/components/BorderGradientButton.js +++ b/src/components/BorderGradientButton.js @@ -1,40 +1,39 @@ -import { BlurView } from "expo-blur"; -import React from "react"; -import { Image, Pressable, Text, View } from "react-native"; -import { Palette, Style } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; -import { size as sizeStyle } from "../styles/Style"; -import BorderGradient from "./BorderGradient/BorderGradient"; +import { BlurView } from 'expo-blur' +import React from 'react' +import { Image, Pressable, Text, View } from 'react-native' +import { Palette, Style } from '../styles' +import { FONT_FAMILY } from '../styles/Fonts' +import { size as sizeStyle } from '../styles/Style' +import BorderGradient from './BorderGradient/BorderGradient' const HEIGHT_BY_SIZE = { small: 40, medium: 50, large: 58, -}; +} const FONT_SIZE_BY_SIZE = { small: 13, medium: 15, large: 17, -}; +} const BorderGradientButton = ({ - title = "J’ai déjà mes paroles", + title = 'J’ai déjà mes paroles', onPress, icon, titleStyle, containerStyle = {}, - tint = "dark", + tint = 'dark', disabled = false, maxWidth = null, - size = "medium", + size = 'medium', height = null, }) => { - const resolvedSize = HEIGHT_BY_SIZE[size] ? size : "medium"; - const buttonHeight = - typeof height === "number" ? height : HEIGHT_BY_SIZE[resolvedSize]; - const fontSize = FONT_SIZE_BY_SIZE[resolvedSize]; - const iconSize = resolvedSize === "small" ? 14 : 16; + const resolvedSize = HEIGHT_BY_SIZE[size] ? size : 'medium' + const buttonHeight = typeof height === 'number' ? height : HEIGHT_BY_SIZE[resolvedSize] + const fontSize = FONT_SIZE_BY_SIZE[resolvedSize] + const iconSize = resolvedSize === 'small' ? 14 : 16 return ( - {icon && ( - - )} + {icon && } - ); -}; + ) +} -export default BorderGradientButton; +export default BorderGradientButton diff --git a/src/components/BorderGradientButton.web.js b/src/components/BorderGradientButton.web.js index d23452a..67169e8 100644 --- a/src/components/BorderGradientButton.web.js +++ b/src/components/BorderGradientButton.web.js @@ -1,18 +1,18 @@ -import { BlurView } from "expo-blur"; -import React from "react"; -import { Image, Pressable, Text, View } from "react-native"; -import { Palette, Style } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; -import { size } from "../styles/Style"; -import RotationBorder from "./RotationBorder/RotationBorder"; +import { BlurView } from 'expo-blur' +import React from 'react' +import { Image, Pressable, Text, View } from 'react-native' +import { Palette, Style } from '../styles' +import { FONT_FAMILY } from '../styles/Fonts' +import { size } from '../styles/Style' +import RotationBorder from './RotationBorder/RotationBorder' const BorderGradientButton = ({ - title = "J’ai déjà mes paroles", + title = 'J’ai déjà mes paroles', onPress, icon, titleStyle, containerStyle = {}, - tint = "dark", + tint = 'dark', disabled = false, maxWidth = null, }) => { @@ -20,35 +20,35 @@ const BorderGradientButton = ({ ...(maxWidth ? { maxWidth } : {}), ...containerStyle, opacity: disabled ? 0.6 : 1, - }; + } return ( - ); -}; + ) +} -export default BorderGradientButton; +export default BorderGradientButton diff --git a/src/components/BottomSheet/index.js b/src/components/BottomSheet/index.js index 9ce76eb..b916857 100644 --- a/src/components/BottomSheet/index.js +++ b/src/components/BottomSheet/index.js @@ -1,3 +1,3 @@ -import BottomSheet from "@gorhom/bottom-sheet"; +import BottomSheet from '@gorhom/bottom-sheet' -export default BottomSheet; +export default BottomSheet diff --git a/src/components/BottomSheet/index.web.js b/src/components/BottomSheet/index.web.js index 82a63ab..b1f5357 100644 --- a/src/components/BottomSheet/index.web.js +++ b/src/components/BottomSheet/index.web.js @@ -1,41 +1,37 @@ -import React, { useImperativeHandle, useState, useRef } from "react"; -import { View, TouchableOpacity, ScrollView } from "react-native"; -import { Portal } from "@gorhom/portal"; -import { Motion } from "@legendapp/motion"; +import React, { useImperativeHandle, useState, useRef } from 'react' +import { View, TouchableOpacity, ScrollView } from 'react-native' +import { Portal } from '@gorhom/portal' +import { Motion } from '@legendapp/motion' -import { Palette } from "../../styles"; -import { - isDesktop, - isLargeDesktop, - sidebarWidth, -} from "../../hooks/useLayoutType"; +import { Palette } from '../../styles' +import { isDesktop, isLargeDesktop, sidebarWidth } from '../../hooks/useLayoutType' -export const SheetScrollView = ScrollView; -export const SheetBackdrop = View; +export const SheetScrollView = ScrollView +export const SheetBackdrop = View const BottomSheet = React.forwardRef((props, ref) => { - const [showSheet, setShowSheet] = useState(false); + const [showSheet, setShowSheet] = useState(false) - const bottomSheetRef = useRef(); + const bottomSheetRef = useRef() useImperativeHandle(ref, () => ({ snapToIndex: () => { - setShowSheet(true); + setShowSheet(true) }, expand: () => { - setShowSheet(true); + setShowSheet(true) }, collapse: () => closeBottomSheet(), close: () => closeBottomSheet(), - })); + })) const closeBottomSheet = () => { - setShowSheet(false); - props.onChange(-1); - }; + setShowSheet(false) + props.onChange(-1) + } if (!showSheet) { - return null; + return null } return ( @@ -44,51 +40,49 @@ const BottomSheet = React.forwardRef((props, ref) => { initial={{ right: -500, opacity: 0 }} animate={{ right: 0, opacity: 1 }} style={{ - position: "fixed", + position: 'fixed', right: 0, top: 0, left: 0, bottom: 0, flex: 1, zIndex: 1000000, - justifyContent: "center", - alignItems: "center", + justifyContent: 'center', + alignItems: 'center', }} > {props.children} - ); -}); + ) +}) // TODO une croix pour fermer sur mobile web -export default BottomSheet; +export default BottomSheet diff --git a/src/components/BottomSheetContainer.js b/src/components/BottomSheetContainer.js index a7a8cc7..c429aa7 100644 --- a/src/components/BottomSheetContainer.js +++ b/src/components/BottomSheetContainer.js @@ -1,42 +1,42 @@ -import { AnimatePresence, Motion } from "@legendapp/motion"; -import { useKeyboard } from "@react-native-community/hooks"; -import { useCallback, useEffect, useState } from "react"; -import { Keyboard, StyleSheet } from "react-native"; +import { AnimatePresence, Motion } from '@legendapp/motion' +import { useKeyboard } from '@react-native-community/hooks' +import { useCallback, useEffect, useState } from 'react' +import { Keyboard, StyleSheet } from 'react-native' -import BottomSheet from "./BottomSheet"; +import BottomSheet from './BottomSheet' -import useLayoutType from "../hooks/useLayoutType"; -import { Palette } from "../styles"; +import useLayoutType from '../hooks/useLayoutType' +import { Palette } from '../styles' export default ({ children, bottomSheetRef, - snapPoints = ["25%", "50%"], + snapPoints = ['25%', '50%'], handleStyle = {}, ...rest }) => { - const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0); + const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0) - const { keyboardShown = false } = useKeyboard(); - const { isWeb } = useLayoutType(); + const { keyboardShown = false } = useKeyboard() + const { isWeb } = useLayoutType() const handleSheetChanges = useCallback((index) => { - setCurrentSnapPointIndex(index); + setCurrentSnapPointIndex(index) if (index <= 0) { - Keyboard.dismiss(); + Keyboard.dismiss() } - }, []); + }, []) useEffect(() => { if (bottomSheetRef.current && !isWeb && currentSnapPointIndex > 0) { if (keyboardShown) { - bottomSheetRef.current.expand(); + bottomSheetRef.current.expand() } else { - bottomSheetRef.current.snapToIndex(1); + bottomSheetRef.current.snapToIndex(1) } } - }, [keyboardShown]); + }, [keyboardShown]) return ( <> @@ -47,7 +47,7 @@ export default ({ onPress={() => bottomSheetRef?.current?.close()} > @@ -83,5 +83,5 @@ export default ({ {children} - ); -}; + ) +} diff --git a/src/components/Button.js b/src/components/Button.js index d5478c2..744336b 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,17 +1,17 @@ -import { Motion } from "@legendapp/motion"; -import * as Haptics from "expo-haptics"; -import { Text, View } from "react-native"; +import { Motion } from '@legendapp/motion' +import * as Haptics from 'expo-haptics' +import { Text, View } from 'react-native' -import { isDesktop, isMobile, isNative } from "../hooks/useLayoutType"; -import { Fonts, Palette } from "../styles"; -import Style, { gutters, mainBorderRadius } from "../styles/Style"; +import { isDesktop, isMobile, isNative } from '../hooks/useLayoutType' +import { Fonts, Palette } from '../styles' +import Style, { gutters, mainBorderRadius } from '../styles/Style' export default ({ - type = "primary", - theme = "default", // "default" | "radioactiv" + type = 'primary', + theme = 'default', // "default" | "radioactiv" text, - onPress = () => console.log("null"), + onPress = () => console.log('null'), isAbsoluteBottom = false, alternateAction = {}, @@ -23,39 +23,39 @@ export default ({ isMainDesktopPanel = false, }) => { - const buttonWidth = alternateAction?.text ? "49%" : "100%"; + const buttonWidth = alternateAction?.text ? '49%' : '100%' return ( {alternateAction?.text && alternateAction?.onPress ? ( - ); -}; + ) +} export const BaseButton = ({ - type = "primary", - theme = "default", + type = 'primary', + theme = 'default', text, - onPress = () => console.log("null"), + onPress = () => console.log('null'), containerStyle = {}, textStyle = {}, hasShadow = false, }) => { - const primaryColor = - theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary; + const primaryColor = theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary const primaryTransparentColor = - theme === "radioactiv" - ? Palette.transparentRadioactivGreen - : Palette.transparentPrimary; + theme === 'radioactiv' ? Palette.transparentRadioactivGreen : Palette.transparentPrimary - const textColor = type === "secondary" ? primaryColor : Palette.darkPurple; + const textColor = type === 'secondary' ? primaryColor : Palette.darkPurple return ( { if (isNative) { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) } - onPress(); + onPress() }} style={{ - width: "100%", + width: '100%', height: 50, marginTop: gutters / 2, ...Style.containerRow, ...Style.containerCenter, backgroundColor: primaryColor, borderRadius: mainBorderRadius, - ...(type === "secondary" + ...(type === 'secondary' ? { backgroundColor: primaryTransparentColor, } @@ -134,7 +131,7 @@ export const BaseButton = ({ - ); -}; + ) +} diff --git a/src/components/ChatInput.js b/src/components/ChatInput.js index 2216b95..5cb9980 100644 --- a/src/components/ChatInput.js +++ b/src/components/ChatInput.js @@ -1,32 +1,32 @@ -import { useState, useEffect } from "reactn"; -import { Pressable, TextInput, View, Image, Platform } from "react-native"; -import { BlurView } from "expo-blur"; +import { useState, useEffect } from 'reactn' +import { Pressable, TextInput, View, Image, Platform } from 'react-native' +import { BlurView } from 'expo-blur' -import { Fonts, gutters, Palette } from "../styles"; -import Style from "../styles/Style"; +import { Fonts, gutters, Palette } from '../styles' +import Style from '../styles/Style' -import { chatsRef } from "../config/firebase"; +import { chatsRef } from '../config/firebase' -import { icons } from "../assets"; +import { icons } from '../assets' -import { isWeb } from "../hooks/useLayoutType.js"; +import { isWeb } from '../hooks/useLayoutType.js' -import DocumentDropZone from "./DocumentDropZone.js"; -import FilesPreview from "./FilesPreview.js"; +import DocumentDropZone from './DocumentDropZone.js' +import FilesPreview from './FilesPreview.js' const ChatInput = ({ message, setMessage, onSendMessage, containerStyle = {}, - placeholder = "", + placeholder = '', chatID = null, }) => { - const [files, setFiles] = useState([]); + const [files, setFiles] = useState([]) useEffect(() => { - setFiles([]); - }, [chatID]); + setFiles([]) + }, [chatID]) const handleMessageObject = () => { if (message.length > 0 || files.length > 0) { @@ -34,37 +34,37 @@ const ChatInput = ({ customPayload: { files, }, - }); - setMessage(""); - setFiles([]); + }) + setMessage('') + setFiles([]) } - }; + } const handleKeyPress = (e) => { - if (e?.nativeEvent?.key?.toLowerCase() === "enter") { - handleMessageObject(); + if (e?.nativeEvent?.key?.toLowerCase() === 'enter') { + handleMessageObject() } - }; + } return ( - ); -}; + ) +} -export default ChatInput; +export default ChatInput diff --git a/src/components/ChatInterface.js b/src/components/ChatInterface.js index b750b6a..44d67c2 100644 --- a/src/components/ChatInterface.js +++ b/src/components/ChatInterface.js @@ -1,78 +1,66 @@ -import { useState, useRef, useEffect, useGlobal, getGlobal } from "reactn"; -import { - Pressable, - TextInput, - View, - Image, - Text, - Keyboard, - FlatList, -} from "react-native"; -import { responsiveHeight } from "../actions/responsiveSizes.js"; -import { useDataFromRef } from "react-native-minuit/src/hooks"; -import { useKeyboard } from "@react-native-community/hooks"; -import moment from "moment"; +import { useState, useRef, useEffect, useGlobal, getGlobal } from 'reactn' +import { Pressable, TextInput, View, Image, Text, Keyboard, FlatList } from 'react-native' +import { responsiveHeight } from '../actions/responsiveSizes.js' +import { useDataFromRef } from 'react-native-minuit/src/hooks' +import { useKeyboard } from '@react-native-community/hooks' +import moment from 'moment' -import { Fonts, gutters, Palette } from "../styles"; -import Style, { bubbleStyle } from "../styles/Style"; +import { Fonts, gutters, Palette } from '../styles' +import Style, { bubbleStyle } from '../styles/Style' -import firebase, { chatsRef } from "../config/firebase"; +import firebase, { chatsRef } from '../config/firebase' -import { formatNameForConfidentiality } from "../helpers/index.js"; -import useLayoutType from "../hooks/useLayoutType.js"; +import { formatNameForConfidentiality } from '../helpers/index.js' +import useLayoutType from '../hooks/useLayoutType.js' -import TypingLoader from "./TypingLoader"; -import Avatar from "./Avatar"; -import RenderChatFile from "./RenderChatFile.js"; -import HyperlinkContainer from "./HyperlinkContainer.js"; +import TypingLoader from './TypingLoader' +import Avatar from './Avatar' +import RenderChatFile from './RenderChatFile.js' +import HyperlinkContainer from './HyperlinkContainer.js' export default ({ chatID = null, - layout = "default", // default | taskSideBar + layout = 'default', // default | taskSideBar containerStyle = {}, messageListContainerStyle = {}, }) => { - const [currentUID] = useGlobal("currentUID"); - const [currentProjectData] = useGlobal("currentProjectData"); + const [currentUID] = useGlobal('currentUID') + const [currentProjectData] = useGlobal('currentProjectData') - const [isTyping] = useState(false); + const [isTyping] = useState(false) - const flatListRef = useRef(); + const flatListRef = useRef() - const { isNative } = useLayoutType(); - const { keyboardShown = false } = useKeyboard(); + const { isNative } = useLayoutType() + const { keyboardShown = false } = useKeyboard() const { data: messageList } = useDataFromRef({ ref: chatID - ? chatsRef - .doc(chatID) - .collection("messages") - .orderBy("createdAt", "desc") - .limit(50) + ? chatsRef.doc(chatID).collection('messages').orderBy('createdAt', 'desc').limit(50) : null, simpleRef: false, listener: true, condition: chatID, refreshArray: [chatID], - documentID: "messageID", - }); + documentID: 'messageID', + }) let conversation = [ - isTyping ? { senderID: "minuit.ai", userTyping: true } : null, + isTyping ? { senderID: 'minuit.ai', userTyping: true } : null, ...(messageList || []), - ].filter((item) => item); + ].filter((item) => item) - if (layout === "default") { - conversation = conversation.reverse(); + if (layout === 'default') { + conversation = conversation.reverse() } useEffect(() => { if (flatListRef?.current && isNative && keyboardShown) { - flatListRef?.current?.scrollToEnd?.({ animated: true }); + flatListRef?.current?.scrollToEnd?.({ animated: true }) } - }, [keyboardShown, isNative]); + }, [keyboardShown, isNative]) return ( - item?.messageID - ? `${item?.messageID?.toString()}-${index}` - : `no-messageID-${index}` + item?.messageID ? `${item?.messageID?.toString()}-${index}` : `no-messageID-${index}` } renderItem={({ item: { createdAt = null, senderID, - senderName = "", + senderName = '', senderProfilePicture = null, - text = "", + text = '', userTyping = false, files = [], }, index, }) => { - const isCurrentUser = senderID === currentUID; - const isChatbot = senderID === "minuit.ai"; + const isCurrentUser = senderID === currentUID + const isChatbot = senderID === 'minuit.ai' - const senderData = - currentProjectData?.teamMembers?.[senderID] || {}; + const senderData = currentProjectData?.teamMembers?.[senderID] || {} const isDayChange = - moment(createdAt?.toDate()).format("DD/MM/YYYY") !== - moment( - conversation[index - 1]?.createdAt?.toDate() || new Date() - ).format("DD/MM/YYYY") || !conversation[index - 1]; + moment(createdAt?.toDate()).format('DD/MM/YYYY') !== + moment(conversation[index - 1]?.createdAt?.toDate() || new Date()).format( + 'DD/MM/YYYY' + ) || !conversation[index - 1] return ( <> @@ -132,21 +117,19 @@ export default ({ - {moment(createdAt?.toDate()).format( - "[Le] DD/MM/YYYY [à] HH:mm" - )} + {moment(createdAt?.toDate()).format('[Le] DD/MM/YYYY [à] HH:mm')} @@ -156,9 +139,9 @@ export default ({ style={[ Style.containerRow, { - flexDirection: isCurrentUser ? "row-reverse" : "row", - alignItems: "flex-end", - width: "100%", + flexDirection: isCurrentUser ? 'row-reverse' : 'row', + alignItems: 'flex-end', + width: '100%', marginBottom: gutters / 2, }, ]} @@ -175,21 +158,15 @@ export default ({ } : { marginRight: gutters / 2, - backgroundColor: "transparent", + backgroundColor: 'transparent', borderColor: Palette.primary, borderWidth: 1, }), }} > @@ -197,7 +174,7 @@ export default ({ {files?.map((props, index) => ( @@ -209,8 +186,8 @@ export default ({ index !== files?.length - 1 ? gutters / 2 : text?.length > 0 || userTyping - ? gutters / 2 - : 0, + ? gutters / 2 + : 0, }} /> )) || null} @@ -232,11 +209,11 @@ export default ({ @@ -249,30 +226,29 @@ export default ({ - ); + ) }} /> - ); -}; + ) +} export const onSendMessage = async ({ chatID = null, projectID = null, - message = "", + message = '', setMessage = () => {}, setIsTyping = () => {}, customPayload = {}, }) => { try { - const currentUID = getGlobal()?.currentUID || null; - const { name = "", profilePictureURL = null } = - getGlobal()?.currentUserData || {}; + const currentUID = getGlobal()?.currentUID || null + const { name = '', profilePictureURL = null } = getGlobal()?.currentUserData || {} if (message.length > 0 || customPayload?.files?.length > 0) { - Keyboard.dismiss(); - setMessage(""); + Keyboard.dismiss() + setMessage('') const messageData = { projectID, @@ -282,13 +258,13 @@ export const onSendMessage = async ({ senderProfilePicture: profilePictureURL || null, text: message, ...customPayload, - }; + } - await chatsRef.doc(chatID).collection("messages").add(messageData); + await chatsRef.doc(chatID).collection('messages').add(messageData) } } catch (error) { - console.log(error); + console.log(error) } finally { - setIsTyping(false); + setIsTyping(false) } -}; +} diff --git a/src/components/CoinIcon.js b/src/components/CoinIcon.js index fe76056..3bee7b2 100644 --- a/src/components/CoinIcon.js +++ b/src/components/CoinIcon.js @@ -1,6 +1,6 @@ -import React from "react"; -import { Image } from "react-native"; -import { icons } from "../assets"; +import React from 'react' +import { Image } from 'react-native' +import { icons } from '../assets' const CoinIcon = ({ size = 22, style }) => { return ( @@ -10,12 +10,12 @@ const CoinIcon = ({ size = 22, style }) => { { width: size, height: size, - resizeMode: "contain", + resizeMode: 'contain', }, style, ]} /> - ); -}; + ) +} -export default CoinIcon; +export default CoinIcon diff --git a/src/components/ConnectBtn.js b/src/components/ConnectBtn.js index 1d1dc62..8b47da6 100644 --- a/src/components/ConnectBtn.js +++ b/src/components/ConnectBtn.js @@ -1,14 +1,14 @@ -import { BlurView } from "expo-blur"; -import React from "react"; -import { Pressable, Text } from "react-native"; -import { Routes } from "../navigation"; -import { navigate } from "../navigation/NavigationService"; -import { Palette } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; +import { BlurView } from 'expo-blur' +import React from 'react' +import { Pressable, Text } from 'react-native' +import { Routes } from '../navigation' +import { navigate } from '../navigation/NavigationService' +import { Palette } from '../styles' +import { FONT_FAMILY } from '../styles/Fonts' export default function ConnectBtn({ style }) { const handlePress = () => { - navigate(Routes.Login); - }; + navigate(Routes.Login) + } return ( - {"Se connecter"} + {'Se connecter'} - ); + ) } diff --git a/src/components/CreditAmount.js b/src/components/CreditAmount.js index a69b80f..d6bcc71 100644 --- a/src/components/CreditAmount.js +++ b/src/components/CreditAmount.js @@ -1,42 +1,42 @@ -import React from "react"; -import { StyleSheet, Text, View } from "react-native"; -import CoinIcon from "./CoinIcon"; -import { FONT_FAMILY } from "../styles/Fonts"; +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' +import CoinIcon from './CoinIcon' +import { FONT_FAMILY } from '../styles/Fonts' const defaultFormatOptions = { minimumFractionDigits: 0, maximumFractionDigits: 0, -}; +} const formatAmount = (value, options = defaultFormatOptions) => { - if (typeof value !== "number" || !Number.isFinite(value)) { - return null; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null } try { - return new Intl.NumberFormat("fr-FR", { + return new Intl.NumberFormat('fr-FR', { ...defaultFormatOptions, ...options, - }).format(value); + }).format(value) } catch (_error) { - return `${value}`; + return `${value}` } -}; +} const extractNumericValue = (value) => { - if (typeof value === "number" && Number.isFinite(value)) { - return value; + if (typeof value === 'number' && Number.isFinite(value)) { + return value } - if (typeof value === "string") { - const parsed = Number(value); + if (typeof value === 'string') { + const parsed = Number(value) if (Number.isFinite(parsed)) { - return parsed; + return parsed } } - return null; -}; + return null +} const CreditAmount = ({ value, @@ -44,78 +44,62 @@ const CreditAmount = ({ textStyle, iconSize = 18, iconStyle, - iconPosition = "right", + iconPosition = 'right', gap = 6, showPlus = false, formatterOptions, accessibilityLabel, }) => { - const numericValue = React.useMemo( - () => extractNumericValue(value), - [value], - ); + const numericValue = React.useMemo(() => extractNumericValue(value), [value]) - const resolvedValue = - numericValue !== null - ? numericValue - : value ?? 0; + const resolvedValue = numericValue !== null ? numericValue : (value ?? 0) const formattedValue = numericValue !== null - ? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}` - : typeof resolvedValue === "string" - ? resolvedValue - : `${resolvedValue}`; + ? (formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`) + : typeof resolvedValue === 'string' + ? resolvedValue + : `${resolvedValue}` - const prefix = - numericValue !== null && showPlus && numericValue > 0 ? "+" : ""; + const prefix = numericValue !== null && showPlus && numericValue > 0 ? '+' : '' - const a11yLabel = - accessibilityLabel || `${prefix}${formattedValue} pièces`; + const a11yLabel = accessibilityLabel || `${prefix}${formattedValue} pièces` const containerStyles = Array.isArray(style) ? [styles.container, { gap }, ...style] - : [styles.container, { gap }, style]; + : [styles.container, { gap }, style] const textStyles = Array.isArray(textStyle) ? [styles.value, ...textStyle] - : [styles.value, textStyle]; + : [styles.value, textStyle] const iconStyles = Array.isArray(iconStyle) ? [styles.icon, ...iconStyle] - : [styles.icon, iconStyle]; + : [styles.icon, iconStyle] return ( - - {iconPosition === "left" ? ( - - ) : null} + + {iconPosition === 'left' ? : null} {`${prefix}${formattedValue}`} - {iconPosition === "right" ? ( - - ) : null} + {iconPosition === 'right' ? : null} - ); -}; + ) +} const styles = StyleSheet.create({ container: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', }, value: { fontFamily: FONT_FAMILY.InterSemiBold, fontSize: 16, - color: "#fff", + color: '#fff', }, icon: { width: 18, height: 18, }, -}); +}) -export default CreditAmount; +export default CreditAmount diff --git a/src/components/Dialog/index.js b/src/components/Dialog/index.js index 1bed148..d7a6a1d 100644 --- a/src/components/Dialog/index.js +++ b/src/components/Dialog/index.js @@ -1,8 +1,7 @@ -import Dialog from "react-native-dialog"; - -export const Container = Dialog.Container; -export const Button = Dialog.Button; -export const Title = Dialog.Title; -export const Input = Dialog.Input; -export const Description = Dialog.Description; +import Dialog from 'react-native-dialog' +export const Container = Dialog.Container +export const Button = Dialog.Button +export const Title = Dialog.Title +export const Input = Dialog.Input +export const Description = Dialog.Description diff --git a/src/components/Dialog/index.web.js b/src/components/Dialog/index.web.js index 6cd243f..ac4e678 100644 --- a/src/components/Dialog/index.web.js +++ b/src/components/Dialog/index.web.js @@ -1,29 +1,29 @@ -import React from "react"; -import { Text, View } from "react-native"; +import React from 'react' +import { Text, View } from 'react-native' -import { Input as MinuitInput } from "../Input"; -import { BaseButton as MinuitButton } from "../Button"; -import Overlay from "../Overlay"; +import { Input as MinuitInput } from '../Input' +import { BaseButton as MinuitButton } from '../Button' +import Overlay from '../Overlay' -import { Fonts, Palette, gutters } from "../../styles"; -import Style from "../../styles/Style"; +import { Fonts, Palette, gutters } from '../../styles' +import Style from '../../styles/Style' export const Container = ({ visible, setVisible = () => null, children }) => { return ( {children} - ); -}; + ) +} export const Title = ({ children }) => { return ( { > {children} - ); -}; + ) +} export const Description = ({ children }) => { return ( { > {children} - ); -}; + ) +} -export const Button = ({ - label, - onPress, - type = "primary", - containerStyle = {}, -}) => { +export const Button = ({ label, onPress, type = 'primary', containerStyle = {} }) => { return ( - ); -}; + ) +} export const Input = (props) => { - return ; -}; + return +} diff --git a/src/components/DocumentDropZone.js b/src/components/DocumentDropZone.js index 7112283..a1abf13 100644 --- a/src/components/DocumentDropZone.js +++ b/src/components/DocumentDropZone.js @@ -1,27 +1,27 @@ -import { useActionSheet } from "@expo/react-native-action-sheet"; -import * as DocumentPicker from "expo-document-picker"; -import * as ImagePicker from "expo-image-picker"; -import moment from "moment"; -import { Image, Pressable, Text, View } from "react-native"; -import Compressor from "react-native-compressor"; -import useMinuit from "react-native-minuit/src/hooks/useMinuit"; -import React, { useCallback, useEffect, useRef, useState } from "reactn"; +import { useActionSheet } from '@expo/react-native-action-sheet' +import * as DocumentPicker from 'expo-document-picker' +import * as ImagePicker from 'expo-image-picker' +import moment from 'moment' +import { Image, Pressable, Text, View } from 'react-native' +import Compressor from 'react-native-compressor' +import useMinuit from 'react-native-minuit/src/hooks/useMinuit' +import React, { useCallback, useEffect, useRef, useState } from 'reactn' -import { arrayUnion } from "../config/firebase"; +import { arrayUnion } from '../config/firebase' -import { icons } from "../assets"; -import { Fonts, Palette } from "../styles"; -import Style, { gutterConstant, mainBorderRadius } from "../styles/Style"; +import { icons } from '../assets' +import { Fonts, Palette } from '../styles' +import Style, { gutterConstant, mainBorderRadius } from '../styles/Style' -import { uploadFileToFirebase } from "../helpers/uploadToFirebase"; -import useLayoutType from "../hooks/useLayoutType"; +import { uploadFileToFirebase } from '../helpers/uploadToFirebase' +import useLayoutType from '../hooks/useLayoutType' const NATIVE_OPTIONS = [ - "Importer depuis la galerie", - "Ajouter un fichier", - "Prendre une photo ou une vidéo", - "Annuler", -]; + 'Importer depuis la galerie', + 'Ajouter un fichier', + 'Prendre une photo ou une vidéo', + 'Annuler', +] const DocumentDropZone = ({ containerStyle = {}, @@ -35,162 +35,162 @@ const DocumentDropZone = ({ customElement = null, shouldReturnObject = false, }) => { - const dropRef = useRef(null); + const dropRef = useRef(null) - const [isDragging, setIsDragging] = useState(false); + const [isDragging, setIsDragging] = useState(false) - const { setIsLoading, setTooltip } = useMinuit(); - const { isDesktop, isWeb, isNative } = useLayoutType(); - const { showActionSheetWithOptions } = useActionSheet(); + const { setIsLoading, setTooltip } = useMinuit() + const { isDesktop, isWeb, isNative } = useLayoutType() + const { showActionSheetWithOptions } = useActionSheet() useEffect(() => { if (isWeb && dropRef.current) { - const el = dropRef.current; + const el = dropRef.current const handleDragIn = (e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } const handleDragOut = (e) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() if (!dropRef.current.contains(e.relatedTarget)) { - console.log("drag left"); - setIsDragging(false); + console.log('drag left') + setIsDragging(false) } - }; + } const handleDrop = (e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); + e.preventDefault() + e.stopPropagation() + setIsDragging(false) - let files = [...e.dataTransfer.files]; + let files = [...e.dataTransfer.files] if (files.length > 0) { for (const file of files) { - handleWebFile({ file }); + handleWebFile({ file }) } } - }; + } - el.addEventListener("dragenter", handleDragIn); - el.addEventListener("dragleave", handleDragOut); - el.addEventListener("dragover", (e) => e.preventDefault()); - el.addEventListener("drop", handleDrop); + el.addEventListener('dragenter', handleDragIn) + el.addEventListener('dragleave', handleDragOut) + el.addEventListener('dragover', (e) => e.preventDefault()) + el.addEventListener('drop', handleDrop) return () => { - el.removeEventListener("dragenter", handleDragIn); - el.removeEventListener("dragleave", handleDragOut); - el.removeEventListener("dragover", (e) => e.preventDefault()); - el.removeEventListener("drop", handleDrop); - }; + el.removeEventListener('dragenter', handleDragIn) + el.removeEventListener('dragleave', handleDragOut) + el.removeEventListener('dragover', (e) => e.preventDefault()) + el.removeEventListener('drop', handleDrop) + } } - }, [dropRef?.current]); + }, [dropRef?.current]) const handleWebFile = ({ file }) => { try { - const { type = "" } = file; - const reader = new FileReader(); + const { type = '' } = file + const reader = new FileReader() reader.onloadend = () => { - const base64 = reader.result.split(",")[1]; // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct - const uri = reader.result; // URI en base64 du fichier + const base64 = reader.result.split(',')[1] // Vous pourriez avoir besoin de cette valeur base64 pour un upload direct + const uri = reader.result // URI en base64 du fichier - console.log("file", file); + console.log('file', file) - onUploadDocument({ files: [{ name: file.name, uri, type }] }); - }; + onUploadDocument({ files: [{ name: file.name, uri, type }] }) + } reader.onerror = (err) => { - console.error("FileReader error", err); - }; + console.error('FileReader error', err) + } - reader.readAsDataURL(file); // Lire le fichier et déclencher reader.onloadend lorsque c'est fait + reader.readAsDataURL(file) // Lire le fichier et déclencher reader.onloadend lorsque c'est fait } catch (error) { - console.log(error); - setTooltip({ text: error.message, type: "error" }); + console.log(error) + setTooltip({ text: error.message, type: 'error' }) } - }; + } const onAddFile = async ({} = {}) => { try { if (isNative) { - const cancelButtonIndex = NATIVE_OPTIONS.length - 1; + const cancelButtonIndex = NATIVE_OPTIONS.length - 1 showActionSheetWithOptions( { options: NATIVE_OPTIONS, cancelButtonIndex, - userInterfaceStyle: "dark", + userInterfaceStyle: 'dark', ...Style.actionSheet, }, async (selectedIndex) => { if (selectedIndex !== cancelButtonIndex) { switch (selectedIndex) { case 0: - onChooseLibrary(); - break; + onChooseLibrary() + break case 1: - onChooseDocumentPicker(); - break; + onChooseDocumentPicker() + break case 2: - onTakePicture(); - break; + onTakePicture() + break default: - break; + break } } } - ); + ) } else { - onChooseDocumentPicker(); + onChooseDocumentPicker() } } catch (error) { - console.log(error); - setTooltip({ text: error.message, type: "error" }); + console.log(error) + setTooltip({ text: error.message, type: 'error' }) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } const onChooseLibrary = async ({} = {}) => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsEditing: false, quality: 3, - }); + }) if (result?.assets?.[0]?.uri) { - const { uri, fileName = "" } = result?.assets?.[0]; + const { uri, fileName = '' } = result?.assets?.[0] onUploadDocument({ files: [ { name: getAssetName({ fileName, uri }), uri, - type: "IMAGE", + type: 'IMAGE', }, ], - }); + }) } else { - throw new Error("Aucune image sélectionnée"); + throw new Error('Aucune image sélectionnée') } - }; + } const onChooseDocumentPicker = async ({} = {}) => { const result = await DocumentPicker.getDocumentAsync({ - type: "*/*", + type: '*/*', copyToCacheDirectory: false, - }); + }) if (result?.assets?.length > 0) { - const { name = "", uri = null } = result?.assets[0] || {}; + const { name = '', uri = null } = result?.assets[0] || {} if (!uri) { - throw new Error("Erreur lors de l'ajout du document"); + throw new Error("Erreur lors de l'ajout du document") } onUploadDocument({ @@ -200,117 +200,117 @@ const DocumentDropZone = ({ uri, }, ], - }); + }) } else { - console.log(result); - throw new Error("Erreur lors de l'ajout du document"); + console.log(result) + throw new Error("Erreur lors de l'ajout du document") } - }; + } const onTakePicture = useCallback(async () => { - const { status } = await ImagePicker.requestCameraPermissionsAsync(); + const { status } = await ImagePicker.requestCameraPermissionsAsync() - if (status !== "granted") { - throw new Error("Permissions caméra non accordées"); + if (status !== 'granted') { + throw new Error('Permissions caméra non accordées') } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsEditing: true, quality: 1, - }); + }) if (result?.assets?.[0]?.uri) { - const { uri, fileName = "" } = result?.assets?.[0]; + const { uri, fileName = '' } = result?.assets?.[0] onUploadDocument({ files: [ { name: getAssetName({ fileName, uri }), uri, - type: "IMAGE", + type: 'IMAGE', }, ], - }); + }) } else { - throw new Error("Aucune image sélectionnée"); + throw new Error('Aucune image sélectionnée') } - }, []); + }, []) const getDefaultFileName = () => { - const randomID = Math.random().toString(36).substring(7); - return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}`; - }; + const randomID = Math.random().toString(36).substring(7) + return `${moment().format(`DD_MM_YYYY_HH_mm_ss`)}_${randomID}` + } - const getAssetName = ({ fileName = "", uri = "" }) => { - let name = fileName; + const getAssetName = ({ fileName = '', uri = '' }) => { + let name = fileName - if (!name && uri?.startsWith("file://")) { - const splittedArray = uri?.split("/") || []; - name = splittedArray?.[splittedArray?.length - 1] || ""; + if (!name && uri?.startsWith('file://')) { + const splittedArray = uri?.split('/') || [] + name = splittedArray?.[splittedArray?.length - 1] || '' } if (!name?.length) { - let extension = uri?.split(";")?.[0]?.split("/")?.[1] || ""; + let extension = uri?.split(';')?.[0]?.split('/')?.[1] || '' - const defaultFileName = getDefaultFileName(); + const defaultFileName = getDefaultFileName() if (extension?.length) { - name = `${defaultFileName}.${extension}`; + name = `${defaultFileName}.${extension}` } else { - name = `${defaultFileName}`; + name = `${defaultFileName}` } } - return name; - }; + return name + } const onUploadDocument = async ({ files = [] } = {}) => { try { - setIsLoading(true); + setIsLoading(true) - const filesToUpdate = []; + const filesToUpdate = [] await Promise.all( files.map(async (file) => { - const { name = "", uri = null } = file; + const { name = '', uri = null } = file - console.log(file); + console.log(file) if (!uri) { - throw new Error("Erreur lors de l'ajout du document"); + throw new Error("Erreur lors de l'ajout du document") } - const fileName = name || getDefaultFileName(); - let type = "FILE"; + const fileName = name || getDefaultFileName() + let type = 'FILE' if (name?.toLowerCase()?.match(/\.(jpeg|jpg|gif|png)$/) != null) { - type = "IMAGE"; + type = 'IMAGE' } if (name?.toLowerCase()?.match(/\.(mp4|mov|avi|mkv)$/) != null) { - type = "VIDEO"; + type = 'VIDEO' } - let compressedURI = uri; - let thumbnailURI = null; + let compressedURI = uri + let thumbnailURI = null if (isNative) { - if (type === "IMAGE") { + if (type === 'IMAGE') { compressedURI = await Compressor.Image.compress(uri, { - compressionMethod: "manual", + compressionMethod: 'manual', maxWidth: 1000, quality: 0.8, - }); - } else if (type === "VIDEO") { - compressedURI = await Compressor.Video.compress(uri); + }) + } else if (type === 'VIDEO') { + compressedURI = await Compressor.Video.compress(uri) } } const { resultURI = null } = await uploadFileToFirebase({ uri: compressedURI, path: `documents/${documentID}/files/${fileName}`, - }); + }) if (resultURI) { if (shouldReturnObject) { @@ -319,39 +319,39 @@ const DocumentDropZone = ({ uri: resultURI, type, thumbnailURI, - }); + }) } else { - filesToUpdate.push(resultURI); + filesToUpdate.push(resultURI) } } else { - console.log("resultURI is null"); + console.log('resultURI is null') } }) - ); + ) if (filesToUpdate.length > 0) { if (documentExists) { await collectionRef.doc(documentID).update({ files: arrayUnion(...filesToUpdate), - }); + }) } if (setFiles) { - setFiles((prev) => [...prev, ...filesToUpdate]); + setFiles((prev) => [...prev, ...filesToUpdate]) } } - setTooltip({ text: "Document(s) ajouté(s) avec succès" }); + setTooltip({ text: 'Document(s) ajouté(s) avec succès' }) } catch (error) { - console.log(error); - setTooltip({ text: error.message, type: "error" }); + console.log(error) + setTooltip({ text: error.message, type: 'error' }) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } if (!documentID) { - return null; + return null } return ( @@ -361,11 +361,9 @@ const DocumentDropZone = ({ {isWeb && isDesktop ? `Glissez-déposez ici\nles fichiers, images et vidéos\nà ajouter.` - : "Ajouter une image,\nun fichier ou une vidéo."} + : 'Ajouter une image,\nun fichier ou une vidéo.'} )} - ); -}; + ) +} -export default DocumentDropZone; +export default DocumentDropZone diff --git a/src/components/DynamicImage.js b/src/components/DynamicImage.js index 8847fec..4eecf87 100644 --- a/src/components/DynamicImage.js +++ b/src/components/DynamicImage.js @@ -1,40 +1,34 @@ -import React, { useState, useEffect } from "react"; -import { View } from "react-native"; -import { Image } from "expo-image"; +import React, { useState, useEffect } from 'react' +import { View } from 'react-native' +import { Image } from 'expo-image' const DynamicImage = ({ uri, children, ...props }) => { - const [loading, setLoading] = useState(true); - const [numRetries, setNumRetries] = useState(0); + const [loading, setLoading] = useState(true) + const [numRetries, setNumRetries] = useState(0) useEffect(() => { if (numRetries < 15) { const timer = setTimeout(() => { - setLoading(true); - }, 2500); - return () => clearTimeout(timer); + setLoading(true) + }, 2500) + return () => clearTimeout(timer) } - }, [numRetries]); + }, [numRetries]) const handleError = () => { - setLoading(false); - setNumRetries(numRetries + 1); - }; + setLoading(false) + setNumRetries(numRetries + 1) + } return ( {loading && numRetries < 15 && uri ? ( - + ) : ( {children} )} - ); -}; + ) +} -export default DynamicImage; +export default DynamicImage diff --git a/src/components/EmptyFlashListPlaceholder.js b/src/components/EmptyFlashListPlaceholder.js index 7889c35..b8dc728 100644 --- a/src/components/EmptyFlashListPlaceholder.js +++ b/src/components/EmptyFlashListPlaceholder.js @@ -1,60 +1,54 @@ -import React, { useGlobal } from "reactn"; -import { View, Text } from "react-native"; +import React, { useGlobal } from 'reactn' +import { View, Text } from 'react-native' -import useLayoutType from "../hooks/useLayoutType"; +import useLayoutType from '../hooks/useLayoutType' -import Button from "./Button"; +import Button from './Button' -import { Style, Fonts } from "../styles"; -import { responsiveScreenHeight } from "react-native-responsive-dimensions"; +import { Style, Fonts } from '../styles' +import { responsiveScreenHeight } from 'react-native-responsive-dimensions' -const EmptyFlashListPlaceholder = ({ - loading = false, - text = "-", - buttonData = {}, -}) => { - const [, setShowSearch] = useGlobal("showSearch"); +const EmptyFlashListPlaceholder = ({ loading = false, text = '-', buttonData = {} }) => { + const [, setShowSearch] = useGlobal('showSearch') - const { isMobile } = useLayoutType; + const { isMobile } = useLayoutType if (loading) { return ( - + Chargement en cours... - ); + ) } return ( - - {text} - + {text} {buttonData?.text?.length > 0 && (