commit abbc9b16e16b584bbaa8c942cad7e4c11f0ef857 Author: deploy Date: Wed Apr 29 09:57:19 2026 +0200 deploy: notesfrais — 2026-04-29 09:57:19 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79aa779 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ───────────────────────────────────────────────────────────────── +# NotesFrais — variables d'environnement (docker-compose) +# Copier ce fichier en .env puis remplir les valeurs CHANGE_ME +# ───────────────────────────────────────────────────────────────── + +# ── Domaine public (sans https://) ─────────────────────────────── +# Utilisé par Coolify/Traefik pour le routage HTTPS. +DOMAIN=frais.domench.fr + +# ── Base de données PostgreSQL ─────────────────────────────────── +# Mot de passe du compte notesfrais dans PostgreSQL. +# Générer : openssl rand -hex 32 +DB_PASSWORD=CHANGE_ME + +# ── JWT (authentification) ─────────────────────────────────────── +# Secret pour signer les tokens d'accès (HS256). +# Générer : openssl rand -hex 64 +JWT_SECRET=CHANGE_ME + +# ── Chiffrement AES-256-GCM (secrets stockés en base) ─────────── +# Clé de 32 octets hex pour chiffrer les mots de passe SMTP +# et les identifiants Microsoft Graph enregistrés en BDD. +# Générer : openssl rand -hex 32 +APP_SECRET=CHANGE_ME + +# ── Initialisation des comptes (script init-users uniquement) ──── +# Ces variables ne sont lues qu'une seule fois lors du premier +# lancement de npm run init-users (ou docker exec … node dist/scripts/init-users.js). +# Elles peuvent être supprimées ensuite. +GREG_EMAIL=greg@domench.fr +GREG_PASSWORD=CHANGE_ME +GAEL_EMAIL=gael@domench.fr +GAEL_PASSWORD=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f509b45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*/node_modules/ +dist/ +*/dist/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..dec3c1c --- /dev/null +++ b/README.md @@ -0,0 +1,323 @@ +# NotesFrais + +PWA de gestion des notes de frais — capture photo, OCR, génération PDF, envoi par e-mail et export SharePoint. + +## Stack technique + +| Couche | Techno | +|--------|--------| +| Frontend | React 18 + Vite + TailwindCSS + TanStack Query v5 | +| Backend | Node.js 20 + Express + TypeScript | +| Base de données | PostgreSQL 16 | +| Stockage fichiers | Volume Docker (`/app/uploads`) | +| Authentification | JWT (access 15 min + refresh 30 j) | +| PDF | pdf-lib | +| E-mail | Nodemailer (SMTP par société) | +| SharePoint | Microsoft Graph API (client_credentials) | +| PWA / offline | vite-plugin-pwa + Workbox + IndexedDB queue | +| Déploiement | Docker multi-stage + Coolify + Traefik (HTTPS) | + +--- + +## 1. Pré-requis + +- Docker ≥ 24 et Docker Compose v2 +- Un domaine pointant sur le VPS (ex. `frais.domench.fr`) +- Coolify installé sur le VPS (gère le routage HTTPS via Traefik) +- Un **App Registration Azure / Entra ID** pour Microsoft Graph (voir §5) + +--- + +## 2. Installation + +### 2.1 Cloner le dépôt + +```bash +git clone notesfrais +cd notesfrais +``` + +### 2.2 Créer le fichier `.env` + +```bash +cp .env.example .env +``` + +Éditer `.env` et remplir toutes les valeurs `CHANGE_ME` : + +| Variable | Description | Commande de génération | +|----------|-------------|------------------------| +| `DOMAIN` | Domaine public (sans `https://`) | — | +| `DB_PASSWORD` | Mot de passe PostgreSQL | `openssl rand -hex 32` | +| `JWT_SECRET` | Secret de signature JWT | `openssl rand -hex 64` | +| `APP_SECRET` | Clé AES-256 pour chiffrement BDD | `openssl rand -hex 32` | +| `GREG_EMAIL` | E-mail du compte Greg (init uniquement) | — | +| `GREG_PASSWORD` | Mot de passe du compte Greg | — | +| `GAEL_EMAIL` | E-mail du compte Gaël (init uniquement) | — | +| `GAEL_PASSWORD` | Mot de passe du compte Gaël | — | + +### 2.3 Premier démarrage + +```bash +# Build et démarrage des conteneurs +docker compose up -d --build + +# Vérifier que tout est sain +docker compose ps +docker compose logs backend --tail=50 +``` + +Le script `docker-entrypoint.sh` attend automatiquement que PostgreSQL soit prêt, puis exécute la migration. + +### 2.4 Créer les utilisateurs initiaux + +Cette commande n'est à exécuter **qu'une seule fois**, juste après le premier démarrage : + +```bash +docker compose exec backend node dist/scripts/initUsers.js +``` + +Une fois les comptes créés, vous pouvez supprimer les variables `GREG_*` et `GAEL_*` du `.env`. + +--- + +## 3. Déploiement avec Coolify + +1. Dans Coolify, créer un nouveau service de type **Docker Compose**. +2. Pointer sur le dépôt Git (branche `main`). +3. Dans **Variables d'environnement**, renseigner les mêmes clés que dans `.env`. +4. Dans **Domaines**, configurer `frais.domench.fr` → port `80` (Traefik gère le TLS). +5. Lancer le build ; Coolify s'occupe du certificat Let's Encrypt. + +> **Astuce** : le `docker-compose.yml` expose uniquement le port 80 du frontend. +> Traefik route le trafic HTTPS → HTTP interne. Le backend n'est pas exposé directement. + +--- + +## 4. Paramétrage in-app + +Une fois connecté, aller dans **Réglages** : + +### 4.1 Sociétés + +Ajouter chaque société avec son nom et son adresse e-mail de contact. + +### 4.2 Configuration e-mail (par société) + +Renseigner les paramètres SMTP pour chaque société (hôte, port, TLS, utilisateur, mot de passe). +Le mot de passe SMTP est chiffré en AES-256-GCM avant d'être stocké en base. + +### 4.3 Microsoft Graph / SharePoint + +Renseigner une seule fois pour **toutes les sociétés** : + +| Champ | Description | +|-------|-------------| +| Tenant ID | ID du tenant Azure (voir §5) | +| Client ID | ID de l'App Registration | +| Client Secret | Secret de l'App Registration | +| Site ID | ID du site SharePoint (voir §5.4) | +| Item ID | ID du fichier Excel (voir §5.5) | +| Nom de feuille | Nom de l'onglet Excel (ex. `App`) | + +--- + +## 5. Configuration Microsoft Graph (Azure / Entra ID) + +### 5.1 Créer l'App Registration + +1. Aller sur [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID** → **Inscriptions d'applications** → **Nouvelle inscription**. +2. Nom : `NotesFrais` — Type de compte : **Locataire unique**. +3. URI de redirection : aucune (flux client_credentials). +4. **Créer**. + +### 5.2 Créer un secret client + +1. Dans l'app → **Certificats et secrets** → **Nouveau secret client**. +2. Durée recommandée : 24 mois. +3. **Copier la valeur immédiatement** (elle n'est visible qu'une fois). + +### 5.3 Accorder les permissions API + +1. **Autorisations API** → **Ajouter une autorisation** → **Microsoft Graph** → **Autorisations d'application**. +2. Ajouter : + - `Files.ReadWrite.All` + - `Sites.ReadWrite.All` +3. Cliquer **Accorder le consentement administrateur** (bouton vert) — indispensable pour les permissions d'application. + +### 5.4 Obtenir le Site ID SharePoint + +Dans [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) (connecté avec un compte admin) : + +``` +GET https://graph.microsoft.com/v1.0/sites/.sharepoint.com:/sites/ +``` + +Remplacer `` par le nom du tenant (ex. `1dotech`) et `` par le chemin du site. +Récupérer la valeur du champ `id` dans la réponse (format : `host,guid1,guid2`). + +### 5.5 Obtenir l'Item ID du fichier Excel + +``` +GET https://graph.microsoft.com/v1.0/sites//drive/root/children +``` + +Trouver le fichier Excel dans la liste et copier son champ `id`. + +> **Alternative** : naviguer dans l'arborescence avec +> `GET /sites//drive/root:/` + +--- + +## 6. Mise à jour + +```bash +git pull +docker compose up -d --build +``` + +Le `docker-entrypoint.sh` ré-applique la migration à chaque démarrage (toutes les instructions SQL utilisent `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` — elles sont idempotentes). + +--- + +## 7. Sauvegarde et restauration + +### Sauvegarde de la base de données + +```bash +# Dump compressé horodaté +docker compose exec db pg_dump -U notesfrais notesfrais \ + | gzip > "backup_notesfrais_$(date +%Y%m%d_%H%M%S).sql.gz" +``` + +### Sauvegarde des fichiers uploadés + +```bash +# Copie locale du volume uploads +docker cp $(docker compose ps -q backend):/app/uploads ./uploads_backup +``` + +### Restauration de la base de données + +```bash +# Arrêter le backend le temps de la restauration +docker compose stop backend + +# Restaurer +gunzip -c backup_notesfrais_YYYYMMDD_HHMMSS.sql.gz \ + | docker compose exec -T db psql -U notesfrais notesfrais + +# Redémarrer +docker compose start backend +``` + +### Restauration des fichiers + +```bash +docker cp ./uploads_backup/. $(docker compose ps -q backend):/app/uploads/ +``` + +### Script de sauvegarde automatique (cron) + +Créer `/etc/cron.daily/notesfrais-backup` : + +```bash +#!/bin/bash +set -e +BACKUP_DIR=/var/backups/notesfrais +mkdir -p "$BACKUP_DIR" + +# Garder 30 jours +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete + +# Dump BDD +cd /chemin/vers/notesfrais +docker compose exec -T db pg_dump -U notesfrais notesfrais \ + | gzip > "$BACKUP_DIR/db_$(date +%Y%m%d_%H%M%S).sql.gz" +``` + +```bash +chmod +x /etc/cron.daily/notesfrais-backup +``` + +--- + +## 8. Logs et débogage + +```bash +# Tous les services +docker compose logs -f + +# Backend uniquement +docker compose logs -f backend + +# Vérifier l'état des conteneurs +docker compose ps + +# Accéder au shell backend +docker compose exec backend sh + +# Console PostgreSQL +docker compose exec db psql -U notesfrais notesfrais +``` + +--- + +## 9. Variables d'environnement — référence complète + +### Fichier `.env` (racine, pour docker-compose) + +| Variable | Obligatoire | Défaut | Description | +|----------|-------------|--------|-------------| +| `DOMAIN` | Oui | `frais.domench.fr` | Domaine public | +| `DB_PASSWORD` | Oui | — | Mot de passe PostgreSQL | +| `JWT_SECRET` | Oui | — | Secret JWT (≥ 64 octets hex) | +| `APP_SECRET` | Oui | — | Clé AES-256 (32 octets hex) | +| `GREG_EMAIL` | Init seult. | — | E-mail compte Greg | +| `GREG_PASSWORD` | Init seult. | — | Mot de passe compte Greg | +| `GAEL_EMAIL` | Init seult. | — | E-mail compte Gaël | +| `GAEL_PASSWORD` | Init seult. | — | Mot de passe compte Gaël | + +### Variables injectées par docker-compose (non à configurer dans `.env`) + +| Variable | Valeur fixée dans docker-compose.yml | +|----------|--------------------------------------| +| `NODE_ENV` | `production` | +| `PORT` | `3001` | +| `DATABASE_URL` | construit depuis `DB_PASSWORD` | +| `FRONTEND_URL` | construit depuis `DOMAIN` | +| `UPLOADS_DIR` | `/app/uploads` | + +--- + +## 10. Structure du projet + +``` +notesfrais/ +├── backend/ +│ ├── src/ +│ │ ├── index.ts # Point d'entrée Express +│ │ ├── config.ts # Variables d'env +│ │ ├── db.ts # Pool PostgreSQL +│ │ ├── middleware/ # Auth JWT, error handler +│ │ ├── routes/ # auth, invoices, companies, settings, guests +│ │ ├── services/ # pdf, email, sharepoint +│ │ ├── migrations/ # 001_init.sql +│ │ └── scripts/ # migrate.ts, initUsers.ts +│ ├── Dockerfile +│ ├── docker-entrypoint.sh +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── pages/ # Login, NewInvoice, MyInvoices, Settings… +│ │ ├── components/ # Layout, modaux… +│ │ ├── hooks/ # useOfflineQueue, useOnline… +│ │ ├── utils/ # offlineQueue (IndexedDB), api… +│ │ └── types/ # index.ts +│ ├── Dockerfile +│ ├── nginx.conf +│ └── package.json +├── docker-compose.yml +├── .env.example +└── README.md +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..bf66890 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +uploads +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..84a3011 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,26 @@ +# ─── Application ──────────────────────────────────────────── +PORT=3001 +NODE_ENV=production +FRONTEND_URL=https://frais.domench.fr + +# ─── Base de données PostgreSQL ───────────────────────────── +DATABASE_URL=postgresql://notesfrais:CHANGE_ME@postgres:5432/notesfrais + +# ─── JWT ──────────────────────────────────────────────────── +# Générer avec : openssl rand -hex 64 +JWT_SECRET=CHANGE_ME_very_long_random_secret_here +JWT_EXPIRES_IN=15m +REFRESH_TOKEN_EXPIRES_DAYS=30 + +# ─── Chiffrement (mots de passe SMTP + secrets Graph en BDD) ─ +# Générer avec : openssl rand -hex 32 +APP_SECRET=CHANGE_ME_another_long_random_secret + +# ─── Répertoire de stockage des fichiers ──────────────────── +UPLOADS_DIR=/data/uploads + +# ─── Création initiale des utilisateurs (init-users uniquement) +GREG_EMAIL=greg@example.com +GREG_PASSWORD=CHANGE_ME +GAEL_EMAIL=gael@example.com +GAEL_PASSWORD=CHANGE_ME diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c3e7ae2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +# ── Stage 1 : Build TypeScript ─────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package.json ./ +RUN npm install --include=dev + +COPY tsconfig.json ./ +COPY src/ ./src/ + +# Compile tout (app + scripts) → dist/ +RUN npm run build + +# ── Stage 2 : Image de production ──────────────────────────── +FROM node:20-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production + +# Dépendances de production uniquement +COPY package.json ./ +RUN npm install --omit=dev + +# Code compilé (app + scripts) +COPY --from=builder /app/dist ./dist + +# Dossiers de stockage (montés via volume en prod) +RUN mkdir -p /app/uploads/images /app/uploads/pdfs + +# Script de démarrage +COPY docker-entrypoint.sh ./ +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 3001 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..dc696e6 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -e + +echo "▶ NotesFrais backend — démarrage" + +# Attendre que PostgreSQL soit prêt (Coolify peut démarrer les conteneurs en parallèle) +MAX=30 +i=0 +until node -e " + const { Client } = require('pg'); + const c = new Client({ connectionString: process.env.DATABASE_URL }); + c.connect().then(() => { c.end(); process.exit(0); }).catch(() => process.exit(1)); +" 2>/dev/null; do + i=$((i+1)) + if [ $i -ge $MAX ]; then + echo "✗ PostgreSQL inaccessible après ${MAX} tentatives — abandon" + exit 1 + fi + echo " PostgreSQL non prêt, attente (${i}/${MAX})…" + sleep 2 +done + +echo "✓ PostgreSQL prêt" + +# Migration (idempotente — IF NOT EXISTS sur toutes les créations) +echo "▶ Migration de la base de données…" +node dist/scripts/migrate.js +echo "✓ Migration terminée" + +# Démarrage du serveur +echo "▶ Démarrage du serveur Express sur le port ${PORT:-3001}" +exec node dist/index.js diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..c4382c8 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1526 @@ +{ + "name": "notesfrais-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notesfrais-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.3", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.13", + "pdf-lib": "^1.17.1", + "pg": "^8.11.5", + "uuid": "^9.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", + "@types/node": "^20.12.7", + "@types/nodemailer": "^6.4.14", + "@types/pg": "^8.11.5", + "@types/uuid": "^9.0.8", + "tsx": "^4.7.3", + "typescript": "^5.4.5" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "license": "(MIT AND Zlib)" + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "license": "(MIT AND Zlib)" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "6.4.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "dev": true, + "optional": true + }, + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { + "dev": true, + "optional": true + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "license": "(MIT AND Zlib)" + }, + "node_modules/pg": { + "version": "8.20.0", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "dev": true, + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..cee34ed --- /dev/null +++ b/backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "notesfrais-backend", + "version": "1.0.0", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "migrate": "tsx src/scripts/migrate.ts", + "init-users": "tsx src/scripts/initUsers.ts" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.3", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.13", + "pdf-lib": "^1.17.1", + "pg": "^8.11.5", + "uuid": "^9.0.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", + "@types/node": "^20.12.7", + "@types/nodemailer": "^6.4.14", + "@types/pg": "^8.11.5", + "@types/uuid": "^9.0.8", + "tsx": "^4.7.3", + "typescript": "^5.4.5" + } +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..66151f3 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,28 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +function require_env(key: string): string { + const val = process.env[key]; + if (!val) throw new Error(`Variable d'environnement manquante : ${key}`); + return val; +} + +export const config = { + port: parseInt(process.env.PORT || '3001'), + nodeEnv: process.env.NODE_ENV || 'development', + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', + + // Base de données + databaseUrl: process.env.DATABASE_URL || 'postgresql://notesfrais:notesfrais@localhost:5432/notesfrais', + + // JWT + jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-prod', + jwtExpiresIn: (process.env.JWT_EXPIRES_IN || '15m') as string, + refreshTokenExpiresDays: parseInt(process.env.REFRESH_TOKEN_EXPIRES_DAYS || '30'), + + // Chiffrement AES-256 (mots de passe SMTP, secrets Graph) + appSecret: process.env.APP_SECRET || 'dev-app-secret-change-in-prod', + + // Stockage fichiers + uploadsDir: process.env.UPLOADS_DIR || './uploads', +}; diff --git a/backend/src/crypto.ts b/backend/src/crypto.ts new file mode 100644 index 0000000..b1903b1 --- /dev/null +++ b/backend/src/crypto.ts @@ -0,0 +1,40 @@ +/** + * Chiffrement AES-256-GCM symétrique. + * Utilisé pour stocker les mots de passe SMTP et secrets Microsoft Graph en BDD. + * La clé est dérivée de APP_SECRET (variable d'environnement). + */ +import crypto from 'crypto'; +import { config } from './config'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + return crypto.createHash('sha256').update(config.appSecret).digest(); +} + +/** + * Chiffre une chaîne de texte. + * Retourne une chaîne base64 : iv (16) + tag (16) + ciphertext. + */ +export function encrypt(plaintext: string): string { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +} + +/** + * Déchiffre une chaîne encodée en base64 produite par encrypt(). + */ +export function decrypt(encoded: string): string { + const data = Buffer.from(encoded, 'base64'); + const iv = data.subarray(0, IV_LENGTH); + const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const enc = data.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(tag); + return decipher.update(enc).toString('utf8') + decipher.final('utf8'); +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..a9acd4d --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,18 @@ +import { Pool } from 'pg'; +import { config } from './config'; + +export const db = new Pool({ + connectionString: config.databaseUrl, + ssl: config.nodeEnv === 'production' ? { rejectUnauthorized: false } : false, + max: 10, +}); + +db.on('error', (err) => { + console.error('Erreur pool PostgreSQL :', err.message); +}); + +export async function testConnection(): Promise { + const client = await db.connect(); + client.release(); + console.log('✅ PostgreSQL connecté'); +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e464ffc --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,66 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import path from 'path'; +import fs from 'fs'; +import { config } from './config'; +import { testConnection } from './db'; + +// Routes +import authRoutes from './routes/auth'; +import invoicesRoutes from './routes/invoices'; +import companiesRoutes from './routes/companies'; +import categoriesRoutes from './routes/categories'; +import settingsRoutes from './routes/settings'; + +const app = express(); + +// ─── Sécurité ──────────────────────────────────────────────── +app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); +app.use(cors({ + origin: config.frontendUrl, + credentials: true, +})); +app.use(express.json({ limit: '10mb' })); + +// ─── Répertoires uploads ───────────────────────────────────── +fs.mkdirSync(path.join(config.uploadsDir, 'images'), { recursive: true }); +fs.mkdirSync(path.join(config.uploadsDir, 'pdfs'), { recursive: true }); + +// ─── Routes API ────────────────────────────────────────────── +app.use('/api/auth', authRoutes); +app.use('/api/invoices', invoicesRoutes); +app.use('/api/companies', companiesRoutes); +app.use('/api/categories', categoriesRoutes); +app.use('/api/settings', settingsRoutes); + +// ─── Health check ───────────────────────────────────────────── +app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' })); + +// ─── Gestionnaire d'erreurs global ──────────────────────────── +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[Error]', err.stack); + res.status(500).json({ + error: config.nodeEnv === 'production' ? 'Erreur serveur interne' : err.message, + }); +}); + +// ─── Démarrage ──────────────────────────────────────────────── +async function start() { + await testConnection(); + app.listen(config.port, () => { + console.log(`🚀 NotesFrais backend démarré sur le port ${config.port}`); + console.log(` Environnement : ${config.nodeEnv}`); + console.log(` Frontend autorisé : ${config.frontendUrl}`); + }); +} + +start().catch((err) => { + console.error('Impossible de démarrer le serveur :', err.message); + process.exit(1); +}); + +export default app; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..7e2b9d5 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,33 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../config'; + +export interface AuthRequest extends Request { + user?: { id: number; name: string; email: string }; +} + +/** + * Middleware JWT — vérifie le Bearer token dans Authorization. + * Injecte req.user si valide, sinon 401. + */ +export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Token manquant' }); + return; + } + + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, config.jwtSecret) as { + id: number; + name: string; + email: string; + }; + req.user = payload; + next(); + } catch { + res.status(401).json({ error: 'Token invalide ou expiré' }); + } +} diff --git a/backend/src/middleware/validate.ts b/backend/src/middleware/validate.ts new file mode 100644 index 0000000..5cdba39 --- /dev/null +++ b/backend/src/middleware/validate.ts @@ -0,0 +1,28 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodSchema, ZodError } from 'zod'; + +/** + * Middleware de validation Zod. + * Parse req.body avec le schéma fourni. + * Retourne 400 avec le détail des erreurs si invalide. + */ +export function validate(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + req.body = schema.parse(req.body); + next(); + } catch (err) { + if (err instanceof ZodError) { + res.status(400).json({ + error: 'Données invalides', + details: err.errors.map((e) => ({ + field: e.path.join('.'), + message: e.message, + })), + }); + return; + } + next(err); + } + }; +} diff --git a/backend/src/migrations/001_init.sql b/backend/src/migrations/001_init.sql new file mode 100644 index 0000000..77ed07a --- /dev/null +++ b/backend/src/migrations/001_init.sql @@ -0,0 +1,169 @@ +-- ============================================================= +-- NotesFrais — Migration 001 : Initialisation de la base +-- ============================================================= + +-- Extension pour gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================= +-- UTILISATEURS +-- Deux utilisateurs fixes : Greg et Gaël +-- Chaque utilisateur possède sa propre config SMTP +-- ============================================================= +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + -- Config SMTP (expéditeur propre à chaque user) + smtp_host VARCHAR(255), + smtp_port INTEGER DEFAULT 587, + smtp_secure BOOLEAN DEFAULT FALSE, -- TRUE = port 465 / SSL + smtp_user VARCHAR(255), + smtp_pass_enc TEXT, -- chiffré AES-256 côté app + smtp_from_name VARCHAR(100), + smtp_from_email VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- PARAMÈTRES GLOBAUX DE L'APPLICATION +-- Stocke la config Microsoft Graph + localisation du fichier Excel +-- Clés attendues : graph_tenant_id, graph_client_id, +-- graph_client_secret_enc, +-- sharepoint_site_id, sharepoint_item_id, +-- sharepoint_sheet_name +-- ============================================================= +CREATE TABLE IF NOT EXISTS app_settings ( + key VARCHAR(100) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- SOCIÉTÉS +-- Entités vers lesquelles envoyer les factures à rembourser +-- ============================================================= +CREATE TABLE IF NOT EXISTS companies ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, -- destinataire de l'email de remboursement + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- CATÉGORIES DE DÉPENSES +-- ============================================================= +CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + is_active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- FACTURES (table principale) +-- ============================================================= +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE RESTRICT, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE RESTRICT, + supplier VARCHAR(255), -- fournisseur (OCR ou saisie manuelle) + amount DECIMAL(10,2) NOT NULL, + invoice_date DATE NOT NULL, -- date figurant sur le justificatif + comment TEXT, -- commentaire libre + -- Fichiers + images JSONB DEFAULT '[]', -- [{ "path": "...", "order": 0 }] + pdf_path TEXT, -- chemin relatif du PDF généré sur le VPS + pdf_filename TEXT, -- ex: 2026-04-29_Restaurant_SocieteA_42.50€.pdf + -- Options d'envoi + add_to_tracking BOOLEAN DEFAULT TRUE, -- case "Ajouter au fichier de suivi" + tracking_added BOOLEAN DEFAULT FALSE,-- envoi SharePoint réalisé + email_sent BOOLEAN DEFAULT FALSE, + sent_at TIMESTAMPTZ, + -- Statut remboursement + status VARCHAR(20) DEFAULT 'pending' + CHECK (status IN ('pending', 'reimbursed')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- INVITÉS (associés à une facture) +-- ============================================================= +CREATE TABLE IF NOT EXISTS guests ( + id SERIAL PRIMARY KEY, + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + company VARCHAR(255), + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- REFRESH TOKENS (authentification JWT) +-- ============================================================= +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================= +-- INDEX +-- ============================================================= +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); +CREATE INDEX IF NOT EXISTS idx_invoices_company_id ON invoices(company_id); +CREATE INDEX IF NOT EXISTS idx_invoices_category_id ON invoices(category_id); +CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); +CREATE INDEX IF NOT EXISTS idx_invoices_date ON invoices(invoice_date); +CREATE INDEX IF NOT EXISTS idx_invoices_sent_at ON invoices(sent_at); +CREATE INDEX IF NOT EXISTS idx_guests_invoice_id ON guests(invoice_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); + +-- ============================================================= +-- TRIGGER : mise à jour automatique de updated_at +-- ============================================================= +CREATE OR REPLACE FUNCTION trigger_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_updated_at_users + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); + +CREATE TRIGGER set_updated_at_companies + BEFORE UPDATE ON companies + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); + +CREATE TRIGGER set_updated_at_invoices + BEFORE UPDATE ON invoices + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); + +-- ============================================================= +-- DONNÉES INITIALES +-- ============================================================= + +-- Catégories pré-configurées +INSERT INTO categories (name, sort_order) VALUES + ('Restaurant', 1), + ('Transport', 2), + ('Hôtel', 3), + ('Matériel', 4) +ON CONFLICT (name) DO NOTHING; + +-- Note : les utilisateurs Greg et Gaël sont créés via le script +-- d'initialisation (voir README — init-users.sh) afin de ne pas +-- stocker de mots de passe en clair dans les migrations. diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..7d33561 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,126 @@ +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { z } from 'zod'; +import { db } from '../db'; +import { config } from '../config'; +import { validate } from '../middleware/validate'; +import { requireAuth, AuthRequest } from '../middleware/auth'; + +const router = Router(); + +// ─── Helpers ──────────────────────────────────────────────── + +function generateAccessToken(user: { id: number; name: string; email: string }): string { + return jwt.sign( + { id: user.id, name: user.name, email: user.email }, + config.jwtSecret, + { expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'] } + ); +} + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +// ─── Schémas de validation ─────────────────────────────────── + +const loginSchema = z.object({ + email: z.string().email('Email invalide'), + password: z.string().min(1, 'Mot de passe requis'), +}); + +// ─── Routes ───────────────────────────────────────────────── + +/** + * POST /api/auth/login + * Body: { email, password } + * Retourne: { accessToken, refreshToken, user } + */ +router.post('/login', validate(loginSchema), async (req: Request, res: Response): Promise => { + const { email, password } = req.body; + + const result = await db.query('SELECT * FROM users WHERE email = $1', [email]); + const user = result.rows[0]; + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ error: 'Email ou mot de passe incorrect' }); + return; + } + + const accessToken = generateAccessToken(user); + const refreshToken = crypto.randomBytes(40).toString('hex'); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + config.refreshTokenExpiresDays); + + await db.query( + 'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', + [user.id, hashToken(refreshToken), expiresAt] + ); + + res.json({ + accessToken, + refreshToken, + user: { id: user.id, name: user.name, email: user.email }, + }); +}); + +/** + * POST /api/auth/refresh + * Body: { refreshToken } + * Retourne: { accessToken } + */ +router.post('/refresh', async (req: Request, res: Response): Promise => { + const { refreshToken } = req.body; + if (!refreshToken) { + res.status(400).json({ error: 'Refresh token manquant' }); + return; + } + + const result = await db.query( + `SELECT rt.user_id, u.name, u.email + FROM refresh_tokens rt + JOIN users u ON u.id = rt.user_id + WHERE rt.token_hash = $1 AND rt.expires_at > NOW()`, + [hashToken(refreshToken)] + ); + + const row = result.rows[0]; + if (!row) { + res.status(401).json({ error: 'Session expirée, veuillez vous reconnecter' }); + return; + } + + const accessToken = generateAccessToken({ id: row.user_id, name: row.name, email: row.email }); + res.json({ accessToken }); +}); + +/** + * 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 }); +}); + +/** + * 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]); +}); + +export default router; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts new file mode 100644 index 0000000..ddb9af6 --- /dev/null +++ b/backend/src/routes/categories.ts @@ -0,0 +1,50 @@ +import { Router, Response } from 'express'; +import { z } from 'zod'; +import { db } from '../db'; +import { requireAuth, AuthRequest } from '../middleware/auth'; +import { validate } from '../middleware/validate'; + +const router = Router(); +router.use(requireAuth); + +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 => { + 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 => { + 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 => { + 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 *', + [name, sort_order, req.params.id] + ); + 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 => { + 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 new file mode 100644 index 0000000..5b5fa47 --- /dev/null +++ b/backend/src/routes/companies.ts @@ -0,0 +1,54 @@ +import { Router, Response } from 'express'; +import { z } from 'zod'; +import { db } from '../db'; +import { requireAuth, AuthRequest } from '../middleware/auth'; +import { validate } from '../middleware/validate'; + +const router = Router(); +router.use(requireAuth); + +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 => { + 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 => { + 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 => { + const { name, email } = req.body; + const result = await db.query( + `UPDATE companies SET name=$1, email=$2, updated_at=NOW() + WHERE id=$3 AND is_active=TRUE RETURNING *`, + [name, email, req.params.id] + ); + 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 => { + 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 new file mode 100644 index 0000000..f11de53 --- /dev/null +++ b/backend/src/routes/invoices.ts @@ -0,0 +1,438 @@ +import { Router, Response } from 'express'; +import { z } from 'zod'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs/promises'; +import { db } from '../db'; +import { requireAuth, AuthRequest } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { config } from '../config'; +import { generateInvoicePdf } from '../services/pdf'; +import { sendInvoiceEmail } from '../services/email'; +import { addRowToExcel } from '../services/sharepoint'; + +const router = Router(); +router.use(requireAuth); + +// ─── Upload images ──────────────────────────────────────────── + +const storage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const dir = path.join(config.uploadsDir, 'images'); + await fs.mkdir(dir, { recursive: true }); + cb(null, dir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 15 * 1024 * 1024 }, // 15 Mo + fileFilter: (_req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/webp']; + cb(null, allowed.includes(file.mimetype)); + }, +}); + +/** + * POST /api/invoices/upload-image + * 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 => { + 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 ──────────────────────────────────── + +const createSchema = z.object({ + company_id: z.number().int().positive(), + category_id: z.number().int().positive(), + supplier: z.string().optional(), + amount: z.number().positive('Le montant doit être positif'), + invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'), + comment: z.string().optional(), + images: z.array(z.object({ + path: z.string().min(1), + order: z.number().int(), + })).min(1, 'Au moins une image requise'), + add_to_tracking: z.boolean().default(true), + guests: z.array(z.object({ + name: z.string().min(1), + company: z.string().optional().nullable(), + sort_order: z.number().int().optional().default(0), + })).default([]), +}); + +// ─── Helpers ───────────────────────────────────────────────── + +async function getInvoiceById(id: string) { + const result = await db.query( + `SELECT i.*, + co.name AS company_name, + co.email AS company_email, + cat.name AS category_name, + u.name AS user_name + FROM invoices i + JOIN companies co ON co.id = i.company_id + JOIN categories cat ON cat.id = i.category_id + JOIN users u ON u.id = i.user_id + WHERE i.id = $1`, + [id] + ); + if (!result.rows[0]) return null; + + const invoice = result.rows[0]; + const guests = await db.query( + 'SELECT name, company, sort_order FROM guests WHERE invoice_id=$1 ORDER BY sort_order', + [id] + ); + invoice.guests = guests.rows; + return invoice; +} + +// ─── Routes ───────────────────────────────────────────────── + +/** + * GET /api/invoices/summary + * 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 => { + const result = await db.query( + `SELECT co.name AS company_name, i.status, + SUM(i.amount) AS total, + COUNT(*)::int AS count + FROM invoices i + JOIN companies co ON co.id = i.company_id + WHERE i.user_id = $1 + GROUP BY co.name, i.status + ORDER BY co.name, i.status`, + [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 => { + const { company_ids, category_ids, status, date_from, date_to, search } = req.query; + + const conditions: string[] = ['i.user_id = $1']; + const params: unknown[] = [req.user!.id]; + let p = 2; + + if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); } + if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); } + if (status) { conditions.push(`i.status = $${p++}`); params.push(status); } + if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); } + if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); } + if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; } + + const result = await db.query( + `SELECT i.invoice_date, co.name AS company, cat.name AS category, + i.supplier, i.amount, i.comment, i.status, + i.sent_at, i.pdf_filename + FROM invoices i + JOIN companies co ON co.id = i.company_id + JOIN categories cat ON cat.id = i.category_id + WHERE ${conditions.join(' AND ')} + ORDER BY COALESCE(i.sent_at, i.created_at) DESC`, + params + ); + + const headers = ['Date','Société','Catégorie','Fournisseur','Montant (€)','Commentaire','Statut','Date envoi','Fichier PDF']; + const csvLines = [headers.join(';')]; + + for (const row of result.rows) { + const invoiceDate = row.invoice_date + ? new Date(row.invoice_date).toLocaleDateString('fr-FR') + : ''; + const sentDate = row.sent_at + ? new Date(row.sent_at).toLocaleDateString('fr-FR') + : ''; + + csvLines.push([ + invoiceDate, + row.company, + row.category, + row.supplier || '', + Number(row.amount).toFixed(2).replace('.', ','), + `"${(row.comment || '').replace(/"/g, '""')}"`, + row.status === 'pending' ? 'En attente' : 'Remboursé', + sentDate, + row.pdf_filename || '', + ].join(';')); + } + + 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 => { + const { company_ids, category_ids, status, date_from, date_to, search, + sort_by, sort_dir, page, limit } = req.query; + + const conditions: string[] = ['i.user_id = $1']; + const params: unknown[] = [req.user!.id]; + let p = 2; + + if (company_ids) { conditions.push(`i.company_id = ANY($${p++}::int[])`); params.push(String(company_ids).split(',').map(Number)); } + if (category_ids) { conditions.push(`i.category_id = ANY($${p++}::int[])`); params.push(String(category_ids).split(',').map(Number)); } + if (status) { conditions.push(`i.status = $${p++}`); params.push(status); } + if (date_from) { conditions.push(`i.invoice_date >= $${p++}`); params.push(date_from); } + if (date_to) { conditions.push(`i.invoice_date <= $${p++}`); params.push(date_to); } + if (search) { conditions.push(`(i.supplier ILIKE $${p} OR i.comment ILIKE $${p})`); params.push(`%${search}%`); p++; } + + // Tri sécurisé + const sortMap: Record = { + invoice_date: 'i.invoice_date', + amount: 'i.amount', + supplier: 'i.supplier', + company_name: 'co.name', + category_name: 'cat.name', + status: 'i.status', + sent_at: 'i.sent_at', + }; + const orderCol = sortMap[String(sort_by)] ?? 'i.sent_at'; + const orderDir = sort_dir === 'asc' ? 'ASC' : 'DESC'; + + const pageNum = Math.max(1, parseInt(String(page || '1'))); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit || '50')))); + const offset = (pageNum - 1) * limitNum; + const where = conditions.join(' AND '); + + const [dataRes, countRes] = await Promise.all([ + db.query( + `SELECT i.id, i.supplier, i.amount, i.invoice_date, i.comment, + i.status, i.email_sent, i.tracking_added, i.sent_at, + i.add_to_tracking, i.pdf_filename, i.created_at, + co.name AS company_name, + cat.name AS category_name + FROM invoices i + JOIN companies co ON co.id = i.company_id + JOIN categories cat ON cat.id = i.category_id + WHERE ${where} + ORDER BY ${orderCol} ${orderDir} NULLS LAST + LIMIT $${p} OFFSET $${p + 1}`, + [...params, limitNum, offset] + ), + db.query( + `SELECT COUNT(*)::int AS total + FROM invoices i + JOIN companies co ON co.id = i.company_id + JOIN categories cat ON cat.id = i.category_id + WHERE ${where}`, + params + ), + ]); + + res.json({ + data: dataRes.rows, + total: countRes.rows[0].total, + page: pageNum, + limit: limitNum, + }); +}); + +/** + * POST /api/invoices + * Crée une facture (avec invités) + */ +router.post('/', validate(createSchema), async (req: AuthRequest, res: Response): Promise => { + const { company_id, category_id, supplier, amount, invoice_date, + comment, images, add_to_tracking, guests } = req.body; + + const client = await db.connect(); + try { + await client.query('BEGIN'); + + const invResult = await client.query( + `INSERT INTO invoices + (user_id, company_id, category_id, supplier, amount, + invoice_date, comment, images, add_to_tracking) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [req.user!.id, company_id, category_id, supplier ?? null, amount, + invoice_date, comment ?? null, JSON.stringify(images), add_to_tracking] + ); + const invoice = invResult.rows[0]; + + for (const g of guests) { + await client.query( + 'INSERT INTO guests (invoice_id, name, company, sort_order) VALUES ($1,$2,$3,$4)', + [invoice.id, g.name, g.company ?? null, g.sort_order ?? 0] + ); + } + + await client.query('COMMIT'); + res.status(201).json(await getInvoiceById(invoice.id)); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +}); + +/** + * POST /api/invoices/:id/send + * 1. Génère le PDF (images + page invités si applicable) + * 2. Envoie l'email à la société + * 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 => { + 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; } + + const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]); + const user = userResult.rows[0]; + + if (!user.smtp_host || !user.smtp_pass_enc) { + res.status(400).json({ error: 'SMTP non configuré. Allez dans Paramètres → Email.' }); + return; + } + + // ── Nommage du PDF ──────────────────────────────────────── + const dateObj = new Date(invoice.invoice_date); + const dateStr = dateObj.toISOString().split('T')[0]; // AAAA-MM-JJ + const amountStr = Number(invoice.amount).toFixed(2).replace('.', ','); + const safeCat = invoice.category_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-'); + const safeCo = invoice.company_name.replace(/[^a-zA-Z0-9À-ɏ]/g, '-'); + const pdfFilename = `${dateStr}_${safeCat}_${safeCo}_${amountStr}€.pdf`; + + const pdfDir = path.join(config.uploadsDir, 'pdfs'); + const pdfPath = path.join(pdfDir, `${invoice.id}.pdf`); + + // ── Génération PDF ──────────────────────────────────────── + const imagePaths = (invoice.images as Array<{ path: string; order: number }>) + .sort((a, b) => a.order - b.order) + .map((img) => path.join(config.uploadsDir, 'images', img.path)); + + await generateInvoicePdf(imagePaths, invoice.guests, pdfPath); + + // ── Envoi email ─────────────────────────────────────────── + await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename); + + // ── SharePoint (non bloquant) ───────────────────────────── + let trackingAdded = false; + if (invoice.add_to_tracking) { + try { + const [year, month, day] = dateStr.split('-'); + await addRowToExcel({ + category: invoice.category_name, + companyName: invoice.company_name, + comment: invoice.comment || '', + guests: invoice.guests, + date: `${day}/${month}/${year}`, + amount: Number(invoice.amount), + userName: user.name, + }); + trackingAdded = true; + } catch (err: any) { + // Non bloquant : l'email est déjà envoyé, on log l'erreur + console.warn('[SharePoint] Erreur non bloquante :', err.message); + } + } + + // ── Mise à jour BDD ─────────────────────────────────────── + await db.query( + `UPDATE invoices + SET pdf_path=$1, pdf_filename=$2, email_sent=TRUE, + tracking_added=$3, sent_at=NOW(), updated_at=NOW() + WHERE id=$4`, + [path.join('pdfs', `${invoice.id}.pdf`), pdfFilename, trackingAdded, invoice.id] + ); + + res.json(await getInvoiceById(invoice.id)); +}); + +/** + * GET /api/invoices/:id + */ +router.get('/:id', 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 => { + const { status } = req.body; + if (!['pending', 'reimbursed'].includes(status)) { + res.status(400).json({ error: 'Statut invalide (pending ou reimbursed)' }); + return; + } + const result = await db.query( + 'UPDATE invoices SET status=$1, updated_at=NOW() WHERE id=$2 AND user_id=$3 RETURNING id, status', + [status, req.params.id, req.user!.id] + ); + if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; } + res.json(result.rows[0]); +}); + +/** + * GET /api/invoices/:id/pdf + * Téléchargement du PDF généré + */ +router.get('/:id/pdf', 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] + ); + const invoice = result.rows[0]; + if (!invoice?.pdf_path) { res.status(404).json({ error: 'PDF non disponible' }); return; } + + 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 => { + 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] + ); + if (!result.rows[0]) { res.status(404).json({ error: 'Facture introuvable' }); return; } + + const deleted = result.rows[0]; + + // Nettoyage PDF + if (deleted.pdf_path) { + await fs.unlink(path.join(config.uploadsDir, deleted.pdf_path)).catch(() => {}); + } + // Nettoyage images sources + const images = (deleted.images as Array<{ path: string }>) || []; + for (const img of images) { + await fs.unlink(path.join(config.uploadsDir, 'images', img.path)).catch(() => {}); + } + + res.json({ success: true }); +}); + +export default router; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..d1af92c --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,140 @@ +import { Router, Response } from 'express'; +import { z } from 'zod'; +import { db } from '../db'; +import { requireAuth, AuthRequest } from '../middleware/auth'; +import { validate } from '../middleware/validate'; +import { encrypt } from '../crypto'; + +const router = Router(); +router.use(requireAuth); + +// ─── Schémas ───────────────────────────────────────────────── + +const smtpSchema = z.object({ + smtp_host: z.string().min(1, 'Hôte SMTP requis'), + smtp_port: z.number().int().min(1).max(65535), + smtp_secure: z.boolean(), + smtp_user: z.string().min(1), + smtp_pass: z.string().optional(), // absent = conserver l'existant + smtp_from_name: z.string().min(1), + smtp_from_email: z.string().email(), +}); + +const appSettingsSchema = z.object({ + graph_tenant_id: z.string().optional(), + graph_client_id: z.string().optional(), + graph_client_secret: z.string().optional(), // absent = conserver l'existant + sharepoint_site_id: z.string().optional(), + sharepoint_item_id: z.string().optional(), + sharepoint_sheet_name: z.string().optional(), +}); + +// ─── Routes SMTP ───────────────────────────────────────────── + +/** GET /api/settings/smtp — Config SMTP de l'utilisateur (sans mot de passe) */ +router.get('/smtp', 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, + (smtp_pass_enc IS NOT NULL) AS has_password + FROM users WHERE id=$1`, + [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 => { + const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from_name, smtp_from_email } = req.body; + + if (smtp_pass) { + await db.query( + `UPDATE users + SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4, + smtp_pass_enc=$5, smtp_from_name=$6, smtp_from_email=$7, updated_at=NOW() + WHERE id=$8`, + [smtp_host, smtp_port, smtp_secure, smtp_user, encrypt(smtp_pass), smtp_from_name, smtp_from_email, req.user!.id] + ); + } else { + await db.query( + `UPDATE users + SET smtp_host=$1, smtp_port=$2, smtp_secure=$3, smtp_user=$4, + smtp_from_name=$5, smtp_from_email=$6, updated_at=NOW() + WHERE id=$7`, + [smtp_host, smtp_port, smtp_secure, smtp_user, smtp_from_name, smtp_from_email, req.user!.id] + ); + } + + res.json({ success: true }); +}); + +/** POST /api/settings/smtp/test — Envoie un email de test */ +router.post('/smtp/test', 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]; + + if (!user.smtp_host || !user.smtp_pass_enc) { + res.status(400).json({ error: 'SMTP non configuré' }); + return; + } + + try { + const { sendTestEmail } = await import('../services/email'); + await sendTestEmail(user); + res.json({ success: true, message: `Email de test envoyé à ${user.smtp_from_email}` }); + } 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 => { + const result = await db.query( + `SELECT key, value FROM app_settings + WHERE key IN ( + 'graph_tenant_id','graph_client_id', + 'sharepoint_site_id','sharepoint_item_id','sharepoint_sheet_name' + )` + ); + const settings: Record = {}; + for (const row of result.rows) settings[row.key] = row.value; + // Indique juste si le secret est présent sans l'exposer + const secretResult = await db.query( + "SELECT value FROM app_settings WHERE key='graph_client_secret_enc'" + ); + 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 => { + const { + graph_tenant_id, graph_client_id, graph_client_secret, + sharepoint_site_id, sharepoint_item_id, sharepoint_sheet_name, + } = req.body; + + const upsert = async (key: string, value: string | undefined) => { + if (value === undefined || value === null || value === '') return; + await db.query( + `INSERT INTO app_settings (key, value, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()`, + [key, value] + ); + }; + + await upsert('graph_tenant_id', graph_tenant_id); + await upsert('graph_client_id', graph_client_id); + await upsert('sharepoint_site_id', sharepoint_site_id); + await upsert('sharepoint_item_id', sharepoint_item_id); + await upsert('sharepoint_sheet_name', sharepoint_sheet_name); + if (graph_client_secret) { + await upsert('graph_client_secret_enc', encrypt(graph_client_secret)); + } + + res.json({ success: true }); +}); + +export default router; diff --git a/backend/src/scripts/initUsers.ts b/backend/src/scripts/initUsers.ts new file mode 100644 index 0000000..b3f6563 --- /dev/null +++ b/backend/src/scripts/initUsers.ts @@ -0,0 +1,48 @@ +/** + * Script d'initialisation des utilisateurs Greg et Gaël. + * À exécuter une seule fois après la migration. + * + * Variables d'environnement requises : + * GREG_EMAIL, GREG_PASSWORD, GAEL_EMAIL, GAEL_PASSWORD + * + * Usage : npm run init-users + */ +import 'dotenv/config'; +import bcrypt from 'bcryptjs'; +import { db } from '../db'; + +async function initUsers() { + const SALT_ROUNDS = 12; + + const users = [ + { + name: 'Greg', + email: process.env.GREG_EMAIL || 'greg@example.com', + password: process.env.GREG_PASSWORD || 'changeme', + }, + { + name: 'Gaël', + email: process.env.GAEL_EMAIL || 'gael@example.com', + password: process.env.GAEL_PASSWORD || 'changeme', + }, + ]; + + for (const user of users) { + const hash = await bcrypt.hash(user.password, SALT_ROUNDS); + const result = await db.query( + `INSERT INTO users (name, email, password_hash) + VALUES ($1, $2, $3) + ON CONFLICT (email) DO UPDATE SET name=$1, updated_at=NOW() + RETURNING id, name, email`, + [user.name, user.email, hash] + ); + console.log(`✅ Utilisateur créé/mis à jour : ${result.rows[0].name} <${result.rows[0].email}>`); + } + + await db.end(); +} + +initUsers().catch((err) => { + console.error('❌ Initialisation échouée :', err.message); + process.exit(1); +}); diff --git a/backend/src/scripts/migrate.ts b/backend/src/scripts/migrate.ts new file mode 100644 index 0000000..e9aee5f --- /dev/null +++ b/backend/src/scripts/migrate.ts @@ -0,0 +1,24 @@ +/** + * Script de migration — crée toutes les tables et données initiales. + * Usage : npm run migrate + */ +import 'dotenv/config'; +import { db } from '../db'; +import fs from 'fs'; +import path from 'path'; + +async function migrate() { + const sqlPath = path.join(__dirname, '../migrations/001_init.sql'); + const sql = fs.readFileSync(sqlPath, 'utf8'); + + console.log('🔄 Exécution de la migration 001_init.sql...'); + await db.query(sql); + console.log('✅ Migration terminée avec succès'); + + await db.end(); +} + +migrate().catch((err) => { + console.error('❌ Migration échouée :', err.message); + process.exit(1); +}); diff --git a/backend/src/services/email.ts b/backend/src/services/email.ts new file mode 100644 index 0000000..863e8f8 --- /dev/null +++ b/backend/src/services/email.ts @@ -0,0 +1,66 @@ +import nodemailer from 'nodemailer'; +import { decrypt } from '../crypto'; + +export interface UserSmtpConfig { + smtp_host: string; + smtp_port: number; + smtp_secure: boolean; + smtp_user: string; + smtp_pass_enc: string; + smtp_from_name: string; + smtp_from_email: string; +} + +function createTransporter(user: UserSmtpConfig) { + return nodemailer.createTransport({ + host: user.smtp_host, + port: user.smtp_port, + secure: user.smtp_secure, + auth: { + user: user.smtp_user, + pass: decrypt(user.smtp_pass_enc), + }, + }); +} + +/** + * Envoie la facture par email. + * Expéditeur = compte SMTP de l'utilisateur connecté. + * Destinataire = email de la société à rembourser. + */ +export async function sendInvoiceEmail( + user: UserSmtpConfig, + toEmail: string, + pdfAbsPath: string, + pdfFilename: string +): Promise { + const transporter = createTransporter(user); + + await transporter.sendMail({ + from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`, + to: toEmail, + subject: 'Facture à rembourser', + text: '', + attachments: [ + { + filename: pdfFilename, + path: pdfAbsPath, + contentType: 'application/pdf', + }, + ], + }); +} + +/** + * Email de test pour vérifier la config SMTP de l'utilisateur. + */ +export async function sendTestEmail(user: UserSmtpConfig): Promise { + const transporter = createTransporter(user); + + await transporter.sendMail({ + from: `"${user.smtp_from_name}" <${user.smtp_from_email}>`, + to: user.smtp_from_email, + subject: '[NotesFrais] Test de configuration SMTP', + text: 'Votre configuration SMTP fonctionne correctement ✓', + }); +} diff --git a/backend/src/services/pdf.ts b/backend/src/services/pdf.ts new file mode 100644 index 0000000..7f7d26b --- /dev/null +++ b/backend/src/services/pdf.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +// @ts-ignore — pdf-lib est complet lors d'un npm install propre sur le VPS +const { PDFDocument, rgb, StandardFonts, PageSizes } = require('pdf-lib'); + +import fs from 'fs/promises'; +import path from 'path'; + +export interface PdfGuest { + name: string; + company?: string | null; +} + +export async function generateInvoicePdf( + imagePaths: string[], + guests: PdfGuest[], + outputPath: string +): Promise { + const pdfDoc = await PDFDocument.create(); + + for (const imgPath of imagePaths) { + const imgBytes = await fs.readFile(imgPath); + const ext = path.extname(imgPath).toLowerCase(); + const image = ext === '.png' + ? await pdfDoc.embedPng(imgBytes) + : await pdfDoc.embedJpg(imgBytes); + const dims = image.scale(1); + const page = pdfDoc.addPage([dims.width, dims.height]); + page.drawImage(image, { x: 0, y: 0, width: dims.width, height: dims.height }); + } + + if (guests.length > 0) { + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const [W, H] = PageSizes.A4; + const M = 50; + const LH = 24; + const page = pdfDoc.addPage([W, H]); + let y = H - M; + + page.drawText('Liste des invites', { x: M, y, size: 18, font: fontBold, color: rgb(0.1, 0.1, 0.1) }); + y -= LH * 1.5; + + page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 1, color: rgb(0.2, 0.2, 0.2) }); + y -= LH; + + page.drawText('Nom', { x: M, y, size: 11, font: fontBold, color: rgb(0.3, 0.3, 0.3) }); + page.drawText('Entreprise', { x: M + 230, y, size: 11, font: fontBold, color: rgb(0.3, 0.3, 0.3) }); + y -= 6; + + page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.5, color: rgb(0.7, 0.7, 0.7) }); + y -= LH; + + for (const guest of guests) { + if (y < M + LH) break; + page.drawText(guest.name, { x: M, y, size: 11, font, color: rgb(0.1, 0.1, 0.1), maxWidth: 220 }); + if (guest.company) { + page.drawText(guest.company, { x: M + 230, y, size: 11, font, color: rgb(0.1, 0.1, 0.1), maxWidth: W - M - 230 - M }); + } + y -= LH; + } + } + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + const pdfBytes = await pdfDoc.save(); + await fs.writeFile(outputPath, pdfBytes); +} diff --git a/backend/src/services/sharepoint.ts b/backend/src/services/sharepoint.ts new file mode 100644 index 0000000..81904d8 --- /dev/null +++ b/backend/src/services/sharepoint.ts @@ -0,0 +1,151 @@ +import { db } from '../db'; +import { decrypt } from '../crypto'; + +// ─── Types ─────────────────────────────────────────────────── + +interface GraphConfig { + tenantId: string; + clientId: string; + clientSecret: string; + sharepointSiteId: string; + sharepointItemId: string; + sharepointSheet: string; +} + +export interface SharepointRowData { + category: string; + companyName: string; + comment: string; + guests: Array<{ name: string; company?: string | null }>; + date: string; // format JJ/MM/AAAA + amount: number; + userName: string; // 'Greg' ou 'Gaël' +} + +// ─── Helpers ───────────────────────────────────────────────── + +async function getGraphConfig(): Promise { + const result = await db.query( + `SELECT key, value FROM app_settings + WHERE key IN ( + 'graph_tenant_id', 'graph_client_id', 'graph_client_secret_enc', + 'sharepoint_site_id', 'sharepoint_item_id', 'sharepoint_sheet_name' + )` + ); + const cfg: Record = {}; + for (const row of result.rows) cfg[row.key] = row.value; + + if (!cfg.graph_tenant_id || !cfg.graph_client_id || !cfg.graph_client_secret_enc) { + throw new Error('Microsoft Graph non configuré (tenant_id, client_id ou client_secret manquant)'); + } + if (!cfg.sharepoint_site_id || !cfg.sharepoint_item_id) { + throw new Error('Fichier Excel SharePoint non configuré (site_id ou item_id manquant dans Paramètres → Microsoft 365)'); + } + + return { + tenantId: cfg.graph_tenant_id, + clientId: cfg.graph_client_id, + clientSecret: decrypt(cfg.graph_client_secret_enc), + sharepointSiteId: cfg.sharepoint_site_id, + sharepointItemId: cfg.sharepoint_item_id, + sharepointSheet: cfg.sharepoint_sheet_name ?? 'Feuil1', + }; +} + +async function getAccessToken(cfg: GraphConfig): Promise { + const response = await fetch( + `https://login.microsoftonline.com/${cfg.tenantId}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + scope: 'https://graph.microsoft.com/.default', + }), + } + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Obtention token Graph échouée (${response.status}) : ${body}`); + } + + const data = (await response.json()) as { access_token: string }; + return data.access_token; +} + +// ─── Fonction principale ────────────────────────────────────── + +/** + * Ajoute une ligne dans le fichier Excel SharePoint commun selon le mapping : + * A : Catégorie + * B : Société facturée + * C : Commentaire + invités + * D : Date (JJ/MM/AAAA) + * E : Montant si Greg + * F : Montant si Gaël + */ +export async function addRowToExcel(row: SharepointRowData): Promise { + const cfg = await getGraphConfig(); + const token = await getAccessToken(cfg); + + // Construction de la cellule C (commentaire + invités) + let cellComment = row.comment || ''; + if (row.guests.length > 0) { + const guestStr = row.guests + .map((g) => (g.company ? `${g.name} — ${g.company}` : g.name)) + .join(' ; '); + cellComment = cellComment + ? `${cellComment}. Invités : ${guestStr}` + : `Invités : ${guestStr}`; + } + + // Colonnes E/F selon l'utilisateur + const nameLower = row.userName.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); + const isGreg = nameLower === 'greg'; + const isGael = nameLower === 'gael'; + + const values = [[ + row.category, // A + row.companyName, // B + cellComment, // C + row.date, // D + isGreg ? row.amount : null, // E — Greg + isGael ? row.amount : null, // F — Gaël + ]]; + + // ── Trouver la première ligne vide ─────────────────────────── + const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`; + + const rangeResp = await fetch(`${baseUrl}/usedRange`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!rangeResp.ok) { + const body = await rangeResp.text(); + throw new Error(`Graph usedRange échoué (${rangeResp.status}) : ${body}`); + } + + const rangeData = (await rangeResp.json()) as { rowCount: number }; + const nextRow = rangeData.rowCount + 1; + + // ── Écriture de la nouvelle ligne ──────────────────────────── + const writeResp = await fetch( + `${baseUrl}/range(address='A${nextRow}:F${nextRow}')`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ values }), + } + ); + + if (!writeResp.ok) { + const body = await writeResp.text(); + throw new Error(`Graph écriture ligne échouée (${writeResp.status}) : ${body}`); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..9ef84e3 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ca3ca4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + + # ── PostgreSQL ────────────────────────────────────────────── + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: notesfrais + POSTGRES_USER: notesfrais + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + # ── Backend Express ───────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://notesfrais:${DB_PASSWORD}@db:5432/notesfrais + JWT_SECRET: ${JWT_SECRET} + APP_SECRET: ${APP_SECRET} + UPLOADS_DIR: /app/uploads + FRONTEND_URL: https://${DOMAIN:-frais.domench.fr} + volumes: + - uploads:/app/uploads + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + # ── Frontend nginx ────────────────────────────────────────── + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + depends_on: + - backend + restart: unless-stopped + # Coolify gère le routage HTTPS via Traefik. + # Configurer le domaine (frais.domench.fr) dans l'interface Coolify. + ports: + - "80:80" + +volumes: + pgdata: + uploads: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3b24e4a --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5971b5a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# ── Stage 1 : Build Vite ───────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package.json ./ +RUN npm install --include=dev + +COPY . . + +# En prod le frontend est servi par nginx qui proxifie /api → backend +# VITE_API_URL est laissé vide : le client utilise le chemin relatif /api +RUN npm run build + +# ── Stage 2 : Serveur nginx ─────────────────────────────────── +FROM nginx:1.27-alpine AS runtime + +# SPA + proxy API +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Assets du build Vite +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..497d3e7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + NotesFrais + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8f199e1 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # ── Compression ─────────────────────────────────────────── + gzip on; + gzip_vary on; + gzip_types + text/plain text/css text/javascript + application/javascript application/json + image/svg+xml; + + # ── PWA : cache long sur les assets hasheés ─────────────── + location ~* \.(js|css|woff2?|png|svg|ico|webmanifest)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # ── API → backend (service Docker interne) ──────────────── + location /api/ { + proxy_pass http://backend:3001/api/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Nécessaire pour l'upload d'images (jusqu'à 15 Mo) + client_max_body_size 20m; + + # Timeout généreux pour la génération PDF + proxy_read_timeout 120s; + } + + # ── SPA fallback (React Router) ─────────────────────────── + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fb0f633 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "notesfrais-frontend", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.32.1", + "axios": "^1.6.8", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-image-crop": "^11.0.5", + "react-router-dom": "^6.23.1", + "tesseract.js": "^5.0.5", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vite-plugin-pwa": "^0.20.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..4d934b7 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,74 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Toaster } from 'react-hot-toast'; +import { useAuthStore } from './store/auth'; +import Login from './pages/Login'; +import Layout from './components/Layout'; +import NewInvoice from './pages/NewInvoice'; +import MyInvoices from './pages/MyInvoices'; +import Settings from './pages/Settings'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }, + }, +}); + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { user } = useAuthStore(); + if (!user) return ; + return <>{children}; +} + +export default function App() { + return ( + + + + + {/* Page de connexion — accessible sans auth */} + } /> + + {/* Pages protégées — layout avec nav du bas */} + + + + } + > + } /> + } /> + } /> + } /> + + + {/* Fallback */} + } /> + + + + + {/* Toasts globaux */} + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..e47afb1 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,86 @@ +/** + * Client Axios centralisé. + * - Ajoute automatiquement le Bearer token à chaque requête + * - Rafraîchit le token silencieusement en cas de 401 + * - Redirige vers /login si le refresh échoue + */ +import axios from 'axios'; +import { useAuthStore } from '../store/auth'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL ?? '/api', + timeout: 30_000, +}); + +// ─── Request interceptor — injection du token ──────────────── +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// ─── Response interceptor — refresh automatique ────────────── +let isRefreshing = false; +type QueueItem = { resolve: (t: string) => void; reject: (e: unknown) => void }; +let failedQueue: QueueItem[] = []; + +function processQueue(error: unknown, token: string | null) { + for (const item of failedQueue) { + if (error) item.reject(error); + else item.resolve(token!); + } + failedQueue = []; +} + +api.interceptors.response.use( + (res) => res, + async (error) => { + const orig = error.config as typeof error.config & { _retry?: boolean }; + + if (error.response?.status === 401 && !orig._retry) { + // Si déjà en train de rafraîchir, mettre en file d'attente + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + orig.headers.Authorization = `Bearer ${token}`; + return api(orig); + }); + } + + orig._retry = true; + isRefreshing = true; + + const { refreshToken, setAccessToken, logout } = useAuthStore.getState(); + + if (!refreshToken) { + logout(); + window.location.href = '/login'; + return Promise.reject(error); + } + + try { + // Appel direct axios (pas l'instance api) pour éviter la boucle + const resp = await axios.post('/api/auth/refresh', { refreshToken }); + const newToken: string = resp.data.accessToken; + setAccessToken(newToken); + processQueue(null, newToken); + orig.headers.Authorization = `Bearer ${newToken}`; + return api(orig); + } catch (refreshErr) { + processQueue(refreshErr, null); + logout(); + window.location.href = '/login'; + return Promise.reject(refreshErr); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + } +); + +export default api; diff --git a/frontend/src/components/Camera.tsx b/frontend/src/components/Camera.tsx new file mode 100644 index 0000000..cc94ee5 --- /dev/null +++ b/frontend/src/components/Camera.tsx @@ -0,0 +1,62 @@ +/** + * Composant capture photo. + * - Sur mobile : ouvre la caméra native (capture="environment") + * - Sur desktop : ouvre la galerie de fichiers + * Retourne un DataURL de l'image capturée. + */ +import { useRef } from 'react'; + +interface Props { + onCapture: (dataUrl: string, mimeType: string) => void; + disabled?: boolean; +} + +export default function Camera({ onCapture, disabled }: Props) { + const inputRef = useRef(null); + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (ev) => { + const result = ev.target?.result as string; + onCapture(result, file.type || 'image/jpeg'); + }; + reader.readAsDataURL(file); + + // Reset input pour permettre de re-sélectionner le même fichier + e.target.value = ''; + } + + return ( + <> + + + + ); +} diff --git a/frontend/src/components/GuestManager.tsx b/frontend/src/components/GuestManager.tsx new file mode 100644 index 0000000..de16b88 --- /dev/null +++ b/frontend/src/components/GuestManager.tsx @@ -0,0 +1,97 @@ +/** + * Gestion de la liste d'invités pour une facture. + * Ajout / suppression d'invités (nom + entreprise). + */ +import { useState } from 'react'; +import type { Guest } from '../types'; + +interface Props { + guests: Guest[]; + onChange: (guests: Guest[]) => void; +} + +export default function GuestManager({ guests, onChange }: Props) { + const [name, setName] = useState(''); + const [company, setCompany] = useState(''); + + function addGuest() { + const trimmed = name.trim(); + if (!trimmed) return; + onChange([ + ...guests, + { name: trimmed, company: company.trim() || null, sort_order: guests.length }, + ]); + setName(''); + setCompany(''); + } + + function removeGuest(index: number) { + onChange(guests.filter((_, i) => i !== index)); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { e.preventDefault(); addGuest(); } + } + + return ( +
+ + {/* Liste des invités ajoutés */} + {guests.length > 0 && ( +
    + {guests.map((g, i) => ( +
  • +
    +

    {g.name}

    + {g.company && ( +

    {g.company}

    + )} +
    + +
  • + ))} +
+ )} + + {/* Formulaire ajout */} +
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Nom de l'invité *" + className="form-input text-sm py-2" + /> + setCompany(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Entreprise (optionnel)" + className="form-input text-sm py-2" + /> + +
+
+ ); +} diff --git a/frontend/src/components/ImageCropper.tsx b/frontend/src/components/ImageCropper.tsx new file mode 100644 index 0000000..11f1d85 --- /dev/null +++ b/frontend/src/components/ImageCropper.tsx @@ -0,0 +1,103 @@ +/** + * Recadrage d'image avec react-image-crop. + * Permet à l'utilisateur de sélectionner la zone utile de la facture. + */ +import { useState, useRef, useCallback } from 'react'; +import ReactCrop, { type Crop, type PixelCrop, centerCrop, makeAspectCrop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; + +interface Props { + src: string; + onConfirm: (croppedDataUrl: string) => void; + onCancel: () => void; +} + +function cropImageToDataUrl( + image: HTMLImageElement, + crop: PixelCrop, + mimeType = 'image/jpeg' +): string { + const canvas = document.createElement('canvas'); + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + + canvas.width = crop.width * scaleX; + canvas.height = crop.height * scaleY; + + const ctx = canvas.getContext('2d')!; + ctx.drawImage( + image, + crop.x * scaleX, crop.y * scaleY, + crop.width * scaleX, crop.height * scaleY, + 0, 0, + canvas.width, canvas.height + ); + + return canvas.toDataURL(mimeType, 0.92); +} + +export default function ImageCropper({ src, onConfirm, onCancel }: Props) { + const imgRef = useRef(null); + const [crop, setCrop] = useState(); + const [completed, setCompleted] = useState(); + + const onImageLoad = useCallback((e: React.SyntheticEvent) => { + const { naturalWidth: w, naturalHeight: h } = e.currentTarget; + // Crop initial centré — pas de ratio imposé (factures de toutes formes) + const initial = centerCrop( + makeAspectCrop({ unit: '%', width: 90 }, w / h, w, h), + w, h + ); + setCrop(initial); + }, []); + + function handleConfirm() { + if (!completed || !imgRef.current) return; + const dataUrl = cropImageToDataUrl(imgRef.current, completed); + onConfirm(dataUrl); + } + + return ( +
+ {/* Header */} +
+ + Recadrer la facture + +
+ + {/* Cropper */} +
+ setCrop(c)} + onComplete={(c) => setCompleted(c)} + minWidth={50} + minHeight={50} + > + Facture + +
+ +

+ Faites glisser les poignées pour ajuster le recadrage +

+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..c1d16d2 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,176 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useEffect, useRef } from 'react'; +import { useAuthStore } from '../store/auth'; +import { useOnlineStatus } from '../hooks/useOnlineStatus'; +import { useOfflineQueue } from '../hooks/useOfflineQueue'; +import api from '../api/client'; +import toast from 'react-hot-toast'; + +// ─── Icônes ───────────────────────────────────────────────── + +function IconPlus({ active }: { active: boolean }) { + return ( + + + + ); +} + +function IconList({ active }: { active: boolean }) { + return ( + + + + ); +} + +function IconSettings({ active }: { active: boolean }) { + return ( + + + + + ); +} + +// ─── Layout principal ──────────────────────────────────────── + +export default function Layout() { + const { user, refreshToken, logout } = useAuthStore(); + const navigate = useNavigate(); + const isOnline = useOnlineStatus(); + const { count: queueCount, processing, processQueue } = useOfflineQueue(); + + // Auto-traitement de la file dès la reconnexion + const wasOnline = useRef(isOnline); + useEffect(() => { + if (!wasOnline.current && isOnline) { + // Petite temporisation pour laisser le réseau se stabiliser + const timer = setTimeout(() => processQueue(), 2000); + return () => clearTimeout(timer); + } + wasOnline.current = isOnline; + }, [isOnline, processQueue]); + + async function handleLogout() { + try { + await api.post('/auth/logout', { refreshToken }); + } catch { + // Ignore silencieusement + } + logout(); + navigate('/login', { replace: true }); + } + + const navLinkClass = ({ isActive }: { isActive: boolean }) => + `flex flex-col items-center gap-0.5 py-2 px-4 min-w-[64px] transition-colors ${ + isActive ? 'text-indigo-600' : 'text-gray-400' + }`; + + return ( +
+ + {/* ── Header ── */} +
+
+ NotesFrais + +
+ {/* Traitement file en cours */} + {processing && ( + + + + + + Envoi… + + )} + + {/* Indicateur hors ligne */} + {!isOnline && ( + + + Hors ligne + {queueCount > 0 && ( + + {queueCount} + + )} + + )} + + {/* Nom utilisateur + déconnexion */} +
+ {user?.name} + +
+
+
+
+ + {/* ── Contenu principal ── */} +
+ +
+ + {/* ── Barre de navigation du bas ── */} + + +
+ ); +} diff --git a/frontend/src/hooks/useOCR.ts b/frontend/src/hooks/useOCR.ts new file mode 100644 index 0000000..42cc10b --- /dev/null +++ b/frontend/src/hooks/useOCR.ts @@ -0,0 +1,129 @@ +/** + * Hook OCR avec Tesseract.js (100% local, compatible offline). + * + * Stratégie de parsing : + * 1. Montant — cherche le plus grand nombre décimal de la page + * 2. Date — cherche une date dans les formats courants (JJ/MM/AAAA, AAAA-MM-JJ, etc.) + * 3. Fournisseur — première ligne significative du texte + */ +import { useState, useCallback, useRef } from 'react'; + +export interface OcrResult { + rawText: string; + amount: string; // ex: "42.50" + date: string; // ex: "2026-04-15" (ISO) + supplier: string; +} + +interface OcrState { + loading: boolean; + progress: number; // 0-100 + result: OcrResult | null; + error: string | null; +} + +// ─── Regex helpers ──────────────────────────────────────────── + +function extractAmount(text: string): string { + // Cherche les montants : 1 234,56 / 1234.56 / 42,50 / €12.50 + const matches = text.match(/\b\d{1,6}[.,]\d{2}\b/g) ?? []; + if (!matches.length) return ''; + + // Retourne le plus grand (probablement le total) + const values = matches.map((m) => parseFloat(m.replace(',', '.'))); + const max = Math.max(...values); + return max > 0 ? max.toFixed(2) : ''; +} + +function extractDate(text: string): string { + // Formats : DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, DD.MM.YYYY + const patterns = [ + /\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{4})\b/, + /\b(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})\b/, + ]; + + for (const pat of patterns) { + const m = text.match(pat); + if (!m) continue; + + let year: number, month: number, day: number; + + if (parseInt(m[1]) > 31) { + // Format YYYY-MM-DD + [year, month, day] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])]; + } else { + // Format DD/MM/YYYY + [day, month, year] = [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])]; + } + + if (year < 2000 || year > 2100) continue; + if (month < 1 || month > 12) continue; + if (day < 1 || day > 31) continue; + + return `${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`; + } + + return new Date().toISOString().split('T')[0]; // fallback: aujourd'hui +} + +function extractSupplier(text: string): string { + const lines = text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 3 && !/^\d/.test(l) && !/^[€$£]/.test(l)); + + return lines[0]?.substring(0, 60) ?? ''; +} + +// ─── Hook ───────────────────────────────────────────────────── + +export function useOCR() { + const [state, setState] = useState({ + loading: false, + progress: 0, + result: null, + error: null, + }); + + // Ref pour garder le worker Tesseract en mémoire (évite de le recharger) + const workerRef = useRef(null); + + const run = useCallback(async (imageDataUrl: string) => { + setState({ loading: true, progress: 0, result: null, error: null }); + + try { + // Chargement lazy de Tesseract.js + const Tesseract = await import('tesseract.js'); + + if (!workerRef.current) { + workerRef.current = await Tesseract.createWorker('fra+eng', 1, { + logger: (m: any) => { + if (m.status === 'recognizing text') { + setState((s) => ({ ...s, progress: Math.round(m.progress * 100) })); + } + }, + }); + } + + const { data } = await workerRef.current.recognize(imageDataUrl); + const rawText = data.text; + + const result: OcrResult = { + rawText, + amount: extractAmount(rawText), + date: extractDate(rawText), + supplier: extractSupplier(rawText), + }; + + setState({ loading: false, progress: 100, result, error: null }); + } catch (err: any) { + setState({ loading: false, progress: 0, result: null, error: err.message }); + } + }, []); + + const reset = useCallback(() => { + setState({ loading: false, progress: 0, result: null, error: null }); + }, []); + + return { ...state, run, reset }; +} diff --git a/frontend/src/hooks/useOfflineQueue.ts b/frontend/src/hooks/useOfflineQueue.ts new file mode 100644 index 0000000..8351ad4 --- /dev/null +++ b/frontend/src/hooks/useOfflineQueue.ts @@ -0,0 +1,105 @@ +/** + * Hook — File d'attente hors-ligne + * + * Expose : + * count — nombre d'envois en attente (pour le badge nav) + * processing — vrai pendant le traitement de la file + * addToQueue — ajoute un invoice_id à la file + toast + * processQueue — rejoue tous les envois en attente + * refresh — resynchronise le count depuis IndexedDB + */ +import { useState, useEffect, useCallback } from 'react'; +import toast from 'react-hot-toast'; +import api from '../api/client'; +import { + queueGetAll, + queueAdd, + queueRemove, + queueBumpRetries, + QUEUE_EVENT, +} from '../utils/offlineQueue'; + +export function useOfflineQueue() { + const [count, setCount] = useState(0); + const [processing, setProcessing] = useState(false); + + // ── Synchronise le count depuis IndexedDB ───────────────── + + const refresh = useCallback(async () => { + try { + const items = await queueGetAll(); + setCount(items.length); + } catch { + // IndexedDB indisponible (ex: mode privé sur certains navigateurs) + } + }, []); + + // Charge au montage + useEffect(() => { + refresh(); + }, [refresh]); + + // Écoute les changements émis par d'autres instances du hook + useEffect(() => { + window.addEventListener(QUEUE_EVENT, refresh); + return () => window.removeEventListener(QUEUE_EVENT, refresh); + }, [refresh]); + + // ── Ajoute à la file ────────────────────────────────────── + + const addToQueue = useCallback(async (invoiceId: string) => { + try { + await queueAdd(invoiceId); + toast('Facture créée — envoi mis en attente', { + icon: '📶', + duration: 5000, + }); + } catch { + toast.error('Impossible de mettre en file d\'attente'); + } + }, []); + + // ── Traite la file ──────────────────────────────────────── + + const processQueue = useCallback(async () => { + if (processing) return; + + let items; + try { + items = await queueGetAll(); + } catch { + return; + } + + if (items.length === 0) return; + + setProcessing(true); + let successCount = 0; + let failCount = 0; + + for (const item of items) { + try { + await api.post(`/invoices/${item.invoiceId}/send`); + await queueRemove(item.invoiceId); + successCount++; + } catch { + await queueBumpRetries(item.invoiceId); + failCount++; + } + } + + setProcessing(false); + await refresh(); + + if (successCount > 0) { + toast.success( + `${successCount} facture${successCount > 1 ? 's' : ''} envoyée${successCount > 1 ? 's' : ''} depuis la file d'attente` + ); + } + if (failCount > 0) { + toast.error(`${failCount} envoi${failCount > 1 ? 's' : ''} toujours en échec — nouvelle tentative à la prochaine connexion`); + } + }, [processing, refresh]); + + return { count, processing, addToQueue, processQueue, refresh }; +} diff --git a/frontend/src/hooks/useOnlineStatus.ts b/frontend/src/hooks/useOnlineStatus.ts new file mode 100644 index 0000000..b0a75f6 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +/** + * Hook qui retourne le statut de connexion réseau en temps réel. + * Utilisé pour l'indicateur visuel hors ligne et la gestion de la queue offline. + */ +export function useOnlineStatus(): boolean { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return isOnline; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..78f99b1 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Import Inter depuis Google Fonts (fallback: system-ui) */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +@layer base { + html { + -webkit-tap-highlight-color: transparent; + -webkit-font-smoothing: antialiased; + } + + body { + @apply font-sans text-gray-900; + } + + /* Prevent scroll bounce on iOS but allow scrolling in containers */ + #root { + @apply min-h-screen; + } +} + +@layer components { + /* Bouton primaire réutilisable */ + .btn-primary { + @apply flex items-center justify-center gap-2 w-full py-3 px-4 + bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 + disabled:opacity-50 disabled:cursor-not-allowed + text-white font-semibold rounded-xl transition-colors + text-base select-none; + } + + /* Bouton secondaire */ + .btn-secondary { + @apply flex items-center justify-center gap-2 w-full py-3 px-4 + bg-gray-100 hover:bg-gray-200 active:bg-gray-300 + text-gray-700 font-semibold rounded-xl transition-colors + text-base select-none; + } + + /* Champ de formulaire */ + .form-input { + @apply w-full px-4 py-3 rounded-xl border border-gray-200 + focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent + text-gray-900 placeholder-gray-400 bg-white transition text-base; + } + + /* Label de formulaire */ + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + /* Card */ + .card { + @apply bg-white rounded-2xl border border-gray-100 shadow-sm; + } +} + +/* Loader spinner */ +@keyframes spin { + to { transform: rotate(360deg); } +} +.spinner { + animation: spin 0.75s linear infinite; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..37c2b9a --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,100 @@ +import { useState, FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import toast from 'react-hot-toast'; +import api from '../api/client'; +import { useAuthStore } from '../store/auth'; + +export default function Login() { + const navigate = useNavigate(); + const { setAuth } = useAuthStore(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + const res = await api.post('/auth/login', { email, password }); + setAuth(res.data.user, res.data.accessToken, res.data.refreshToken); + navigate('/new', { replace: true }); + } catch (err: any) { + toast.error(err.response?.data?.error ?? 'Connexion impossible'); + } finally { + setLoading(false); + } + } + + return ( +
+ + {/* Logo / titre */} +
+
+ + + +
+

NotesFrais

+

Gestion de notes de frais

+
+ + {/* Formulaire */} +
+
+ + setEmail(e.target.value)} + placeholder="votre@email.com" + required + autoComplete="email" + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="current-password" + className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400 transition" + /> +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/MyInvoices.tsx b/frontend/src/pages/MyInvoices.tsx new file mode 100644 index 0000000..5b9723e --- /dev/null +++ b/frontend/src/pages/MyInvoices.tsx @@ -0,0 +1,799 @@ +/** + * Page "Mes factures" — Étape 9 + * Listing filtrable et triable, récapitulatif, export CSV. + */ +import { useState, useMemo, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import api from '../api/client'; +import type { + Invoice, + InvoiceFilters, + InvoiceSummaryItem, + InvoiceStatus, + Company, + Category, +} from '../types'; + +// ─── API helpers ────────────────────────────────────────────── + +function buildParams(filters: InvoiceFilters): URLSearchParams { + const p = new URLSearchParams(); + filters.company_ids?.forEach((id) => p.append('company_ids[]', String(id))); + filters.category_ids?.forEach((id) => p.append('category_ids[]', String(id))); + if (filters.status) p.set('status', filters.status); + if (filters.date_from) p.set('date_from', filters.date_from); + if (filters.date_to) p.set('date_to', filters.date_to); + if (filters.search) p.set('search', filters.search); + if (filters.sort_by) p.set('sort_by', filters.sort_by); + if (filters.sort_dir) p.set('sort_dir', filters.sort_dir); + if (filters.page != null) p.set('page', String(filters.page)); + if (filters.limit != null) p.set('limit', String(filters.limit)); + return p; +} + +async function fetchInvoices(filters: InvoiceFilters) { + const res = await api.get(`/invoices?${buildParams(filters)}`); + return res.data as { data: Invoice[]; total: number; page: number; limit: number }; +} + +async function fetchSummary() { + const res = await api.get('/invoices/summary'); + return res.data as InvoiceSummaryItem[]; +} + +async function fetchCompanies() { + const res = await api.get('/companies'); + return res.data as Company[]; +} + +async function fetchCategories() { + const res = await api.get('/categories'); + return res.data as Category[]; +} + +// ─── Format helpers ─────────────────────────────────────────── + +function fmt(iso: string | undefined): string { + if (!iso) return '—'; + const d = iso.split('T')[0]; + const [y, m, day] = d.split('-'); + return `${day}/${m}/${y}`; +} + +function fmtAmount(n: number): string { + return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n) + ' €'; +} + +// ─── SortHeader ─────────────────────────────────────────────── + +interface SortHeaderProps { + label: string; + field: string; + current: string; + dir: 'asc' | 'desc'; + onSort: (field: string) => void; + right?: boolean; +} + +function SortHeader({ label, field, current, dir, onSort, right }: SortHeaderProps) { + const active = current === field; + return ( + onSort(field)} + className={`px-3 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide cursor-pointer select-none + whitespace-nowrap hover:text-indigo-600 transition-colors ${right ? 'text-right' : 'text-left'}`} + > + + {label} + {active ? ( + + {dir === 'asc' + ? + : } + + ) : ( + + + + )} + + + ); +} + +// ─── StatusBadge ───────────────────────────────────────────── + +interface StatusBadgeProps { + status: InvoiceStatus; + invoiceId: string; + onToggle: (id: string, next: InvoiceStatus) => void; + toggling: boolean; +} + +function StatusBadge({ status, invoiceId, onToggle, toggling }: StatusBadgeProps) { + const next: InvoiceStatus = status === 'pending' ? 'reimbursed' : 'pending'; + const label = status === 'reimbursed' ? 'Remboursé' : 'En attente'; + const colors = status === 'reimbursed' + ? 'bg-green-100 text-green-700 hover:bg-green-200' + : 'bg-amber-100 text-amber-700 hover:bg-amber-200'; + const dotColor = status === 'reimbursed' ? 'bg-green-500' : 'bg-amber-500'; + + return ( + + ); +} + +// ─── DeleteModal ────────────────────────────────────────────── + +interface DeleteModalProps { + invoice: Invoice | null; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +} + +function DeleteModal({ invoice, onConfirm, onCancel, loading }: DeleteModalProps) { + if (!invoice) return null; + return ( +
+
+
+
+
+ + + +
+
+

