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:
parent
0134390f5e
commit
fc3dfe83c9
16
README.md
16
README.md
@ -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
2
backend/.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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
3751
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
1739
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
186
backend/src/plugins/ai.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
26
backend/src/plugins/auth.ts
Normal file
26
backend/src/plugins/auth.ts
Normal 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)
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
8
backend/src/plugins/google-auth.ts
Normal file
8
backend/src/plugins/google-auth.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -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'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
28
backend/src/plugins/stripe.ts
Normal file
28
backend/src/plugins/stripe.ts
Normal 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'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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
201
backend/src/routes/auth.ts
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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
333
backend/src/routes/users.ts
Normal 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;
|
||||||
@ -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
102
backend/src/server.ts
Normal 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
62
backend/src/types/fastify.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
};
|
|
||||||
32
backend/src/utils/email.ts
Normal file
32
backend/src/utils/email.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 };
|
|
||||||
@ -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
|
|
||||||
};
|
|
||||||
@ -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 };
|
|
||||||
80
backend/src/utils/storage.ts
Normal file
80
backend/src/utils/storage.ts
Normal 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
25
backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
14
deploy.sh
14
deploy.sh
@ -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..."
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user