refactor(backend): migrate from JavaScript to TypeScript

- tsconfig.json strict mode (noImplicitAny, strictNullChecks, noUnused*)
- Replace nodemon with tsx (watch + run TS directly)
- Build script (tsc -> dist/) and typecheck script
- Fastify decorator types in types/fastify.d.ts (prisma, openai,
  stripe, googleClient, auth helpers, ai helpers, request.user)
- Typed route handlers with generic Body/Params
- Strict null checks on Prisma results and env vars
- Stripe plugin now optional (no-op if STRIPE_SECRET_KEY missing)
- Delete dead utils/errors.js (empty) and utils/resend.js
  (contained a hardcoded Resend API key, unused)
- Add @types/bcrypt, @types/nodemailer, typescript-eslint
- ESLint upgraded to typescript-eslint flat config
- deploy.sh: run prisma generate, migrate deploy, backend build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-07 22:36:21 +02:00
parent 0134390f5e
commit fc3dfe83c9
31 changed files with 1171 additions and 6528 deletions

View File

@ -5,7 +5,7 @@ Freedge génère des recettes personnalisées à partir des ingrédients dictés
## Stack ## Stack
- **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router - **Frontend** : React 19 + Vite + TailwindCSS + ShadCN/UI + React Router
- **Backend** : Fastify 4 + Prisma 5 + SQLite - **Backend** : Fastify 4 + TypeScript + Prisma 5 + SQLite
- **IA** : OpenAI (Whisper, GPT-4o-mini, DALL-E 3) - **IA** : OpenAI (Whisper, GPT-4o-mini, DALL-E 3)
- **Stockage** : MinIO (S3-compatible) avec fallback local - **Stockage** : MinIO (S3-compatible) avec fallback local
- **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser) - **Paiement** : Stripe (client créé à l'inscription — intégration abonnement à finaliser)
@ -15,13 +15,15 @@ Freedge génère des recettes personnalisées à partir des ingrédients dictés
``` ```
freedge/ freedge/
├── backend/ ├── backend/ # TypeScript
│ ├── prisma/ # Schéma + migrations SQLite │ ├── prisma/ # Schéma + migrations SQLite
│ ├── tsconfig.json
│ └── src/ │ └── src/
│ ├── plugins/ # auth, ai, stripe, google-auth │ ├── plugins/ # auth, ai, stripe, google-auth
│ ├── routes/ # auth, recipes, users │ ├── routes/ # auth, recipes, users
│ ├── utils/ # env, storage (MinIO), email, resend │ ├── types/ # fastify.d.ts (augmentation décorateurs)
│ └── server.js │ ├── utils/ # env, storage (MinIO), email
│ └── server.ts
└── frontend/ └── frontend/
└── src/ └── src/
├── api/ # Clients HTTP (auth, user, recipe) ├── api/ # Clients HTTP (auth, user, recipe)
@ -56,8 +58,12 @@ npx prisma migrate dev
npx prisma generate npx prisma generate
# Lancement # Lancement
npm run dev # backend sur :3000 npm run dev # backend (tsx watch) sur :3000
cd ../frontend && pnpm dev # frontend sur :5173 cd ../frontend && pnpm dev # frontend sur :5173
# Build production backend
cd backend && npm run build && npm start # compile vers dist/ puis lance node dist/server.js
npm run typecheck # vérification TS sans build
``` ```
## Variables d'environnement backend ## Variables d'environnement backend

2
backend/.gitignore vendored
View File

@ -1,7 +1,9 @@
node_modules/ node_modules/
dist/
.env .env
*.db *.db
*.db-journal *.db-journal
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
prisma/dev.db* prisma/dev.db*
*.tsbuildinfo

View File

@ -1,19 +1,26 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals'; import globals from 'globals';
export default [ export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'prisma/**'],
},
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended,
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2023, ecmaVersion: 2023,
sourceType: 'commonjs', sourceType: 'module',
globals: { globals: {
...globals.node, ...globals.node,
}, },
}, },
rules: { rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off', 'no-console': 'off',
}, },
}, }
]; );

3751
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,17 @@
{ {
"name": "recipe-app-backend", "name": "recipe-app-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Backend pour application de recettes", "description": "Backend Freedge (TypeScript)",
"main": "src/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node dist/server.js",
"dev": "nodemon src/server.js", "dev": "tsx watch src/server.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"migrate": "prisma migrate dev", "migrate": "prisma migrate dev",
"studio": "prisma studio", "studio": "prisma studio",
"lint": "eslint src", "lint": "eslint src",
"format": "prettier --write \"src/**/*.js\"" "format": "prettier --write \"src/**/*.{ts,json}\""
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@ -31,10 +33,15 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.21.0",
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.13.9",
"@types/nodemailer": "^6.4.17",
"eslint": "^9.21.0", "eslint": "^9.21.0",
"globals": "^15.15.0", "globals": "^15.15.0",
"nodemon": "^3.0.1",
"prettier": "^3.3.0", "prettier": "^3.3.0",
"prisma": "^5.0.0" "prisma": "^5.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.24.1"
} }
} }

