diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 9275f46..1f3e9de 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -104,27 +104,31 @@ router.post('/refresh', async (req: Request, res: Response): Promise => { * POST /api/auth/logout * Révoque le refresh token. */ -router.post('/logout', requireAuth, async (req: AuthRequest, res: Response): Promise => { - const { refreshToken } = req.body; - if (refreshToken) { - await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [hashToken(refreshToken)]); - } - res.json({ success: true }); +router.post('/logout', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise => { + try { + const { refreshToken } = req.body; + if (refreshToken) { + await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [hashToken(refreshToken)]); + } + res.json({ success: true }); + } catch (err) { next(err); } }); /** * GET /api/auth/me * Retourne l'utilisateur connecté (sans données sensibles). */ -router.get('/me', requireAuth, async (req: AuthRequest, res: Response): Promise => { - const result = await db.query( - `SELECT id, name, email, - smtp_host, smtp_port, smtp_secure, smtp_user, - smtp_from_name, smtp_from_email - FROM users WHERE id = $1`, - [req.user!.id] - ); - res.json(result.rows[0]); +router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise => { + try { + const result = await db.query( + `SELECT id, name, email, + smtp_host, smtp_port, smtp_secure, smtp_user, + smtp_from_name, smtp_from_email + FROM users WHERE id = $1`, + [req.user!.id] + ); + res.json(result.rows[0]); + } catch (err) { next(err); } }); export default router; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts index ddb9af6..a3f1fd4 100644 --- a/backend/src/routes/categories.ts +++ b/backend/src/routes/categories.ts @@ -1,4 +1,4 @@ -import { Router, Response } from 'express'; +import { Router, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '../db'; import { requireAuth, AuthRequest } from '../middleware/auth'; @@ -7,31 +7,34 @@ import { validate } from '../middleware/validate'; const router = Router(); router.use(requireAuth); +const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => + (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); + const categorySchema = z.object({ name: z.string().min(1).max(100), sort_order: z.number().int().optional().default(0), }); /** GET /api/categories */ -router.get('/', async (_req: AuthRequest, res: Response): Promise => { +router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise => { const result = await db.query( 'SELECT * FROM categories WHERE is_active=TRUE ORDER BY sort_order, name' ); res.json(result.rows); -}); +})); /** POST /api/categories */ -router.post('/', validate(categorySchema), async (req: AuthRequest, res: Response): Promise => { +router.post('/', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { name, sort_order } = req.body; const result = await db.query( 'INSERT INTO categories (name, sort_order) VALUES ($1, $2) RETURNING *', [name, sort_order] ); res.status(201).json(result.rows[0]); -}); +})); /** PUT /api/categories/:id */ -router.put('/:id', validate(categorySchema), async (req: AuthRequest, res: Response): Promise => { +router.put('/:id', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { name, sort_order } = req.body; const result = await db.query( 'UPDATE categories SET name=$1, sort_order=$2 WHERE id=$3 AND is_active=TRUE RETURNING *', @@ -39,12 +42,12 @@ router.put('/:id', validate(categorySchema), async (req: AuthRequest, res: Respo ); if (!result.rows[0]) { res.status(404).json({ error: 'Catégorie introuvable' }); return; } res.json(result.rows[0]); -}); +})); /** DELETE /api/categories/:id — Soft-delete */ -router.delete('/:id', async (req: AuthRequest, res: Response): Promise => { +router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise => { await db.query('UPDATE categories SET is_active=FALSE WHERE id=$1', [req.params.id]); res.json({ success: true }); -}); +})); export default router; diff --git a/backend/src/routes/companies.ts b/backend/src/routes/companies.ts index 5b5fa47..fc7f19b 100644 --- a/backend/src/routes/companies.ts +++ b/backend/src/routes/companies.ts @@ -1,4 +1,4 @@ -import { Router, Response } from 'express'; +import { Router, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '../db'; import { requireAuth, AuthRequest } from '../middleware/auth'; @@ -7,31 +7,34 @@ import { validate } from '../middleware/validate'; const router = Router(); router.use(requireAuth); +const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => + (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); + const companySchema = z.object({ name: z.string().min(1).max(255), email: z.string().email(), }); /** GET /api/companies — Liste toutes les sociétés actives */ -router.get('/', async (_req: AuthRequest, res: Response): Promise => { +router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise => { const result = await db.query( 'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name' ); res.json(result.rows); -}); +})); /** POST /api/companies — Crée une société */ -router.post('/', validate(companySchema), async (req: AuthRequest, res: Response): Promise => { +router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { name, email } = req.body; const result = await db.query( `INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`, [name, email] ); res.status(201).json(result.rows[0]); -}); +})); /** PUT /api/companies/:id — Met à jour une société */ -router.put('/:id', validate(companySchema), async (req: AuthRequest, res: Response): Promise => { +router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { name, email } = req.body; const result = await db.query( `UPDATE companies SET name=$1, email=$2, updated_at=NOW() @@ -40,15 +43,15 @@ router.put('/:id', validate(companySchema), async (req: AuthRequest, res: Respon ); if (!result.rows[0]) { res.status(404).json({ error: 'Société introuvable' }); return; } res.json(result.rows[0]); -}); +})); /** DELETE /api/companies/:id — Soft-delete (is_active = false) */ -router.delete('/:id', async (req: AuthRequest, res: Response): Promise => { +router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise => { await db.query( 'UPDATE companies SET is_active=FALSE, updated_at=NOW() WHERE id=$1', [req.params.id] ); res.json({ success: true }); -}); +})); export default router; diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts index f11de53..c5b2231 100644 --- a/backend/src/routes/invoices.ts +++ b/backend/src/routes/invoices.ts @@ -1,4 +1,4 @@ -import { Router, Response } from 'express'; +import { Router, Response, NextFunction } from 'express'; import { z } from 'zod'; import multer from 'multer'; import path from 'path'; @@ -14,6 +14,11 @@ import { addRowToExcel } from '../services/sharepoint'; const router = Router(); router.use(requireAuth); +// Wrapper pour éviter les unhandledRejection qui crashent Node 20 +function wrap(fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) { + return (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); +} + // ─── Upload images ──────────────────────────────────────────── const storage = multer.diskStorage({ @@ -42,13 +47,13 @@ const upload = multer({ * Upload d'une photo de facture (avant soumission du formulaire). * Retourne: { filename } */ -router.post('/upload-image', upload.single('image'), async (req: AuthRequest, res: Response): Promise => { +router.post('/upload-image', upload.single('image'), wrap(async (req: AuthRequest, res: Response): Promise => { if (!req.file) { res.status(400).json({ error: 'Aucun fichier reçu ou format non supporté (jpg/png/webp)' }); return; } res.json({ filename: req.file.filename }); -}); +})); // ─── Schémas de validation ──────────────────────────────────── @@ -105,7 +110,7 @@ async function getInvoiceById(id: string) { * Récapitulatif des montants par société × statut (En attente / Remboursé) * ⚠ Doit être AVANT /:id pour ne pas être capturé */ -router.get('/summary', async (req: AuthRequest, res: Response): Promise => { +router.get('/summary', wrap(async (req: AuthRequest, res: Response): Promise => { const result = await db.query( `SELECT co.name AS company_name, i.status, SUM(i.amount) AS total, @@ -118,13 +123,13 @@ router.get('/summary', async (req: AuthRequest, res: Response): Promise => [req.user!.id] ); res.json(result.rows); -}); +})); /** * GET /api/invoices/export/csv * Export CSV du listing filtré (mêmes filtres que GET /) */ -router.get('/export/csv', async (req: AuthRequest, res: Response): Promise => { +router.get('/export/csv', wrap(async (req: AuthRequest, res: Response): Promise => { const { company_ids, category_ids, status, date_from, date_to, search } = req.query; const conditions: string[] = ['i.user_id = $1']; @@ -177,13 +182,13 @@ router.get('/export/csv', async (req: AuthRequest, res: Response): Promise res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="factures-${new Date().toISOString().split('T')[0]}.csv"`); res.send('' + csvLines.join('\n')); // BOM pour Excel -}); +})); /** * GET /api/invoices * Liste paginée avec filtres combinables */ -router.get('/', async (req: AuthRequest, res: Response): Promise => { +router.get('/', wrap(async (req: AuthRequest, res: Response): Promise => { const { company_ids, category_ids, status, date_from, date_to, search, sort_by, sort_dir, page, limit } = req.query; @@ -247,13 +252,13 @@ router.get('/', async (req: AuthRequest, res: Response): Promise => { page: pageNum, limit: limitNum, }); -}); +})); /** * POST /api/invoices * Crée une facture (avec invités) */ -router.post('/', validate(createSchema), async (req: AuthRequest, res: Response): Promise => { +router.post('/', validate(createSchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { company_id, category_id, supplier, amount, invoice_date, comment, images, add_to_tracking, guests } = req.body; @@ -287,7 +292,7 @@ router.post('/', validate(createSchema), async (req: AuthRequest, res: Response) } finally { client.release(); } -}); +})); /** * POST /api/invoices/:id/send @@ -296,7 +301,7 @@ router.post('/', validate(createSchema), async (req: AuthRequest, res: Response) * 3. Ajoute au fichier Excel SharePoint (si add_to_tracking = true) * 4. Met à jour la facture en BDD */ -router.post('/:id/send', async (req: AuthRequest, res: Response): Promise => { +router.post('/:id/send', wrap(async (req: AuthRequest, res: Response): Promise => { const invoice = await getInvoiceById(req.params.id); if (!invoice) { res.status(404).json({ error: 'Facture introuvable' }); return; } if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); return; } @@ -361,25 +366,25 @@ router.post('/:id/send', async (req: AuthRequest, res: Response): Promise ); res.json(await getInvoiceById(invoice.id)); -}); +})); /** * GET /api/invoices/:id */ -router.get('/:id', async (req: AuthRequest, res: Response): Promise => { +router.get('/:id', wrap(async (req: AuthRequest, res: Response): Promise => { const invoice = await getInvoiceById(req.params.id); if (!invoice || invoice.user_id !== req.user!.id) { res.status(404).json({ error: 'Facture introuvable' }); return; } res.json(invoice); -}); +})); /** * PATCH /api/invoices/:id/status * Toggle du statut (pending ↔ reimbursed) */ -router.patch('/:id/status', async (req: AuthRequest, res: Response): Promise => { +router.patch('/:id/status', wrap(async (req: AuthRequest, res: Response): Promise => { const { status } = req.body; if (!['pending', 'reimbursed'].includes(status)) { res.status(400).json({ error: 'Statut invalide (pending ou reimbursed)' }); @@ -391,13 +396,13 @@ router.patch('/:id/status', async (req: AuthRequest, res: Response): Promise => { +router.get('/:id/pdf', wrap(async (req: AuthRequest, res: Response): Promise => { const result = await db.query( 'SELECT pdf_path, pdf_filename FROM invoices WHERE id=$1 AND user_id=$2', [req.params.id, req.user!.id] @@ -407,13 +412,13 @@ router.get('/:id/pdf', async (req: AuthRequest, res: Response): Promise => const fullPath = path.join(config.uploadsDir, invoice.pdf_path); res.download(fullPath, invoice.pdf_filename); -}); +})); /** * DELETE /api/invoices/:id * Supprime la facture + nettoyage des fichiers */ -router.delete('/:id', async (req: AuthRequest, res: Response): Promise => { +router.delete('/:id', wrap(async (req: AuthRequest, res: Response): Promise => { const result = await db.query( 'DELETE FROM invoices WHERE id=$1 AND user_id=$2 RETURNING id, pdf_path, images', [req.params.id, req.user!.id] @@ -433,6 +438,6 @@ router.delete('/:id', async (req: AuthRequest, res: Response): Promise => } res.json({ success: true }); -}); +})); export default router; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index d1af92c..45eb143 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,4 +1,4 @@ -import { Router, Response } from 'express'; +import { Router, Response, NextFunction } from 'express'; import { z } from 'zod'; import { db } from '../db'; import { requireAuth, AuthRequest } from '../middleware/auth'; @@ -8,6 +8,9 @@ import { encrypt } from '../crypto'; const router = Router(); router.use(requireAuth); +const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => + (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); + // ─── Schémas ───────────────────────────────────────────────── const smtpSchema = z.object({ @@ -32,7 +35,7 @@ const appSettingsSchema = z.object({ // ─── Routes SMTP ───────────────────────────────────────────── /** GET /api/settings/smtp — Config SMTP de l'utilisateur (sans mot de passe) */ -router.get('/smtp', async (req: AuthRequest, res: Response): Promise => { +router.get('/smtp', wrap(async (req: AuthRequest, res: Response): Promise => { const result = await db.query( `SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_from_name, smtp_from_email, @@ -41,10 +44,10 @@ router.get('/smtp', async (req: AuthRequest, res: Response): Promise => { [req.user!.id] ); res.json(result.rows[0]); -}); +})); /** PUT /api/settings/smtp — Sauvegarde la config SMTP */ -router.put('/smtp', validate(smtpSchema), async (req: AuthRequest, res: Response): Promise => { +router.put('/smtp', validate(smtpSchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from_name, smtp_from_email } = req.body; if (smtp_pass) { @@ -66,10 +69,10 @@ router.put('/smtp', validate(smtpSchema), async (req: AuthRequest, res: Response } res.json({ success: true }); -}); +})); /** POST /api/settings/smtp/test — Envoie un email de test */ -router.post('/smtp/test', async (req: AuthRequest, res: Response): Promise => { +router.post('/smtp/test', wrap(async (req: AuthRequest, res: Response): Promise => { const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]); const user = userResult.rows[0]; @@ -85,12 +88,12 @@ router.post('/smtp/test', async (req: AuthRequest, res: Response): Promise } catch (err: any) { res.status(500).json({ error: `Échec SMTP : ${err.message}` }); } -}); +})); // ─── Routes paramètres application (Microsoft Graph) ───────── /** GET /api/settings/app — Config Graph + SharePoint (sans secret) */ -router.get('/app', async (_req: AuthRequest, res: Response): Promise => { +router.get('/app', wrap(async (_req: AuthRequest, res: Response): Promise => { const result = await db.query( `SELECT key, value FROM app_settings WHERE key IN ( @@ -106,10 +109,10 @@ router.get('/app', async (_req: AuthRequest, res: Response): Promise => { ); settings.has_secret = secretResult.rows[0] ? 'true' : 'false'; res.json(settings); -}); +})); /** PUT /api/settings/app — Sauvegarde la config Microsoft Graph + SharePoint */ -router.put('/app', validate(appSettingsSchema), async (req: AuthRequest, res: Response): Promise => { +router.put('/app', validate(appSettingsSchema), wrap(async (req: AuthRequest, res: Response): Promise => { const { graph_tenant_id, graph_client_id, graph_client_secret, sharepoint_site_id, sharepoint_item_id, sharepoint_sheet_name, @@ -135,6 +138,6 @@ router.put('/app', validate(appSettingsSchema), async (req: AuthRequest, res: Re } res.json({ success: true }); -}); +})); export default router;