Supprimer la facture

+

+ {invoice.supplier || invoice.company_name} — {fmtAmount(invoice.amount)} +

+
+
+

+ Cette action est irréversible. Le PDF et les images associés seront également supprimés. +

+
+ + +
+
+
+ ); +} + +// ─── MultiSelect ────────────────────────────────────────────── + +interface MultiSelectOption { id: number; name: string } + +interface MultiSelectProps { + label: string; + options: MultiSelectOption[]; + selected: number[]; + onChange: (ids: number[]) => void; +} + +function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) { + const [open, setOpen] = useState(false); + + function toggle(id: number) { + onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]); + } + + const display = + selected.length === 0 + ? label + : selected.length === 1 + ? (options.find((o) => o.id === selected[0])?.name ?? label) + : `${selected.length} sélectionnés`; + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {options.length === 0 && ( +

Aucune option

+ )} + {options.map((opt) => ( + + ))} +
+ + )} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────── + +const PAGE_SIZE = 20; + +export default function MyInvoices() { + const qc = useQueryClient(); + + // ── Filter state ── + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [companyIds, setCompanyIds] = useState([]); + const [categoryIds, setCategoryIds] = useState([]); + const [statusFilter, setStatusFilter] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [sortBy, setSortBy] = useState('sent_at'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [page, setPage] = useState(1); + const [filtersOpen, setFiltersOpen] = useState(false); + + // ── Modal state ── + const [deleteTarget, setDeleteTarget] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + // ── Debounce search ── + const debounceTimer = useRef>(); + function handleSearchChange(val: string) { + setSearch(val); + clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => { + setDebouncedSearch(val); + setPage(1); + }, 400); + } + + // ── Filters object ── + const filters: InvoiceFilters = { + company_ids: companyIds.length ? companyIds : undefined, + category_ids: categoryIds.length ? categoryIds : undefined, + status: statusFilter || undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, + search: debouncedSearch || undefined, + sort_by: sortBy, + sort_dir: sortDir, + page, + limit: PAGE_SIZE, + }; + + const hasActiveFilters = + companyIds.length > 0 || categoryIds.length > 0 || !!statusFilter || + !!dateFrom || !!dateTo || !!debouncedSearch; + + // ── Queries ── + const { data: listData, isLoading } = useQuery({ + queryKey: ['invoices', filters], + queryFn: () => fetchInvoices(filters), + placeholderData: (prev) => prev, + }); + + const { data: summary = [] } = useQuery({ + queryKey: ['invoices-summary'], + queryFn: fetchSummary, + }); + + const { data: companies = [] } = useQuery({ + queryKey: ['companies'], + queryFn: fetchCompanies, + staleTime: 5 * 60_000, + }); + + const { data: categories = [] } = useQuery({ + queryKey: ['categories'], + queryFn: fetchCategories, + staleTime: 5 * 60_000, + }); + + // ── Mutations ── + const deleteMut = useMutation({ + mutationFn: (id: string) => api.delete(`/invoices/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['invoices'] }); + qc.invalidateQueries({ queryKey: ['invoices-summary'] }); + setDeleteTarget(null); + toast.success('Facture supprimée'); + }, + onError: () => toast.error('Erreur lors de la suppression'), + }); + + const statusMut = useMutation({ + mutationFn: ({ id, status }: { id: string; status: InvoiceStatus }) => + api.patch(`/invoices/${id}/status`, { status }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['invoices'] }); + qc.invalidateQueries({ queryKey: ['invoices-summary'] }); + setTogglingId(null); + toast.success('Statut mis à jour'); + }, + onError: () => { + setTogglingId(null); + toast.error('Erreur lors du changement de statut'); + }, + }); + + // ── Handlers ── + function handleSort(field: string) { + if (sortBy === field) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + else { setSortBy(field); setSortDir('desc'); } + setPage(1); + } + + function handleToggleStatus(id: string, next: InvoiceStatus) { + setTogglingId(id); + statusMut.mutate({ id, status: next }); + } + + async function handleDownloadPDF(inv: Invoice) { + try { + const res = await api.get(`/invoices/${inv.id}/pdf`, { responseType: 'blob' }); + const url = URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' })); + const a = Object.assign(document.createElement('a'), { + href: url, + download: inv.pdf_filename ?? `facture-${inv.id}.pdf`, + }); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + } catch { + toast.error('Erreur lors du téléchargement'); + } + } + + async function handleExportCSV() { + try { + const params = buildParams({ ...filters, page: undefined, limit: undefined }); + const res = await api.get(`/invoices/export/csv?${params}`, { responseType: 'blob' }); + const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv;charset=utf-8;' })); + const a = Object.assign(document.createElement('a'), { + href: url, + download: `factures-${new Date().toISOString().split('T')[0]}.csv`, + }); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + } catch { + toast.error("Erreur lors de l'export CSV"); + } + } + + function resetFilters() { + setCompanyIds([]); setCategoryIds([]); setStatusFilter(''); + setDateFrom(''); setDateTo(''); + setSearch(''); setDebouncedSearch(''); + setPage(1); + } + + // ── Summary pivot ── + const summaryRows = useMemo(() => { + const map = new Map(); + for (const item of summary) { + if (!map.has(item.company_name)) map.set(item.company_name, { pending: 0, reimbursed: 0 }); + const row = map.get(item.company_name)!; + const v = parseFloat(item.total) || 0; + if (item.status === 'pending') row.pending += v; + else row.reimbursed += v; + } + return Array.from(map.entries()).map(([name, v]) => ({ name, ...v, total: v.pending + v.reimbursed })); + }, [summary]); + + const grandPending = summaryRows.reduce((s, r) => s + r.pending, 0); + const grandReimbursed = summaryRows.reduce((s, r) => s + r.reimbursed, 0); + const grandTotal = grandPending + grandReimbursed; + + const invoices = listData?.data ?? []; + const total = listData?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE) || 1; + + // Compact page buttons (up to 5 around current) + const pageButtons = useMemo(() => { + if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1); + if (page <= 3) return [1, 2, 3, 4, 5]; + if (page >= totalPages - 2) return [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]; + return [page - 2, page - 1, page, page + 1, page + 2]; + }, [page, totalPages]); + + // ── Render ──────────────────────────────────────────────────── + return ( +
+ + {/* ── Récapitulatif ── */} + {summaryRows.length > 0 && ( +
+
+ + + +

Récapitulatif

+
+
+ + + + + + + + + + + {summaryRows.map((row) => ( + + + + + + + ))} + {/* Grand total row */} + + + + + + + +
SociétéEn attenteRembourséTotal
{row.name} + {row.pending > 0 ? fmtAmount(row.pending) : '—'} + + {row.reimbursed > 0 ? fmtAmount(row.reimbursed) : '—'} + + {fmtAmount(row.total)} +
Total{fmtAmount(grandPending)}{fmtAmount(grandReimbursed)}{fmtAmount(grandTotal)}
+
+
+ )} + + {/* ── Barre de filtres ── */} +
+ + {/* Ligne principale : recherche + bouton filtres + export CSV */} +
+
+ + + + handleSearchChange(e.target.value)} + placeholder="Rechercher…" + className="form-input pl-9 text-sm py-2" + /> +
+ + {/* Toggle filtres avancés */} + + + {/* Export CSV */} + +
+ + {/* Filtres avancés dépliables */} + {filtersOpen && ( +
+
+ c.is_active).map((c) => ({ id: c.id, name: c.name }))} + selected={companyIds} + onChange={(ids) => { setCompanyIds(ids); setPage(1); }} + /> + c.is_active).map((c) => ({ id: c.id, name: c.name }))} + selected={categoryIds} + onChange={(ids) => { setCategoryIds(ids); setPage(1); }} + /> +
+ +
+ + { setDateFrom(e.target.value); setPage(1); }} + className="form-input text-sm py-2" + /> + { setDateTo(e.target.value); setPage(1); }} + className="form-input text-sm py-2" + /> +
+ + {hasActiveFilters && ( + + )} +
+ )} +
+ + {/* ── Tableau ── */} +
+ + {/* En-tête du tableau : compteur + pagination info */} +
+