1739
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,179 +0,0 @@
const fp = require('fastify-plugin');
const { OpenAI } = require('openai');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { pipeline } = require('stream/promises');
const { Readable } = require('stream');
const { uploadFile, getFileUrl } = require('../utils/storage');
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
module.exports = fp(async function (fastify, opts) {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
fastify.decorate('openai', openai);
const bufferToStream = (buffer) => Readable.from(buffer);
const downloadToTemp = async (url, extension = '.tmp') => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Échec du téléchargement: ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`);
fs.writeFileSync(tempFilePath, buffer);
return {
path: tempFilePath,
buffer,
cleanup: () => {
try {
fs.unlinkSync(tempFilePath);
} catch (err) {
fastify.log.warn(`Suppression temp échouée: ${err.message}`);
}
},
};
};
// --- Transcription audio ---
fastify.decorate('transcribeAudio', async (audioInput) => {
let tempFile = null;
let audioPath = null;
try {
if (typeof audioInput === 'string') {
audioPath = audioInput;
} else if (audioInput && audioInput.url) {
tempFile = await downloadToTemp(audioInput.url, '.mp3');
audioPath = tempFile.path;
} else if (audioInput && audioInput.localPath) {
audioPath = audioInput.localPath;
} else {
throw new Error("Format d'entrée audio non valide");
}
const transcription = await openai.audio.transcriptions.create({
file: fs.createReadStream(audioPath),
model: 'whisper-1',
});
return transcription.text;
} catch (error) {
fastify.log.error(`Erreur transcription audio: ${error.message}`);
throw error;
} finally {
if (tempFile) tempFile.cleanup();
}
});
// --- Génération d'image (best-effort, isolée) ---
async function generateRecipeImage(title) {
if (!ENABLE_IMAGE_GENERATION) return null;
try {
const imageResponse = await openai.images.generate({
model: 'dall-e-3',
prompt: `Une photo culinaire professionnelle et appétissante du plat "${title}", éclairage studio, style gastronomie.`,
n: 1,
size: '1024x1024',
});
const imageUrl = imageResponse.data[0].url;
const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`Téléchargement image: ${response.statusText}`);
const imageBuffer = Buffer.from(await response.arrayBuffer());
const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
const fileName = `${sanitizedTitle}-${Date.now()}.jpg`;
try {
const filePath = await uploadFile(
{ filename: fileName, file: bufferToStream(imageBuffer) },
'recipes'
);
return await getFileUrl(filePath);
} catch (storageErr) {
fastify.log.warn(`Upload image vers MinIO échoué: ${storageErr.message}`);
// Fallback: on retourne null plutôt que de faire échouer toute la génération
return null;
}
} catch (err) {
fastify.log.warn(`Génération image DALL-E échouée: ${err.message}`);
return null;
}
}
// --- Génération de recette ---
fastify.decorate('generateRecipe', async (ingredients, prompt) => {
const completion = await openai.chat.completions.create({
model: process.env.OPENAI_TEXT_MODEL || 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
"Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser. Tu dois toujours répondre avec un objet JSON valide contenant les champs suivants: titre, ingredients, etapes, temps_preparation (en minutes), temps_cuisson (en minutes), portions, difficulte (facile, moyen, difficile), et conseils.",
},
{
role: 'user',
content: `Voici les ingrédients disponibles: ${ingredients}. ${
prompt || 'Propose une recette avec ces ingrédients.'
} Réponds uniquement avec un objet JSON.`,
},
],
response_format: { type: 'json_object' },
});
let recipeData;
try {
recipeData = JSON.parse(completion.choices[0].message.content);
} catch (err) {
fastify.log.error(`Réponse OpenAI non-JSON: ${err.message}`);
throw new Error('La génération de recette a retourné un format invalide');
}
// Image en best-effort — n'échoue jamais la création de recette
recipeData.image_url = await generateRecipeImage(recipeData.titre || 'recette');
return recipeData;
});
// --- Sauvegarde fichier audio ---
fastify.decorate('saveAudioFile', async (file) => {
if (!file || !file.filename) {
throw new Error('Fichier audio invalide');
}
const fileName = `${Date.now()}-${file.filename}`;
// Tenter MinIO
try {
const filePath = await uploadFile(
{ filename: fileName, file: file.file || bufferToStream(file) },
'audio'
);
const url = await getFileUrl(filePath);
return { success: true, url, path: filePath };
} catch (err) {
fastify.log.warn(`Upload MinIO échoué, fallback local: ${err.message}`);
}
// Fallback local
const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filepath = `${uploadDir}/${fileName}`;
if (Buffer.isBuffer(file.file)) {
fs.writeFileSync(filepath, file.file);
} else if (file.file && typeof file.file.pipe === 'function') {
await pipeline(file.file, fs.createWriteStream(filepath));
} else if (Buffer.isBuffer(file)) {
fs.writeFileSync(filepath, file);
} else {
throw new Error('Format de fichier non pris en charge');
}
return { success: true, localPath: filepath, isLocal: true };
});
});

186
backend/src/plugins/ai.ts Normal file
View File

@ -0,0 +1,186 @@
import fp from 'fastify-plugin';
import { OpenAI } from 'openai';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import type { FastifyInstance } from 'fastify';
import type { MultipartFile } from '@fastify/multipart';
import { uploadFile, getFileUrl } from '../utils/storage';
import type { AudioInput, AudioSaveResult, RecipeData } from '../types/fastify';
const ENABLE_IMAGE_GENERATION = process.env.ENABLE_IMAGE_GENERATION !== 'false';
interface TempFile {
path: string;
buffer: Buffer;
cleanup: () => void;
}
export default fp(async function aiPlugin(fastify: FastifyInstance) {
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
fastify.decorate('openai', openai);
const bufferToStream = (buffer: Buffer): Readable => Readable.from(buffer);
const downloadToTemp = async (url: string, extension = '.tmp'): Promise<TempFile> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Échec du téléchargement: ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const tempFilePath = path.join(os.tmpdir(), `openai-${Date.now()}${extension}`);
fs.writeFileSync(tempFilePath, buffer);
return {
path: tempFilePath,
buffer,
cleanup: () => {
try {
fs.unlinkSync(tempFilePath);
} catch (err) {
fastify.log.warn(`Suppression temp échouée: ${(err as Error).message}`);
}
},
};
};
// --- Transcription audio ---
fastify.decorate('transcribeAudio', async (audioInput: AudioInput): Promise<string> => {
let tempFile: TempFile | null = null;
let audioPath: string | null = null;
try {
if (typeof audioInput === 'string') {
audioPath = audioInput;
} else if (audioInput && 'url' in audioInput && audioInput.url) {
tempFile = await downloadToTemp(audioInput.url, '.mp3');
audioPath = tempFile.path;
} else if (audioInput && 'localPath' in audioInput && audioInput.localPath) {
audioPath = audioInput.localPath;
} else {
throw new Error("Format d'entrée audio non valide");
}
const transcription = await openai.audio.transcriptions.create({
file: fs.createReadStream(audioPath),
model: 'whisper-1',
});
return transcription.text;
} catch (error) {
fastify.log.error(`Erreur transcription audio: ${(error as Error).message}`);
throw error;
} finally {
if (tempFile) tempFile.cleanup();
}
});
// --- Génération image (best-effort, isolée) ---
async function generateRecipeImage(title: string): Promise<string | null> {
if (!ENABLE_IMAGE_GENERATION) return null;
try {
const imageResponse = await openai.images.generate({
model: 'dall-e-3',
prompt: `Une photo culinaire professionnelle et appétissante du plat "${title}", éclairage studio, style gastronomie.`,
n: 1,
size: '1024x1024',
});
const imageUrl = imageResponse.data?.[0]?.url;
if (!imageUrl) return null;
const response = await fetch(imageUrl);
if (!response.ok) throw new Error(`Téléchargement image: ${response.statusText}`);
const imageBuffer = Buffer.from(await response.arrayBuffer());
const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
const fileName = `${sanitizedTitle}-${Date.now()}.jpg`;
try {
const filePath = await uploadFile(
{ filename: fileName, file: bufferToStream(imageBuffer) },
'recipes'
);
return await getFileUrl(filePath);
} catch (storageErr) {
fastify.log.warn(`Upload image MinIO échoué: ${(storageErr as Error).message}`);
return null;
}
} catch (err) {
fastify.log.warn(`Génération image DALL-E échouée: ${(err as Error).message}`);
return null;
}
}
// --- Génération de recette ---
fastify.decorate(
'generateRecipe',
async (ingredients: string, prompt?: string): Promise<RecipeData> => {
const completion = await openai.chat.completions.create({
model: process.env.OPENAI_TEXT_MODEL || 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
"Tu es un chef cuisinier expert qui crée des recettes délicieuses et faciles à réaliser. Tu dois toujours répondre avec un objet JSON valide contenant les champs suivants: titre, ingredients, etapes, temps_preparation (en minutes), temps_cuisson (en minutes), portions, difficulte (facile, moyen, difficile), et conseils.",
},
{
role: 'user',
content: `Voici les ingrédients disponibles: ${ingredients}. ${
prompt || 'Propose une recette avec ces ingrédients.'
} Réponds uniquement avec un objet JSON.`,
},
],
response_format: { type: 'json_object' },
});
const raw = completion.choices[0]?.message.content;
if (!raw) throw new Error('Réponse OpenAI vide');
let recipeData: RecipeData;
try {
recipeData = JSON.parse(raw) as RecipeData;
} catch (err) {
fastify.log.error(`Réponse OpenAI non-JSON: ${(err as Error).message}`);
throw new Error('La génération de recette a retourné un format invalide');
}
recipeData.image_url = await generateRecipeImage(recipeData.titre || 'recette');
return recipeData;
}
);
// --- Sauvegarde fichier audio ---
fastify.decorate('saveAudioFile', async (file: MultipartFile): Promise<AudioSaveResult> => {
if (!file || !file.filename) {
throw new Error('Fichier audio invalide');
}
const fileName = `${Date.now()}-${file.filename}`;
// Tentative MinIO
try {
const filePath = await uploadFile({ filename: fileName, file: file.file }, 'audio');
const url = await getFileUrl(filePath);
return { success: true, url, path: filePath };
} catch (err) {
fastify.log.warn(`Upload MinIO échoué, fallback local: ${(err as Error).message}`);
}
// Fallback local
const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filepath = `${uploadDir}/${fileName}`;
if (file.file && typeof file.file.pipe === 'function') {
await pipeline(file.file, fs.createWriteStream(filepath));
} else {
throw new Error('Format de fichier non pris en charge');
}
return { success: true, localPath: filepath, isLocal: true };
});
});

View File

@ -1,26 +0,0 @@
const fp = require('fastify-plugin');
const jwt = require('@fastify/jwt');
const bcrypt = require('bcrypt');
module.exports = fp(async function (fastify, opts) {
fastify.register(jwt, {
secret: process.env.JWT_SECRET
});
fastify.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Non authentifié' });
}
});
fastify.decorate('hashPassword', async (password) => {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
});
fastify.decorate('comparePassword', async (password, hash) => {
return bcrypt.compare(password, hash);
});
});

View File

@ -0,0 +1,26 @@
import fp from 'fastify-plugin';
import jwt from '@fastify/jwt';
import bcrypt from 'bcrypt';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
const SALT_ROUNDS = 10;
export default fp(async function authPlugin(fastify: FastifyInstance) {
await fastify.register(jwt, {
secret: process.env.JWT_SECRET!,
});
fastify.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: 'Non authentifié' });
}
});
fastify.decorate('hashPassword', (password: string) => bcrypt.hash(password, SALT_ROUNDS));
fastify.decorate('comparePassword', (password: string, hash: string) =>
bcrypt.compare(password, hash)
);
});

View File

@ -1,8 +0,0 @@
const fp = require('fastify-plugin');
const { OAuth2Client } = require('google-auth-library');
module.exports = fp(async function (fastify, opts) {
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
fastify.decorate('googleClient', googleClient);
});

View File

@ -0,0 +1,8 @@
import fp from 'fastify-plugin';
import { OAuth2Client } from 'google-auth-library';
import type { FastifyInstance } from 'fastify';
export default fp(async function googleAuthPlugin(fastify: FastifyInstance) {
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
fastify.decorate('googleClient', googleClient);
});

View File

@ -1,24 +0,0 @@
const fp = require('fastify-plugin');
const Stripe = require('stripe');
module.exports = fp(async function (fastify, opts) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
fastify.decorate('stripe', stripe);
fastify.decorate('createCustomer', async (email, name) => {
return stripe.customers.create({
email,
name
});
});
fastify.decorate('createSubscription', async (customerId, priceId) => {
return stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
});
});

View File

@ -0,0 +1,28 @@
import fp from 'fastify-plugin';
import Stripe from 'stripe';
import type { FastifyInstance } from 'fastify';
export default fp(async function stripePlugin(fastify: FastifyInstance) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
fastify.log.warn('STRIPE_SECRET_KEY non définie — le plugin Stripe est désactivé');
return;
}
const stripe = new Stripe(key);
fastify.decorate('stripe', stripe);
fastify.decorate('createCustomer', (email: string, name: string) =>
stripe.customers.create({ email, name })
);
fastify.decorate('createSubscription', (customerId: string, priceId: string) =>
stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
})
);
});

View File

@ -1,182 +0,0 @@
module.exports = async function (fastify, opts) {
// Inscription
fastify.post('/register', {
schema: {
body: {
type: 'object',
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 },
name: { type: 'string' }
}
}
}
}, async (request, reply) => {
const { email, password, name } = request.body;
try {
// Vérifier si l'utilisateur existe déjà
const existingUser = await fastify.prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
}
// Créer un client Stripe
const customer = await fastify.createCustomer(email, name);
// Hacher le mot de passe
const hashedPassword = await fastify.hashPassword(password);
// Créer l'utilisateur
const user = await fastify.prisma.user.create({
data: {
email,
password: hashedPassword,
name,
stripeId: customer.id
}
});
// Générer un token JWT
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription
},
token
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de l\'inscription' });
}
});
// Connexion
fastify.post('/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' }
}
}
}
}, async (request, reply) => {
const { email, password } = request.body;
try {
// Trouver l'utilisateur
const user = await fastify.prisma.user.findUnique({
where: { email }
});
if (!user) {
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
}
// Vérifier le mot de passe
const passwordMatch = await fastify.comparePassword(password, user.password);
if (!passwordMatch) {
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
}
// Générer un token JWT
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription
},
token
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la connexion' });
}
});
fastify.post('/google-auth', {
schema: {
body: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' }
}
}
}
}, async (request, reply) => {
const { token } = request.body;
try {
// Utiliser le token d'accès pour obtenir les informations utilisateur
// au lieu d'essayer de le vérifier comme un ID token
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Impossible de récupérer les informations utilisateur');
}
const userData = await response.json();
const { email, name, sub: googleId } = userData;
// Vérifier si l'utilisateur existe déjà
let user = await fastify.prisma.user.findUnique({
where: { email }
});
if (!user) {
// Créer un client Stripe
const customer = await fastify.createCustomer(email, name);
// Créer l'utilisateur
user = await fastify.prisma.user.create({
data: {
email,
name,
googleId,
stripeId: customer.id
}
});
} else if (!user.googleId) {
// Mettre à jour l'utilisateur existant avec l'ID Google
user = await fastify.prisma.user.update({
where: { id: user.id },
data: { googleId }
});
}
// Générer un token JWT
const jwtToken = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription
},
token: jwtToken
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de l\'authentification Google' });
}
});
};

201
backend/src/routes/auth.ts Normal file
View File

@ -0,0 +1,201 @@
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
interface RegisterBody {
email: string;
password: string;
name: string;
}
interface LoginBody {
email: string;
password: string;
}
interface GoogleAuthBody {
token: string;
}
interface GoogleUserInfo {
email: string;
name: string;
sub: string;
}
const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
// --- Inscription ---
fastify.post<{ Body: RegisterBody }>(
'/register',
{
schema: {
body: {
type: 'object',
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 6 },
name: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { email, password, name } = request.body;
try {
const existingUser = await fastify.prisma.user.findUnique({ where: { email } });
if (existingUser) {
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
}
// Créer un client Stripe (le plugin peut être désactivé)
let stripeId = '';
if (fastify.stripe) {
const customer = await fastify.createCustomer(email, name);
stripeId = customer.id;
}
const hashedPassword = await fastify.hashPassword(password);
const user = await fastify.prisma.user.create({
data: {
email,
password: hashedPassword,
name,
stripeId,
},
});
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription,
},
token,
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: "Erreur lors de l'inscription" });
}
}
);
// --- Connexion ---
fastify.post<{ Body: LoginBody }>(
'/login',
{
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { email, password } = request.body;
try {
const user = await fastify.prisma.user.findUnique({ where: { email } });
if (!user || !user.password) {
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
}
const passwordMatch = await fastify.comparePassword(password, user.password);
if (!passwordMatch) {
return reply.code(401).send({ error: 'Email ou mot de passe incorrect' });
}
const token = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription,
},
token,
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la connexion' });
}
}
);
// --- Google OAuth ---
fastify.post<{ Body: GoogleAuthBody }>(
'/google-auth',
{
schema: {
body: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
async (request, reply) => {
const { token } = request.body;
try {
const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error('Impossible de récupérer les informations utilisateur');
}
const userData = (await response.json()) as GoogleUserInfo;
const { email, name, sub: googleId } = userData;
let user = await fastify.prisma.user.findUnique({ where: { email } });
if (!user) {
let stripeId = '';
if (fastify.stripe) {
const customer = await fastify.createCustomer(email, name);
stripeId = customer.id;
}
user = await fastify.prisma.user.create({
data: { email, name, googleId, stripeId },
});
} else if (!user.googleId) {
user = await fastify.prisma.user.update({
where: { id: user.id },
data: { googleId },
});
}
const jwtToken = fastify.jwt.sign({ id: user.id }, { expiresIn: '7d' });
return {
user: {
id: user.id,
email: user.email,
name: user.name,
subscription: user.subscription,
},
token: jwtToken,
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: "Erreur lors de l'authentification Google" });
}
}
);
};
export default authRoutes;

View File

@ -1,29 +1,28 @@
const fs = require('fs'); import * as fs from 'node:fs';
const multipart = require('@fastify/multipart'); import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
const { deleteFile } = require('../utils/storage'); import multipart from '@fastify/multipart';
import { deleteFile } from '../utils/storage';
const FREE_PLAN_LIMIT = 5; const FREE_PLAN_LIMIT = 5;
module.exports = async function (fastify, opts) { const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
fastify.register(multipart, { await fastify.register(multipart, {
limits: { limits: {
fileSize: 15 * 1024 * 1024, // 15 MB max pour un upload audio fileSize: 15 * 1024 * 1024, // 15 MB
files: 1, files: 1,
}, },
}); });
fastify.addHook('preHandler', fastify.authenticate); fastify.addHook('preHandler', fastify.authenticate);
// Créer une recette // --- POST /create ---
fastify.post('/create', async (request, reply) => { fastify.post('/create', async (request, reply) => {
try { try {
const data = await request.file(); const data = await request.file();
if (!data) { if (!data) {
return reply.code(400).send({ error: 'Fichier audio requis' }); return reply.code(400).send({ error: 'Fichier audio requis' });
} }
// Vérifier le plan de l'utilisateur (et compter atomiquement)
const [user, recipeCount] = await Promise.all([ const [user, recipeCount] = await Promise.all([
fastify.prisma.user.findUnique({ where: { id: request.user.id } }), fastify.prisma.user.findUnique({ where: { id: request.user.id } }),
fastify.prisma.recipe.count({ where: { userId: request.user.id } }), fastify.prisma.recipe.count({ where: { userId: request.user.id } }),
@ -40,21 +39,17 @@ module.exports = async function (fastify, opts) {
}); });
} }
// Sauvegarder le fichier audio (Minio si dispo, sinon fallback local)
const audioResult = await fastify.saveAudioFile(data); const audioResult = await fastify.saveAudioFile(data);
const audioUrl = audioResult.url || audioResult.localPath || null; const audioUrl = audioResult.url || audioResult.localPath || null;
const audioStoragePath = audioResult.path || null; // chemin Minio si applicable const audioStoragePath = audioResult.path ?? null;
// Transcrire l'audio
const transcription = await fastify.transcribeAudio(audioResult); const transcription = await fastify.transcribeAudio(audioResult);
// Générer la recette (image gérée en best-effort, cf. plugin ai)
const recipeData = await fastify.generateRecipe( const recipeData = await fastify.generateRecipe(
transcription, transcription,
'Crée une recette délicieuse et détaillée' 'Crée une recette délicieuse et détaillée'
); );
// Normaliser les tableaux en chaînes
const ingredientsString = Array.isArray(recipeData.ingredients) const ingredientsString = Array.isArray(recipeData.ingredients)
? recipeData.ingredients.join('\n') ? recipeData.ingredients.join('\n')
: recipeData.ingredients || ''; : recipeData.ingredients || '';
@ -69,13 +64,13 @@ module.exports = async function (fastify, opts) {
ingredients: ingredientsString, ingredients: ingredientsString,
userPrompt: transcription, userPrompt: transcription,
generatedRecipe: JSON.stringify(recipeData), generatedRecipe: JSON.stringify(recipeData),
imageUrl: recipeData.image_url || null, imageUrl: recipeData.image_url ?? null,
preparationTime: recipeData.temps_preparation || null, preparationTime: recipeData.temps_preparation ?? null,
cookingTime: recipeData.temps_cuisson || null, cookingTime: recipeData.temps_cuisson ?? null,
servings: recipeData.portions || null, servings: recipeData.portions ?? null,
difficulty: recipeData.difficulte || null, difficulty: recipeData.difficulte ?? null,
steps: stepsString, steps: stepsString,
tips: recipeData.conseils || null, tips: recipeData.conseils ?? null,
audioUrl: audioStoragePath || audioUrl, audioUrl: audioStoragePath || audioUrl,
userId: request.user.id, userId: request.user.id,
}, },
@ -93,7 +88,7 @@ module.exports = async function (fastify, opts) {
steps: recipe.steps, steps: recipe.steps,
tips: recipe.tips, tips: recipe.tips,
imageUrl: recipe.imageUrl, imageUrl: recipe.imageUrl,
audioUrl: audioUrl, audioUrl,
createdAt: recipe.createdAt, createdAt: recipe.createdAt,
}, },
}; };
@ -103,7 +98,7 @@ module.exports = async function (fastify, opts) {
} }
}); });
// Lister les recettes // --- GET /list ---
fastify.get('/list', async (request, reply) => { fastify.get('/list', async (request, reply) => {
try { try {
const recipes = await fastify.prisma.recipe.findMany({ const recipes = await fastify.prisma.recipe.findMany({
@ -129,10 +124,9 @@ module.exports = async function (fastify, opts) {
} }
}); });
// Récupérer une recette par ID // --- GET /:id ---
fastify.get('/:id', async (request, reply) => { fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
try { try {
// findFirst car le where composite (id + userId) n'est pas unique côté Prisma
const recipe = await fastify.prisma.recipe.findFirst({ const recipe = await fastify.prisma.recipe.findFirst({
where: { where: {
id: request.params.id, id: request.params.id,
@ -144,12 +138,14 @@ module.exports = async function (fastify, opts) {
return reply.code(404).send({ error: 'Recette non trouvée' }); return reply.code(404).send({ error: 'Recette non trouvée' });
} }
let parsed = null; let parsed: unknown = null;
if (recipe.generatedRecipe) { if (recipe.generatedRecipe) {
try { try {
parsed = JSON.parse(recipe.generatedRecipe); parsed = JSON.parse(recipe.generatedRecipe);
} catch (err) { } catch (err) {
fastify.log.warn(`generatedRecipe corrompu pour ${recipe.id}: ${err.message}`); fastify.log.warn(
`generatedRecipe corrompu pour ${recipe.id}: ${(err as Error).message}`
);
} }
} }
@ -160,8 +156,8 @@ module.exports = async function (fastify, opts) {
} }
}); });
// Supprimer une recette // --- DELETE /:id ---
fastify.delete('/:id', async (request, reply) => { fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
try { try {
const recipe = await fastify.prisma.recipe.findUnique({ const recipe = await fastify.prisma.recipe.findUnique({
where: { id: request.params.id }, where: { id: request.params.id },
@ -177,19 +173,18 @@ module.exports = async function (fastify, opts) {
await fastify.prisma.recipe.delete({ where: { id: request.params.id } }); await fastify.prisma.recipe.delete({ where: { id: request.params.id } });
// Best-effort: supprimer le fichier audio associé // Nettoyage best-effort du fichier audio
if (recipe.audioUrl) { if (recipe.audioUrl) {
try { try {
if (recipe.audioUrl.startsWith('http')) { if (recipe.audioUrl.startsWith('http')) {
// URL Minio — on ne peut rien faire sans stocker la clé. À refactorer // URL presignée — ne contient pas d'info de clé fiable, skip
// quand audioUrl stockera explicitement le path plutôt que l'URL.
} else if (recipe.audioUrl.includes('/') && !recipe.audioUrl.startsWith('./')) { } else if (recipe.audioUrl.includes('/') && !recipe.audioUrl.startsWith('./')) {
await deleteFile(recipe.audioUrl).catch(() => {}); await deleteFile(recipe.audioUrl).catch(() => {});
} else if (fs.existsSync(recipe.audioUrl)) { } else if (fs.existsSync(recipe.audioUrl)) {
fs.unlinkSync(recipe.audioUrl); fs.unlinkSync(recipe.audioUrl);
} }
} catch (cleanupErr) { } catch (cleanupErr) {
fastify.log.warn(`Nettoyage audio échoué: ${cleanupErr.message}`); fastify.log.warn(`Nettoyage audio échoué: ${(cleanupErr as Error).message}`);
} }
} }
@ -200,3 +195,5 @@ module.exports = async function (fastify, opts) {
} }
}); });
}; };
export default recipesRoutes;

View File

@ -1,320 +0,0 @@
const crypto = require('node:crypto');
const { sendEmail } = require('../utils/email');
module.exports = async function (fastify, opts) {
// Middleware d'authentification pour les routes protégées
const authenticateUser = async (request, reply) => {
try {
await fastify.authenticate(request, reply);
} catch (err) {
reply.code(401).send({ error: 'Authentification requise' });
}
};
// Récupérer le profil utilisateur
fastify.get('/profile', { preHandler: authenticateUser }, async (request, reply) => {
try {
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id },
select: {
id: true,
email: true,
name: true,
subscription: true,
createdAt: true
}
});
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
return { user };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la récupération du profil' });
}
});
// Mettre à jour le profil utilisateur
fastify.put('/profile', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { name } = request.body;
if (!name) {
return reply.code(400).send({ error: 'Le nom est requis' });
}
const updatedUser = await fastify.prisma.user.update({
where: { id: request.user.id },
data: { name },
select: {
id: true,
email: true,
name: true,
subscription: true
}
});
return { user: updatedUser };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la mise à jour du profil' });
}
});
// Changer le mot de passe (utilisateur connecté)
fastify.put('/change-password', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { currentPassword, newPassword } = request.body;
if (!currentPassword || !newPassword) {
return reply.code(400).send({ error: 'Les mots de passe actuels et nouveaux sont requis' });
}
if (newPassword.length < 8) {
return reply.code(400).send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
// Vérifier l'utilisateur et son mot de passe actuel
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user || !user.password) {
return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await fastify.comparePassword(currentPassword, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe actuel incorrect' });
}
// Hasher et mettre à jour le nouveau mot de passe
const hashedPassword = await fastify.hashPassword(newPassword);
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { password: hashedPassword }
});
return { success: true, message: 'Mot de passe mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors du changement de mot de passe' });
}
});
// Changer l'email (utilisateur connecté)
fastify.put('/change-email', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { newEmail, password } = request.body;
if (!newEmail || !password) {
return reply.code(400).send({ error: 'Le nouvel email et le mot de passe sont requis' });
}
// Vérifier si l'email est déjà utilisé
const existingUser = await fastify.prisma.user.findUnique({
where: { email: newEmail }
});
if (existingUser) {
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
}
// Vérifier l'utilisateur et son mot de passe
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user || !user.password) {
return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await fastify.comparePassword(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
// Mettre à jour l'email
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { email: newEmail }
});
return { success: true, message: 'Email mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors du changement d\'email' });
}
});
// Demande de réinitialisation de mot de passe (utilisateur non connecté)
fastify.post('/forgot-password', async (request, reply) => {
try {
const { email } = request.body;
if (!email) {
return reply.code(400).send({ error: 'Email requis' });
}
// Vérifier si l'utilisateur existe
const user = await fastify.prisma.user.findUnique({
where: { email }
});
if (!user) {
// Pour des raisons de sécurité, ne pas indiquer si l'email existe ou non
return reply.code(200).send({
success: true,
message: 'Si un compte existe avec cet email, un lien de réinitialisation a été envoyé'
});
}
// Générer un token de réinitialisation
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 heure
// Stocker le token dans la base de données
await fastify.prisma.user.update({
where: { id: user.id },
data: {
resetToken,
resetTokenExpiry
}
});
// Construire l'URL de réinitialisation
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?token=${resetToken}`;
try {
// Envoyer l'email avec Resend
await sendEmail({
to: user.email,
subject: 'Réinitialisation de votre mot de passe',
text: `Pour réinitialiser votre mot de passe, cliquez sur ce lien: ${resetUrl}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
<h2 style="color: #f97316;">Réinitialisation de votre mot de passe</h2>
<p>Bonjour,</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #f97316; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<p>Ce lien expirera dans 1 heure.</p>
<p>Cordialement,<br>L'équipe Freedge</p>
</div>
`
});
fastify.log.info(`Email de réinitialisation envoyé à ${user.email}`);
} catch (emailError) {
// Journaliser l'erreur mais ne pas échouer la requête
fastify.log.error(`Erreur d'envoi d'email: ${emailError.message}`);
// En mode développement, afficher le lien dans les logs pour faciliter les tests
if (process.env.NODE_ENV === 'development') {
fastify.log.info(`Lien de réinitialisation (DEV ONLY): ${resetUrl}`);
}
}
return {
success: true,
message: 'Si un compte existe avec cet email, un lien de réinitialisation a été envoyé'
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la demande de réinitialisation de mot de passe' });
}
});
// Réinitialiser le mot de passe avec un token (utilisateur non connecté)
fastify.post('/reset-password', async (request, reply) => {
try {
const { token, newPassword } = request.body;
if (!token || !newPassword) {
return reply.code(400).send({ error: 'Token et nouveau mot de passe requis' });
}
if (newPassword.length < 8) {
return reply.code(400).send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
// Trouver l'utilisateur avec ce token
const user = await fastify.prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date()
}
}
});
if (!user) {
return reply.code(400).send({ error: 'Token invalide ou expiré' });
}
// Hasher et mettre à jour le nouveau mot de passe
const hashedPassword = await fastify.hashPassword(newPassword);
await fastify.prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
}
});
return { success: true, message: 'Mot de passe réinitialisé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la réinitialisation du mot de passe' });
}
});
// Supprimer le compte utilisateur
fastify.delete('/account', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { password } = request.body;
// Vérifier l'utilisateur et son mot de passe
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
// Si l'utilisateur a un mot de passe (pas connecté via Google), vérifier le mot de passe
if (user.password) {
if (!password) {
return reply.code(400).send({ error: 'Mot de passe requis' });
}
const isPasswordValid = await fastify.comparePassword(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
}
// Supprimer toutes les recettes de l'utilisateur
await fastify.prisma.recipe.deleteMany({
where: { userId: user.id }
});
// Supprimer l'utilisateur
await fastify.prisma.user.delete({
where: { id: user.id }
});
return { success: true, message: 'Compte supprimé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la suppression du compte' });
}
});
};

333
backend/src/routes/users.ts Normal file
View File

@ -0,0 +1,333 @@
import * as crypto from 'node:crypto';
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import { sendEmail } from '../utils/email';
interface UpdateProfileBody {
name: string;
}
interface ChangePasswordBody {
currentPassword: string;
newPassword: string;
}
interface ChangeEmailBody {
newEmail: string;
password: string;
}
interface ForgotPasswordBody {
email: string;
}
interface ResetPasswordBody {
token: string;
newPassword: string;
}
interface DeleteAccountBody {
password?: string;
}
const usersRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
const authenticateUser = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await fastify.authenticate(request, reply);
} catch {
reply.code(401).send({ error: 'Authentification requise' });
}
};
// --- GET /profile ---
fastify.get('/profile', { preHandler: authenticateUser }, async (request, reply) => {
try {
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id },
select: {
id: true,
email: true,
name: true,
subscription: true,
createdAt: true,
},
});
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
return { user };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la récupération du profil' });
}
});
// --- PUT /profile ---
fastify.put<{ Body: UpdateProfileBody }>(
'/profile',
{ preHandler: authenticateUser },
async (request, reply) => {
try {
const { name } = request.body;
if (!name) {
return reply.code(400).send({ error: 'Le nom est requis' });
}
const updatedUser = await fastify.prisma.user.update({
where: { id: request.user.id },
data: { name },
select: { id: true, email: true, name: true, subscription: true },
});
return { user: updatedUser };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la mise à jour du profil' });
}
}
);
// --- PUT /change-password ---
fastify.put<{ Body: ChangePasswordBody }>(
'/change-password',
{ preHandler: authenticateUser },
async (request, reply) => {
try {
const { currentPassword, newPassword } = request.body;
if (!currentPassword || !newPassword) {
return reply
.code(400)
.send({ error: 'Les mots de passe actuels et nouveaux sont requis' });
}
if (newPassword.length < 8) {
return reply
.code(400)
.send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id },
});
if (!user || !user.password) {
return reply
.code(400)
.send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await fastify.comparePassword(currentPassword, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe actuel incorrect' });
}
const hashedPassword = await fastify.hashPassword(newPassword);
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { password: hashedPassword },
});
return { success: true, message: 'Mot de passe mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors du changement de mot de passe' });
}
}
);
// --- PUT /change-email ---
fastify.put<{ Body: ChangeEmailBody }>(
'/change-email',
{ preHandler: authenticateUser },
async (request, reply) => {
try {
const { newEmail, password } = request.body;
if (!newEmail || !password) {
return reply
.code(400)
.send({ error: 'Le nouvel email et le mot de passe sont requis' });
}
const existingUser = await fastify.prisma.user.findUnique({ where: { email: newEmail } });
if (existingUser) {
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
}
const user = await fastify.prisma.user.findUnique({ where: { id: request.user.id } });
if (!user || !user.password) {
return reply
.code(400)
.send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await fastify.comparePassword(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { email: newEmail },
});
return { success: true, message: 'Email mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: "Erreur lors du changement d'email" });
}
}
);
// --- POST /forgot-password ---
fastify.post<{ Body: ForgotPasswordBody }>('/forgot-password', async (request, reply) => {
try {
const { email } = request.body;
if (!email) {
return reply.code(400).send({ error: 'Email requis' });
}
const user = await fastify.prisma.user.findUnique({ where: { email } });
// Réponse générique pour éviter l'énumération d'utilisateurs
const genericResponse = {
success: true,
message: 'Si un compte existe avec cet email, un lien de réinitialisation a été envoyé',
};
if (!user) {
return reply.code(200).send(genericResponse);
}
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1h
await fastify.prisma.user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
});
const resetUrl = `${
process.env.FRONTEND_URL || 'http://localhost:5173'
}/reset-password?token=${resetToken}`;
try {
await sendEmail({
to: user.email,
subject: 'Réinitialisation de votre mot de passe',
text: `Pour réinitialiser votre mot de passe, cliquez sur ce lien: ${resetUrl}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 5px;">
<h2 style="color: #f97316;">Réinitialisation de votre mot de passe</h2>
<p>Bonjour,</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #f97316; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Réinitialiser mon mot de passe</a>
</div>
<p>Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
<p>Ce lien expirera dans 1 heure.</p>
<p>Cordialement,<br>L'équipe Freedge</p>
</div>
`,
});
fastify.log.info(`Email de réinitialisation envoyé à ${user.email}`);
} catch (emailError) {
fastify.log.error(`Erreur d'envoi d'email: ${(emailError as Error).message}`);
if (process.env.NODE_ENV === 'development') {
fastify.log.info(`Lien de réinitialisation (DEV ONLY): ${resetUrl}`);
}
}
return genericResponse;
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({
error: 'Erreur lors de la demande de réinitialisation de mot de passe',
});
}
});
// --- POST /reset-password ---
fastify.post<{ Body: ResetPasswordBody }>('/reset-password', async (request, reply) => {
try {
const { token, newPassword } = request.body;
if (!token || !newPassword) {
return reply.code(400).send({ error: 'Token et nouveau mot de passe requis' });
}
if (newPassword.length < 8) {
return reply
.code(400)
.send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
const user = await fastify.prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gt: new Date() },
},
});
if (!user) {
return reply.code(400).send({ error: 'Token invalide ou expiré' });
}
const hashedPassword = await fastify.hashPassword(newPassword);
await fastify.prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null,
},
});
return { success: true, message: 'Mot de passe réinitialisé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la réinitialisation du mot de passe' });
}
});
// --- DELETE /account ---
fastify.delete<{ Body: DeleteAccountBody }>(
'/account',
{ preHandler: authenticateUser },
async (request, reply) => {
try {
const { password } = request.body ?? {};
const user = await fastify.prisma.user.findUnique({ where: { id: request.user.id } });
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
if (user.password) {
if (!password) {
return reply.code(400).send({ error: 'Mot de passe requis' });
}
const isPasswordValid = await fastify.comparePassword(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
}
await fastify.prisma.recipe.deleteMany({ where: { userId: user.id } });
await fastify.prisma.user.delete({ where: { id: user.id } });
return { success: true, message: 'Compte supprimé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la suppression du compte' });
}
}
);
};
export default usersRoutes;