+ {isLoading + ? 'Chargement…' + : `${total} facture${total !== 1 ? 's' : ''}${hasActiveFilters ? ' (filtrée' + (total !== 1 ? 's' : '') + ')' : ''}`} +

+ {totalPages > 1 && ( +

Page {page} / {totalPages}

+ )} +
+ + {/* État chargement */} + {isLoading ? ( +
+ + + + + Chargement des factures… +
+ + ) : invoices.length === 0 ? ( + /* État vide */ +
+ + + +

Aucune facture trouvée

+ {hasActiveFilters && ( + + )} +
+ + ) : ( + /* Tableau des factures */ +
+ + + + + + + + + + + + + + {invoices.map((inv) => ( + + + {/* Date d'envoi */} + + + {/* Société */} + + + {/* Catégorie */} + + + {/* Fournisseur */} + + + {/* Montant */} + + + {/* Statut */} + + + {/* Actions */} + + + + ))} + +
+ Statut + + Actions +
+ {fmt(inv.sent_at)} + + {inv.company_name} + + {inv.category_name} + + {inv.supplier || '—'} + + {fmtAmount(inv.amount)} + + + +
+ + {/* Télécharger PDF */} + {inv.pdf_path && ( + + )} + + {/* Supprimer */} + + +
+
+
+ )} + + {/* Pagination */} + {!isLoading && totalPages > 1 && ( +
+ + +
+ {pageButtons.map((p) => ( + + ))} +
+ + +
+ )} + +
+ + {/* ── Modal de confirmation suppression ── */} + deleteTarget && deleteMut.mutate(deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + loading={deleteMut.isPending} + /> + +
+ ); +} diff --git a/frontend/src/pages/NewInvoice.tsx b/frontend/src/pages/NewInvoice.tsx new file mode 100644 index 0000000..46e8b33 --- /dev/null +++ b/frontend/src/pages/NewInvoice.tsx @@ -0,0 +1,473 @@ +/** + * Page "Nouvelle facture" + * Flux complet : capture → recadrage → OCR → formulaire → invités → envoi + */ +import { useState, lazy, Suspense, useRef } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import api from '../api/client'; +import Camera from '../components/Camera'; +import GuestManager from '../components/GuestManager'; +import { useOCR } from '../hooks/useOCR'; +import { useOfflineQueue } from '../hooks/useOfflineQueue'; +import type { Company, Category, Guest, InvoiceImage } from '../types'; + +// Chargement lazy du cropper (lourd, uniquement quand nécessaire) +const ImageCropper = lazy(() => import('../components/ImageCropper')); + +// ─── Types internes ─────────────────────────────────────────── + +type Step = 'capture' | 'crop' | 'form'; + +interface CapturedImage { + dataUrl: string; + filename: string; // renvoyé par le backend après upload + order: number; +} + +// ─── Composant ──────────────────────────────────────────────── + +export default function NewInvoice() { + const qc = useQueryClient(); + const { addToQueue } = useOfflineQueue(); + + // Ref pour retenir l'invoice_id créé si le send échoue hors ligne + const createdInvoiceIdRef = useRef(null); + + // ── Étape du flux ────────────────────────────────────────── + const [step, setStep] = useState('capture'); + + // ── Images capturées ─────────────────────────────────────── + const [pendingDataUrl, setPendingDataUrl] = useState(''); + const [pendingMime, setPendingMime] = useState('image/jpeg'); + const [images, setImages] = useState([]); + + // ── OCR ──────────────────────────────────────────────────── + const ocr = useOCR(); + + // ── Champs formulaire ────────────────────────────────────── + const today = new Date().toISOString().split('T')[0]; + const [companyId, setCompanyId] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [supplier, setSupplier] = useState(''); + const [amount, setAmount] = useState(''); + const [invoiceDate, setInvoiceDate] = useState(today); + const [comment, setComment] = useState(''); + const [addTracking, setAddTracking] = useState(true); + const [guests, setGuests] = useState([]); + const [showGuests, setShowGuests] = useState(false); + + // ── Data ─────────────────────────────────────────────────── + const { data: companies = [] } = useQuery({ + queryKey: ['companies'], + queryFn: () => api.get('/companies').then((r) => r.data), + }); + + const { data: categories = [] } = useQuery({ + queryKey: ['categories'], + queryFn: () => api.get('/categories').then((r) => r.data), + }); + + // ── Mutation upload image ─────────────────────────────────── + const uploadMutation = useMutation({ + mutationFn: async (dataUrl: string) => { + const blob = await (await fetch(dataUrl)).blob(); + const form = new FormData(); + form.append('image', blob, 'facture.jpg'); + const res = await api.post('/invoices/upload-image', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data.filename as string; + }, + }); + + // ── Mutation créer + envoyer facture ─────────────────────── + const sendMutation = useMutation({ + mutationFn: async () => { + if (!companyId || !categoryId || !amount || !invoiceDate) { + throw new Error('Veuillez remplir tous les champs obligatoires'); + } + if (images.length === 0) { + throw new Error('Aucune image attachée'); + } + + createdInvoiceIdRef.current = null; + + const invoiceImages: InvoiceImage[] = images.map((img) => ({ + path: img.filename, + order: img.order, + })); + + // Étape 1 : créer la facture + const createRes = await api.post('/invoices', { + company_id: parseInt(companyId), + category_id: parseInt(categoryId), + supplier: supplier || undefined, + amount: parseFloat(amount), + invoice_date: invoiceDate, + comment: comment || undefined, + images: invoiceImages, + add_to_tracking: addTracking, + guests, + }); + + // Mémoriser l'id en cas d'échec du send hors ligne + createdInvoiceIdRef.current = createRes.data.id; + + // Étape 2 : envoyer (génère PDF + email + Excel) + const sendRes = await api.post(`/invoices/${createRes.data.id}/send`); + return sendRes.data; + }, + onSuccess: () => { + toast.success('Facture envoyée avec succès !'); + qc.invalidateQueries({ queryKey: ['invoices'] }); + createdInvoiceIdRef.current = null; + resetForm(); + }, + onError: (err: any) => { + const invoiceId = createdInvoiceIdRef.current; + + // Facture créée mais envoi échoué hors ligne → mise en file d'attente + if (invoiceId && !navigator.onLine) { + addToQueue(invoiceId); + qc.invalidateQueries({ queryKey: ['invoices'] }); + createdInvoiceIdRef.current = null; + resetForm(); + return; + } + + toast.error(err.response?.data?.error ?? err.message ?? "Erreur lors de l'envoi"); + }, + }); + + // ── Handlers ─────────────────────────────────────────────── + + function handleCapture(dataUrl: string, mime: string) { + setPendingDataUrl(dataUrl); + setPendingMime(mime); + setStep('crop'); + } + + async function handleCropConfirm(croppedDataUrl: string) { + setStep('form'); + + // Upload de l'image + const toastId = toast.loading('Envoi de l\'image…'); + try { + const filename = await uploadMutation.mutateAsync(croppedDataUrl); + const newImage: CapturedImage = { + dataUrl: croppedDataUrl, + filename, + order: images.length, + }; + setImages((prev) => [...prev, newImage]); + toast.success('Image ajoutée', { id: toastId }); + + // Lancer l'OCR sur la première image uniquement + if (images.length === 0) { + ocr.run(croppedDataUrl); + } + } catch { + toast.error('Erreur lors de l\'upload', { id: toastId }); + } + } + + // Quand l'OCR se termine, pré-remplir les champs si vides + if (ocr.result && !amount && ocr.result.amount) { + setAmount(ocr.result.amount); + } + if (ocr.result && invoiceDate === today && ocr.result.date !== today) { + setInvoiceDate(ocr.result.date); + } + if (ocr.result && !supplier && ocr.result.supplier) { + setSupplier(ocr.result.supplier); + } + + function removeImage(index: number) { + setImages((prev) => prev.filter((_, i) => i !== index)); + } + + function resetForm() { + setStep('capture'); + setImages([]); + setCompanyId(''); + setCategoryId(''); + setSupplier(''); + setAmount(''); + setInvoiceDate(today); + setComment(''); + setAddTracking(true); + setGuests([]); + setShowGuests(false); + ocr.reset(); + } + + // ── Rendu ────────────────────────────────────────────────── + + // Écran de recadrage (plein écran) + if (step === 'crop' && pendingDataUrl) { + return ( + }> + { setStep(images.length > 0 ? 'form' : 'capture'); }} + /> + + ); + } + + return ( +
+

Nouvelle facture

+ + {/* ── Étape capture ── */} + {step === 'capture' && ( + + )} + + {/* ── Formulaire (après au moins une image) ── */} + {step === 'form' && ( +
+ + {/* Images capturées */} +
+
+ {images.map((img, i) => ( +
+ {`Photo + +
+ ))} + + {/* Bouton ajouter une photo supplémentaire */} + +
+ + {/* Barre de progression OCR */} + {ocr.loading && ( +
+ + + + +
+
+ Lecture automatique… {ocr.progress}% +
+
+
+
+
+
+ )} +
+ + {/* ── Formulaire ── */} +
+ + {/* Société */} +
+ + +
+ + {/* Catégorie */} +
+ + +
+ + {/* Fournisseur */} +
+ + setSupplier(e.target.value)} + placeholder="Nom du restaurant, magasin…" + className="form-input" + /> +
+ + {/* Montant + Date */} +
+
+ + setAmount(e.target.value)} + placeholder="0.00" + min="0" + step="0.01" + required + className="form-input" + /> +
+
+ + setInvoiceDate(e.target.value)} + required + className="form-input" + /> +
+
+ + {/* Commentaire */} +
+ +