View File

@ -1,88 +0,0 @@
require('dotenv').config();
const { validateEnv } = require('./utils/env');
validateEnv();
const fastify = require('fastify')({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
bodyLimit: 10 * 1024 * 1024, // 10 MB
});
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
// --- Sécurité ---
fastify.register(require('@fastify/helmet'), {
contentSecurityPolicy: false, // laissé au frontend / reverse proxy
});
fastify.register(require('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute',
});
// CORS : whitelist via env, fallback dev
const allowedOrigins = (process.env.CORS_ORIGINS || 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
fastify.register(require('@fastify/cors'), {
origin: (origin, cb) => {
// Autoriser les requêtes sans origine (curl, health checks)
if (!origin) return cb(null, true);
if (allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error(`Origin ${origin} non autorisée`), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
// --- Plugins applicatifs ---
fastify.register(require('./plugins/auth'));
fastify.register(require('./plugins/stripe'));
fastify.register(require('./plugins/ai'));
fastify.register(require('./plugins/google-auth'));
// --- Routes ---
fastify.register(require('./routes/auth'), { prefix: '/auth' });
fastify.register(require('./routes/recipes'), { prefix: '/recipes' });
fastify.register(require('./routes/users'), { prefix: '/users' });
// Healthcheck
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
// Fermeture propre
fastify.addHook('onClose', async (instance, done) => {
await prisma.$disconnect();
done();
});
const shutdown = async (signal) => {
fastify.log.info(`${signal} reçu, arrêt en cours...`);
try {
await fastify.close();
process.exit(0);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
const start = async () => {
try {
const port = Number(process.env.PORT) || 3000;
await fastify.listen({ port, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

102
backend/src/server.ts Normal file
View File

@ -0,0 +1,102 @@
import 'dotenv/config';
import { validateEnv } from './utils/env';
validateEnv();
import Fastify from 'fastify';
import { PrismaClient } from '@prisma/client';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import cors from '@fastify/cors';
import authPlugin from './plugins/auth';
import stripePlugin from './plugins/stripe';
import aiPlugin from './plugins/ai';
import googleAuthPlugin from './plugins/google-auth';
import authRoutes from './routes/auth';
import recipesRoutes from './routes/recipes';
import usersRoutes from './routes/users';
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
bodyLimit: 10 * 1024 * 1024, // 10 MB
});
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
async function bootstrap(): Promise<void> {
// --- Sécurité ---
await fastify.register(helmet, {
contentSecurityPolicy: false,
});
await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
const allowedOrigins = (
process.env.CORS_ORIGINS || 'http://localhost:5173,http://127.0.0.1:5173'
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await fastify.register(cors, {
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error(`Origin ${origin} non autorisée`), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
// --- Plugins applicatifs ---
await fastify.register(authPlugin);
await fastify.register(stripePlugin);
await fastify.register(aiPlugin);
await fastify.register(googleAuthPlugin);
// --- Routes ---
await fastify.register(authRoutes, { prefix: '/auth' });
await fastify.register(recipesRoutes, { prefix: '/recipes' });
await fastify.register(usersRoutes, { prefix: '/users' });
fastify.get('/health', async () => ({ status: 'ok', uptime: process.uptime() }));
fastify.addHook('onClose', async (_instance, done) => {
await prisma.$disconnect();
done();
});
}
const shutdown = async (signal: string): Promise<void> => {
fastify.log.info(`${signal} reçu, arrêt en cours...`);
try {
await fastify.close();
process.exit(0);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
async function start(): Promise<void> {
try {
await bootstrap();
const port = Number(process.env.PORT) || 3000;
await fastify.listen({ port, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
}
void start();

62
backend/src/types/fastify.d.ts vendored Normal file
View File

@ -0,0 +1,62 @@
import type { PrismaClient } from '@prisma/client';
import type { OpenAI } from 'openai';
import type Stripe from 'stripe';
import type { OAuth2Client } from 'google-auth-library';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { MultipartFile } from '@fastify/multipart';
export interface RecipeData {
titre: string;
ingredients: string | string[];
etapes: string | string[];
temps_preparation?: number;
temps_cuisson?: number;
portions?: number;
difficulte?: string;
conseils?: string;
image_url?: string | null;
}
export interface AudioSaveResult {
success: boolean;
url?: string;
path?: string;
localPath?: string;
isLocal?: boolean;
}
export type AudioInput = string | AudioSaveResult;
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
// auth plugin
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
hashPassword: (password: string) => Promise<string>;
comparePassword: (password: string, hash: string) => Promise<boolean>;
// stripe plugin (optionnel: désactivé si STRIPE_SECRET_KEY absent)
stripe?: Stripe;
createCustomer?: (email: string, name: string) => Promise<Stripe.Customer>;
createSubscription?: (
customerId: string,
priceId: string
) => Promise<Stripe.Subscription>;
// ai plugin
openai: OpenAI;
transcribeAudio: (audioInput: AudioInput) => Promise<string>;
generateRecipe: (ingredients: string, prompt?: string) => Promise<RecipeData>;
saveAudioFile: (file: MultipartFile) => Promise<AudioSaveResult>;
// google-auth plugin
googleClient: OAuth2Client;
}
interface FastifyRequest {
user: {
id: string;
};
}
}

View File

@ -1,35 +0,0 @@
const nodemailer = require('nodemailer');
// Créer un transporteur réutilisable avec les informations de configuration SMTP
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_SECURE === 'true', // true pour 465, false pour les autres ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
}
});
// Fonction pour envoyer un email
async function sendEmail({ to, subject, text, html }) {
try {
const info = await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
text,
html
});
console.log('Message sent: %s', info.messageId);
return info;
} catch (error) {
console.error('Erreur lors de l\'envoi de l\'email:', error);
throw error;
}
}
module.exports = {
sendEmail
};

View File

@ -0,0 +1,32 @@
import nodemailer from 'nodemailer';
export interface SendEmailOptions {
to: string;
subject: string;
text: string;
html?: string;
}
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT ?? 587),
secure: process.env.EMAIL_SECURE === 'true',
auth:
process.env.EMAIL_USER && process.env.EMAIL_PASSWORD
? {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
}
: undefined,
});
export async function sendEmail({ to, subject, text, html }: SendEmailOptions): Promise<nodemailer.SentMessageInfo> {
const info = await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
text,
html,
});
return info;
}

View File

@ -1,7 +1,7 @@
// Validation des variables d'environnement au démarrage. // Validation des variables d'environnement au démarrage.
// Échoue vite si une variable critique est manquante. // Échoue vite si une variable critique est manquante.
const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY']; const REQUIRED = ['DATABASE_URL', 'JWT_SECRET', 'OPENAI_API_KEY'] as const;
const OPTIONAL_WARN = [ const OPTIONAL_WARN = [
'STRIPE_SECRET_KEY', 'STRIPE_SECRET_KEY',
@ -12,9 +12,11 @@ const OPTIONAL_WARN = [
'MINIO_BUCKET', 'MINIO_BUCKET',
'RESEND_API_KEY', 'RESEND_API_KEY',
'FRONTEND_URL', 'FRONTEND_URL',
]; ] as const;
function validateEnv(log = console) { type Logger = Pick<Console, 'error' | 'warn'>;
export function validateEnv(log: Logger = console): void {
const missing = REQUIRED.filter((k) => !process.env[k]); const missing = REQUIRED.filter((k) => !process.env[k]);
if (missing.length > 0) { if (missing.length > 0) {
log.error(`Variables d'environnement manquantes: ${missing.join(', ')}`); log.error(`Variables d'environnement manquantes: ${missing.join(', ')}`);
@ -22,7 +24,9 @@ function validateEnv(log = console) {
} }
if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) { if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) {
log.warn('JWT_SECRET fait moins de 32 caractères, utilisez une clé plus longue en production.'); log.warn(
'JWT_SECRET fait moins de 32 caractères, utilisez une clé plus longue en production.'
);
} }
const missingOptional = OPTIONAL_WARN.filter((k) => !process.env[k]); const missingOptional = OPTIONAL_WARN.filter((k) => !process.env[k]);
@ -30,5 +34,3 @@ function validateEnv(log = console) {
log.warn(`Variables optionnelles non définies: ${missingOptional.join(', ')}`); log.warn(`Variables optionnelles non définies: ${missingOptional.join(', ')}`);
} }
} }
module.exports = { validateEnv };

View File

@ -1,47 +0,0 @@
const { Resend } = require('resend');
// Initialiser Resend avec votre clé API
const resend = new Resend("re_MBuQgYVw_DX1EepJFq9U6D7vLfYXh7pTX");
// Fonction pour envoyer un email
async function sendEmail({ to, subject, text, html }) {
try {
// Vérifier si nous sommes en mode développement sans clé API
if (process.env.NODE_ENV === 'development' && !process.env.RESEND_API_KEY) {
console.log('Email simulé en mode développement:');
console.log('À:', to);
console.log('Sujet:', subject);
console.log('Texte:', text);
return { id: 'test-id' };
}
// Envoyer l'email avec Resend
const { data, error } = await resend.emails.send({
from: process.env.EMAIL_FROM || 'Acme <onboarding@resend.dev>',
to,
subject,
text,
html
});
if (error) {
throw new Error(`Erreur Resend: ${error.message}`);
}
return data;
} catch (error) {
console.error('Erreur lors de l\'envoi de l\'email:', error);
throw error;
}
}
// sendEmail({
// to: 'arthurbarre.js@gmail.com',
// subject: 'Test',
// text: 'Test',
// html: '<p>Test</p>'
// });
module.exports = {
sendEmail
};

View File

@ -1,70 +0,0 @@
const Minio = require('minio');
const https = require('https');
let minioClient = null;
function getClient() {
if (minioClient) return minioClient;
if (!process.env.MINIO_ENDPOINT || !process.env.MINIO_ACCESS_KEY) {
return null;
}
const useSSL = process.env.MINIO_USE_SSL === 'true';
const allowSelfSigned = process.env.MINIO_ALLOW_SELF_SIGNED === 'true';
const clientOpts = {
endPoint: process.env.MINIO_ENDPOINT.replace(/^https?:\/\//, ''),
port: parseInt(process.env.MINIO_PORT, 10),
useSSL,
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
pathStyle: true,
};
// Ne désactiver la vérification TLS que si explicitement demandé.
if (useSSL && allowSelfSigned) {
clientOpts.transport = {
agent: new https.Agent({ rejectUnauthorized: false }),
};
}
minioClient = new Minio.Client(clientOpts);
return minioClient;
}
const uploadFile = async (file, folderPath) => {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
const fileName = `${Date.now()}-${file.filename}`;
const filePath = `${folderPath}/${fileName}`;
await client.putObject(process.env.MINIO_BUCKET, filePath, file.file);
return filePath;
};
const deleteFile = async (filePath) => {
const client = getClient();
if (!client) return;
await client.removeObject(process.env.MINIO_BUCKET, filePath);
};
const getFile = async (filePath) => {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.getObject(process.env.MINIO_BUCKET, filePath);
};
const listFiles = async (folderPath) => {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.listObjects(process.env.MINIO_BUCKET, folderPath);
};
const getFileUrl = async (filePath) => {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.presignedUrl('GET', process.env.MINIO_BUCKET, filePath);
};
module.exports = { uploadFile, deleteFile, getFile, listFiles, getFileUrl };

View File

@ -0,0 +1,80 @@
import * as Minio from 'minio';
import * as https from 'node:https';
import type { Readable } from 'node:stream';
interface UploadableFile {
filename: string;
file: Readable | Buffer;
}
let minioClient: Minio.Client | null = null;
function getClient(): Minio.Client | null {
if (minioClient) return minioClient;
if (!process.env.MINIO_ENDPOINT || !process.env.MINIO_ACCESS_KEY) {
return null;
}
const useSSL = process.env.MINIO_USE_SSL === 'true';
const allowSelfSigned = process.env.MINIO_ALLOW_SELF_SIGNED === 'true';
const clientOpts: Minio.ClientOptions = {
endPoint: process.env.MINIO_ENDPOINT.replace(/^https?:\/\//, ''),
port: parseInt(process.env.MINIO_PORT ?? '9000', 10),
useSSL,
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY ?? '',
pathStyle: true,
};
// Ne désactiver la vérification TLS que si explicitement demandé.
if (useSSL && allowSelfSigned) {
(clientOpts as Minio.ClientOptions & { transport?: unknown }).transport = {
agent: new https.Agent({ rejectUnauthorized: false }),
};
}
minioClient = new Minio.Client(clientOpts);
return minioClient;
}
function bucket(): string {
const b = process.env.MINIO_BUCKET;
if (!b) throw new Error('MINIO_BUCKET non défini');
return b;
}
export async function uploadFile(file: UploadableFile, folderPath: string): Promise<string> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
const fileName = `${Date.now()}-${file.filename}`;
const filePath = `${folderPath}/${fileName}`;
await client.putObject(bucket(), filePath, file.file);
return filePath;
}
export async function deleteFile(filePath: string): Promise<void> {
const client = getClient();
if (!client) return;
await client.removeObject(bucket(), filePath);
}
export async function getFile(filePath: string): Promise<Readable> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.getObject(bucket(), filePath);
}
export async function listFiles(folderPath: string): Promise<Minio.BucketStream<Minio.BucketItem>> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.listObjects(bucket(), folderPath);
}
export async function getFileUrl(filePath: string): Promise<string> {
const client = getClient();
if (!client) throw new Error('MinIO non configuré');
return client.presignedUrl('GET', bucket(), filePath);
}

25
backend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "prisma"]
}

View File

@ -49,9 +49,17 @@ log "Installation des dépendances backend..."
cd backend || error "Impossible d'accéder au répertoire backend" cd backend || error "Impossible d'accéder au répertoire backend"
npm install || error "Installation des dépendances backend échouée" npm install || error "Installation des dépendances backend échouée"
# # Génération du client Prisma # Génération du client Prisma
# log "Génération du client Prisma..." log "Génération du client Prisma..."
# npm dlx prisma generate || error "Génération du client Prisma échouée" npx prisma generate || error "Génération du client Prisma échouée"
# Migrations Prisma (production)
log "Application des migrations Prisma..."
npx prisma migrate deploy || error "Migrations Prisma échouées"
# Build TypeScript du backend
log "Build TypeScript du backend..."
npm run build || error "Build backend échoué"
# Installation des dépendances frontend # Installation des dépendances frontend
log "Installation des dépendances frontend..." log "Installation des dépendances frontend..."