From 018fb1d70f60df41e9b92db6e475f91d794d485e Mon Sep 17 00:00:00 2001 From: deploy Date: Thu, 30 Apr 2026 21:14:27 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20resolver=20DNS=20nginx=20pour=20=C3=A9vi?= =?UTF-8?q?ter=20cache=20IP=20stale=20apr=C3=A8s=20restart=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 33 - .gitignore | 6 - README.md | 323 ----- backend/.dockerignore | 5 - backend/.env.example | 26 - backend/Dockerfile | 39 - backend/docker-entrypoint.sh | 2 - backend/package-lock.json | 1526 ---------------------- backend/package.json | 40 - backend/src/config.ts | 28 - backend/src/crypto.ts | 40 - backend/src/db.ts | 20 - backend/src/index.ts | 129 -- backend/src/middleware/auth.ts | 33 - backend/src/middleware/validate.ts | 28 - backend/src/migrations/001_init.sql | 182 --- backend/src/routes/auth.ts | 134 -- backend/src/routes/categories.ts | 53 - backend/src/routes/companies.ts | 57 - backend/src/routes/contacts.ts | 150 --- backend/src/routes/invoices.ts | 447 ------- backend/src/routes/settings.ts | 154 --- backend/src/scripts/initUsers.ts | 48 - backend/src/scripts/migrate.ts | 24 - backend/src/services/email.ts | 66 - backend/src/services/pdf.ts | 126 -- backend/src/services/sharepoint.ts | 172 --- backend/tsconfig.json | 16 - docker-compose.yml | 84 -- frontend/.dockerignore | 4 - frontend/Dockerfile | 25 - frontend/index.html | 18 - frontend/nginx.conf | 16 +- frontend/package.json | 31 - frontend/postcss.config.js | 6 - frontend/src/App.tsx | 74 -- frontend/src/api/client.ts | 86 -- frontend/src/components/Camera.tsx | 62 - frontend/src/components/GuestManager.tsx | 306 ----- frontend/src/components/ImageCropper.tsx | 103 -- frontend/src/components/Layout.tsx | 176 --- frontend/src/hooks/useOCR.ts | 129 -- frontend/src/hooks/useOfflineQueue.ts | 105 -- frontend/src/hooks/useOnlineStatus.ts | 24 - frontend/src/index.css | 66 - frontend/src/main.tsx | 10 - frontend/src/pages/Login.tsx | 100 -- frontend/src/pages/MyInvoices.tsx | 799 ----------- frontend/src/pages/NewInvoice.tsx | 496 ------- frontend/src/pages/Settings.tsx | 574 -------- frontend/src/store/auth.ts | 37 - frontend/src/types/index.ts | 135 -- frontend/src/utils/offlineQueue.ts | 97 -- frontend/tailwind.config.js | 25 - frontend/tsconfig.json | 20 - frontend/tsconfig.node.json | 10 - frontend/vite.config.ts | 61 - 57 files changed, 7 insertions(+), 7579 deletions(-) delete mode 100644 .env.example delete mode 100644 .gitignore delete mode 100644 README.md delete mode 100644 backend/.dockerignore delete mode 100644 backend/.env.example delete mode 100644 backend/Dockerfile delete mode 100644 backend/docker-entrypoint.sh delete mode 100644 backend/package-lock.json delete mode 100644 backend/package.json delete mode 100644 backend/src/config.ts delete mode 100644 backend/src/crypto.ts delete mode 100644 backend/src/db.ts delete mode 100644 backend/src/index.ts delete mode 100644 backend/src/middleware/auth.ts delete mode 100644 backend/src/middleware/validate.ts delete mode 100644 backend/src/migrations/001_init.sql delete mode 100644 backend/src/routes/auth.ts delete mode 100644 backend/src/routes/categories.ts delete mode 100644 backend/src/routes/companies.ts delete mode 100644 backend/src/routes/contacts.ts delete mode 100644 backend/src/routes/invoices.ts delete mode 100644 backend/src/routes/settings.ts delete mode 100644 backend/src/scripts/initUsers.ts delete mode 100644 backend/src/scripts/migrate.ts delete mode 100644 backend/src/services/email.ts delete mode 100644 backend/src/services/pdf.ts delete mode 100644 backend/src/services/sharepoint.ts delete mode 100644 backend/tsconfig.json delete mode 100644 docker-compose.yml delete mode 100644 frontend/.dockerignore delete mode 100644 frontend/Dockerfile delete mode 100644 frontend/index.html delete mode 100644 frontend/package.json delete mode 100644 frontend/postcss.config.js delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/components/Camera.tsx delete mode 100644 frontend/src/components/GuestManager.tsx delete mode 100644 frontend/src/components/ImageCropper.tsx delete mode 100644 frontend/src/components/Layout.tsx delete mode 100644 frontend/src/hooks/useOCR.ts delete mode 100644 frontend/src/hooks/useOfflineQueue.ts delete mode 100644 frontend/src/hooks/useOnlineStatus.ts delete mode 100644 frontend/src/index.css delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/pages/Login.tsx delete mode 100644 frontend/src/pages/MyInvoices.tsx delete mode 100644 frontend/src/pages/NewInvoice.tsx delete mode 100644 frontend/src/pages/Settings.tsx delete mode 100644 frontend/src/store/auth.ts delete mode 100644 frontend/src/types/index.ts delete mode 100644 frontend/src/utils/offlineQueue.ts delete mode 100644 frontend/tailwind.config.js delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/tsconfig.node.json delete mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example deleted file mode 100644 index 79aa779..0000000 --- a/.env.example +++ /dev/null @@ -1,33 +0,0 @@ -# ───────────────────────────────────────────────────────────────── -# 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 deleted file mode 100644 index e30b965..0000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -*/node_modules/ -dist/ -*/dist/ -.env -*.log diff --git a/README.md b/README.md deleted file mode 100644 index dec3c1c..0000000 --- a/README.md +++ /dev/null @@ -1,323 +0,0 @@ -# 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 deleted file mode 100644 index bf66890..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -.env -uploads -*.log diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 84a3011..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# ─── 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 deleted file mode 100644 index f6457ca..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# ── 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 - -# Copier les fichiers non-TypeScript (SQL, assets) dans dist/ -RUN cp -r src/migrations dist/migrations - -# ── 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 deleted file mode 100644 index 7f61fa6..0000000 --- a/backend/docker-entrypoint.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -exec node dist/index.js diff --git a/backend/package-lock.json b/backend/package-lock.json deleted file mode 100644 index c4382c8..0000000 --- a/backend/package-lock.json +++ /dev/null @@ -1,1526 +0,0 @@ -{ - "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 deleted file mode 100644 index 5aeb4b5..0000000 --- a/backend/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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", - "csv-parse": "^5.5.6", - "xlsx": "^0.18.5" - }, - "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 deleted file mode 100644 index 66151f3..0000000 --- a/backend/src/config.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index b1903b1..0000000 --- a/backend/src/crypto.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 deleted file mode 100644 index 72d1178..0000000 --- a/backend/src/db.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Pool } from 'pg'; -import { config } from './config'; - -export const db = new Pool({ - connectionString: config.databaseUrl, - // Pas de SSL forcé — connexion interne Docker (réseau privé) - max: 10, - connectionTimeoutMillis: 10000, // Erreur si connexion impossible après 10s - idleTimeoutMillis: 60000, // Libérer connexions inactives après 60s -}); - -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 deleted file mode 100644 index bbb972d..0000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import 'dotenv/config'; -import express from 'express'; -import cors from 'cors'; -import helmet from 'helmet'; -import path from 'path'; -import fs from 'fs'; -import bcrypt from 'bcryptjs'; -import { config } from './config'; -import { db, 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'; -import contactsRoutes from './routes/contacts'; - -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' })); - -// ─── 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); -app.use('/api/contacts', contactsRoutes); - -// ─── Health check (accessible via /health ET /api/health) ────── -app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' })); -app.get('/api/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0', uptime: process.uptime() })); - -// ─── 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: err.message, - type: err.constructor.name, - }); -}); - -// ─── Initialisation DB + migration (arrière-plan) ───────────── -async function runMigration(): Promise { - const sqlPath = path.join(__dirname, 'migrations/001_init.sql'); - const sql = fs.readFileSync(sqlPath, 'utf8'); - await db.query(sql); - console.log('✅ Migration terminée'); -} - -async function runInitUsers(): Promise { - 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, 12); - await db.query( - `INSERT INTO users (name, email, password_hash) - VALUES ($1, $2, $3) - ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`, - [user.name, user.email, hash] - ); - console.log(` ✅ Utilisateur prêt : ${user.name} <${user.email}>`); - } -} - -async function waitForDb(maxAttempts = 30, delayMs = 2000): Promise { - for (let i = 1; i <= maxAttempts; i++) { - try { - await testConnection(); - return; - } catch { - console.log(` PostgreSQL non prêt (${i}/${maxAttempts}), attente...`); - await new Promise(r => setTimeout(r, delayMs)); - } - } - throw new Error(`PostgreSQL inaccessible après ${maxAttempts} tentatives`); -} - -// ─── Démarrage ──────────────────────────────────────────────── -async function start() { - // Créer les répertoires uploads - try { - fs.mkdirSync(path.join(config.uploadsDir, 'images'), { recursive: true }); - fs.mkdirSync(path.join(config.uploadsDir, 'pdfs'), { recursive: true }); - } catch (err: any) { - console.warn('Avertissement: impossible de créer les répertoires uploads:', err.message); - } - - // ── Bind du port IMMÉDIATEMENT ───────────────────────────── - 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}`); - console.log(` DATABASE_URL: ${process.env.DATABASE_URL - ? process.env.DATABASE_URL.replace(/:([^@]+)@/, ':***@') - : '[non défini — utilise défaut localhost]'}`); - }); - - // ── Initialisation DB en arrière-plan ───────────────────── - (async () => { - try { - await waitForDb(); - await runMigration(); - await runInitUsers(); - console.log('✅ Base de données prête'); - } catch (err: any) { - console.error('⚠️ Initialisation DB échouée (non bloquant):', err.message); - // Le serveur continue — les routes retourneront des 500 si la DB est inaccessible - } - })(); -} - -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 deleted file mode 100644 index 7e2b9d5..0000000 --- a/backend/src/middleware/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 5cdba39..0000000 --- a/backend/src/middleware/validate.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index fac6730..0000000 --- a/backend/src/migrations/001_init.sql +++ /dev/null @@ -1,182 +0,0 @@ --- ============================================================= --- 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() -); - --- ============================================================= --- CONTACTS (répertoire d'invités réutilisables) --- Utilisés comme suggestions dans le formulaire de facture --- ============================================================= -CREATE TABLE IF NOT EXISTS contacts ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - company VARCHAR(255), - sort_order INTEGER DEFAULT 0, - 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); -CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); - --- ============================================================= --- 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 OR REPLACE TRIGGER set_updated_at_users - BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); - -CREATE OR REPLACE TRIGGER set_updated_at_companies - BEFORE UPDATE ON companies - FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); - -CREATE OR REPLACE 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 deleted file mode 100644 index 1f3e9de..0000000 --- a/backend/src/routes/auth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import express, { 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, next: express.NextFunction): Promise => { - try { - 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 }, - }); - } catch (err) { - next(err); - } -}); - -/** - * 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, next: express.NextFunction): Promise => { - try { - const { refreshToken } = req.body; - if (refreshToken) { - await db.query('DELETE FROM refresh_tokens WHERE token_hash = $1', [hashToken(refreshToken)]); - } - res.json({ success: true }); - } catch (err) { next(err); } -}); - -/** - * GET /api/auth/me - * Retourne l'utilisateur connecté (sans données sensibles). - */ -router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: express.NextFunction): Promise => { - try { - const result = await db.query( - `SELECT id, name, email, - smtp_host, smtp_port, smtp_secure, smtp_user, - smtp_from_name, smtp_from_email - FROM users WHERE id = $1`, - [req.user!.id] - ); - res.json(result.rows[0]); - } catch (err) { next(err); } -}); - -export default router; diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts deleted file mode 100644 index a3f1fd4..0000000 --- a/backend/src/routes/categories.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Router, Response, NextFunction } 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 wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => - (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); - -const categorySchema = z.object({ - name: z.string().min(1).max(100), - sort_order: z.number().int().optional().default(0), -}); - -/** GET /api/categories */ -router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise => { - const result = await db.query( - 'SELECT * FROM categories WHERE is_active=TRUE ORDER BY sort_order, name' - ); - res.json(result.rows); -})); - -/** POST /api/categories */ -router.post('/', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, sort_order } = req.body; - const result = await db.query( - 'INSERT INTO categories (name, sort_order) VALUES ($1, $2) RETURNING *', - [name, sort_order] - ); - res.status(201).json(result.rows[0]); -})); - -/** PUT /api/categories/:id */ -router.put('/:id', validate(categorySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, sort_order } = req.body; - const result = await db.query( - 'UPDATE categories SET name=$1, sort_order=$2 WHERE id=$3 AND is_active=TRUE RETURNING *', - [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', wrap(async (req: AuthRequest, res: Response): Promise => { - await db.query('UPDATE categories SET is_active=FALSE WHERE id=$1', [req.params.id]); - res.json({ success: true }); -})); - -export default router; diff --git a/backend/src/routes/companies.ts b/backend/src/routes/companies.ts deleted file mode 100644 index fc7f19b..0000000 --- a/backend/src/routes/companies.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Router, Response, NextFunction } 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 wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => - (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); - -const companySchema = z.object({ - name: z.string().min(1).max(255), - email: z.string().email(), -}); - -/** GET /api/companies — Liste toutes les sociétés actives */ -router.get('/', wrap(async (_req: AuthRequest, res: Response): Promise => { - const result = await db.query( - 'SELECT * FROM companies WHERE is_active = TRUE ORDER BY name' - ); - res.json(result.rows); -})); - -/** POST /api/companies — Crée une société */ -router.post('/', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, email } = req.body; - const result = await db.query( - `INSERT INTO companies (name, email) VALUES ($1, $2) RETURNING *`, - [name, email] - ); - res.status(201).json(result.rows[0]); -})); - -/** PUT /api/companies/:id — Met à jour une société */ -router.put('/:id', validate(companySchema), wrap(async (req: AuthRequest, res: Response): Promise => { - const { name, email } = req.body; - const result = await db.query( - `UPDATE companies SET name=$1, email=$2, updated_at=NOW() - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - await db.query( - 'UPDATE companies SET is_active=FALSE, updated_at=NOW() WHERE id=$1', - [req.params.id] - ); - res.json({ success: true }); -})); - -export default router; diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts deleted file mode 100644 index 7d27c50..0000000 --- a/backend/src/routes/contacts.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Router, RequestHandler } from 'express'; -import multer from 'multer'; -import { db } from '../db'; -import { requireAuth } from '../middleware/auth'; -import { parse as csvParse } from 'csv-parse/sync'; -import * as XLSX from 'xlsx'; - -const router = Router(); -router.use(requireAuth); - -// Multer en mémoire (CSV/XLSX, max 5 Mo) -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 5 * 1024 * 1024 }, - fileFilter: (_req, file, cb) => { - const ok = [ - 'text/csv', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/octet-stream', - ].includes(file.mimetype) || file.originalname.match(/\.(csv|xls|xlsx)$/i); - cb(null, !!ok); - }, -}); - -function wrap(fn: RequestHandler): RequestHandler { - return (req, res, next) => (fn(req, res, next) as any).catch(next); -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** Normalise une ligne brute en { name, company } */ -function normalizeRow(row: Record): { name: string; company: string | null } | null { - // Cherche les colonnes "nom"/"name" et "société"/"company" indépendamment de la casse - const keys = Object.keys(row); - - const nameKey = keys.find((k) => - /^(nom|name|prénom|prenom|contact)$/i.test(k.trim()) - ); - const companyKey = keys.find((k) => - /^(soci[eé]t[eé]|company|entreprise|organisation|organization|employeur|employer)$/i.test(k.trim()) - ); - - // Fallback : première colonne = nom, deuxième = société - const name = (nameKey ? row[nameKey] : row[keys[0]] ?? '').toString().trim(); - const company = companyKey - ? (row[companyKey] ?? '').toString().trim() || null - : keys[1] - ? (row[keys[1]] ?? '').toString().trim() || null - : null; - - if (!name) return null; - return { name, company }; -} - -/** Parse un buffer CSV → tableau de lignes normalisées */ -function parseCSV(buf: Buffer): Array<{ name: string; company: string | null }> { - const rows = csvParse(buf, { - columns: true, - skip_empty_lines: true, - trim: true, - bom: true, - }) as Record[]; - return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>; -} - -/** Parse un buffer XLSX → tableau de lignes normalisées */ -function parseXLSX(buf: Buffer): Array<{ name: string; company: string | null }> { - const wb = XLSX.read(buf, { type: 'buffer' }); - const ws = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json>(ws, { defval: '' }); - return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>; -} - -// ── Routes ─────────────────────────────────────────────────────────────────── - -/** GET /api/contacts — liste tous les contacts triés */ -router.get('/', wrap(async (_req, res) => { - const { rows } = await db.query( - 'SELECT id, name, company, sort_order FROM contacts ORDER BY name ASC' - ); - res.json(rows); -})); - -/** POST /api/contacts — ajoute un contact manuel */ -router.post('/', wrap(async (req, res) => { - const { name, company } = req.body as { name?: string; company?: string }; - if (!name?.trim()) { - return res.status(400).json({ error: 'Le nom est requis.' }); - } - const { rows } = await db.query( - 'INSERT INTO contacts (name, company) VALUES ($1, $2) RETURNING id, name, company, sort_order', - [name.trim(), company?.trim() || null] - ); - res.status(201).json(rows[0]); -})); - -/** POST /api/contacts/import — import CSV ou XLSX */ -router.post('/import', upload.single('file'), wrap(async (req, res) => { - if (!req.file) { - return res.status(400).json({ error: 'Aucun fichier fourni.' }); - } - - const isXLSX = - req.file.originalname.match(/\.(xls|xlsx)$/i) || - req.file.mimetype.includes('spreadsheet') || - req.file.mimetype.includes('ms-excel'); - - let contacts: Array<{ name: string; company: string | null }>; - try { - contacts = isXLSX ? parseXLSX(req.file.buffer) : parseCSV(req.file.buffer); - } catch (err: any) { - return res.status(422).json({ error: `Impossible de lire le fichier : ${err.message}` }); - } - - if (contacts.length === 0) { - return res.status(422).json({ error: 'Aucun contact trouvé dans le fichier. Vérifiez les colonnes (Nom, Société).' }); - } - - // Upsert : si (name, company) existe déjà, on ne duplique pas - let inserted = 0; - let skipped = 0; - - for (const c of contacts) { - const exists = await db.query( - 'SELECT id FROM contacts WHERE LOWER(name) = LOWER($1) AND (company IS NULL AND $2::TEXT IS NULL OR LOWER(company) = LOWER($2))', - [c.name, c.company] - ); - if (exists.rowCount && exists.rowCount > 0) { - skipped++; - } else { - await db.query( - 'INSERT INTO contacts (name, company) VALUES ($1, $2)', - [c.name, c.company] - ); - inserted++; - } - } - - res.json({ inserted, skipped, total: contacts.length }); -})); - -/** DELETE /api/contacts/:id */ -router.delete('/:id', wrap(async (req, res) => { - const { rowCount } = await db.query('DELETE FROM contacts WHERE id = $1', [req.params.id]); - if (!rowCount) return res.status(404).json({ error: 'Contact introuvable.' }); - res.json({ success: true }); -})); - -export default router; diff --git a/backend/src/routes/invoices.ts b/backend/src/routes/invoices.ts deleted file mode 100644 index ee20b81..0000000 --- a/backend/src/routes/invoices.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { Router, Response, NextFunction } 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); - -// Wrapper pour éviter les unhandledRejection qui crashent Node 20 -function wrap(fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) { - return (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); -} - -// ─── Upload images ──────────────────────────────────────────── - -const storage = multer.diskStorage({ - 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'), wrap(async (req: AuthRequest, res: Response): Promise => { - if (!req.file) { - res.status(400).json({ error: 'Aucun fichier reçu ou format non supporté (jpg/png/webp)' }); - return; - } - res.json({ filename: req.file.filename }); -})); - -// ─── Schémas de validation ──────────────────────────────────── - -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', wrap(async (req: AuthRequest, res: Response): Promise => { - const result = await db.query( - `SELECT co.name AS company_name, i.status, - SUM(i.amount) AS total, - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - const { company_ids, category_ids, status, date_from, date_to, search } = req.query; - - const conditions: string[] = ['i.user_id = $1']; - 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('/', wrap(async (req: AuthRequest, res: Response): Promise => { - const { company_ids, category_ids, status, date_from, date_to, search, - sort_by, sort_dir, page, limit } = req.query; - - 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), wrap(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', wrap(async (req: AuthRequest, res: Response): Promise => { - const invoice = await getInvoiceById(req.params.id); - if (!invoice) { res.status(404).json({ error: 'Facture introuvable' }); return; } - if (invoice.user_id !== req.user!.id) { res.status(403).json({ error: 'Accès refusé' }); return; } - - 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, user.name); - - // ── Envoi email ─────────────────────────────────────────── - await sendInvoiceEmail(user, invoice.company_email, pdfPath, pdfFilename); - - // ── SharePoint (non bloquant) ───────────────────────────── - let trackingAdded = false; - let trackingError: string | null = null; - 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é, mais on remonte l'erreur - // pour que le frontend puisse afficher un avertissement. - console.warn('[SharePoint] Erreur non bloquante :', err.message); - trackingError = 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] - ); - - const updated = await getInvoiceById(invoice.id); - res.json({ ...updated, tracking_error: trackingError }); -})); - -/** - * GET /api/invoices/:id - */ -router.get('/:id', wrap(async (req: AuthRequest, res: Response): Promise => { - const invoice = await getInvoiceById(req.params.id); - if (!invoice || invoice.user_id !== req.user!.id) { - res.status(404).json({ error: 'Facture introuvable' }); - return; - } - res.json(invoice); -})); - -/** - * PATCH /api/invoices/:id/status - * Toggle du statut (pending ↔ reimbursed) - */ -router.patch('/:id/status', wrap(async (req: AuthRequest, res: Response): Promise => { - const { status } = req.body; - if (!['pending', 'reimbursed'].includes(status)) { - res.status(400).json({ error: 'Statut invalide (pending ou reimbursed)' }); - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - const result = await db.query( - 'SELECT pdf_path, pdf_filename FROM invoices WHERE id=$1 AND user_id=$2', - [req.params.id, req.user!.id] - ); - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - const result = await db.query( - 'DELETE FROM invoices WHERE id=$1 AND user_id=$2 RETURNING id, pdf_path, images', - [req.params.id, req.user!.id] - ); - 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 deleted file mode 100644 index ad7dbcf..0000000 --- a/backend/src/routes/settings.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Router, Response, NextFunction } 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); - -const wrap = (fn: (req: AuthRequest, res: Response, next: NextFunction) => Promise) => - (req: AuthRequest, res: Response, next: NextFunction) => fn(req, res, next).catch(next); - -// ─── Schémas ───────────────────────────────────────────────── - -const smtpSchema = z.object({ - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - const result = await db.query( - `SELECT smtp_host, smtp_port, smtp_secure, smtp_user, - smtp_from_name, smtp_from_email, - (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), wrap(async (req: AuthRequest, res: Response): Promise => { - const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from_name, smtp_from_email } = req.body; - - if (smtp_pass) { - 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', wrap(async (req: AuthRequest, res: Response): Promise => { - const userResult = await db.query('SELECT * FROM users WHERE id=$1', [req.user!.id]); - const user = userResult.rows[0]; - - 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', wrap(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), wrap(async (req: AuthRequest, res: Response): Promise => { - const { - graph_tenant_id, graph_client_id, graph_client_secret, - sharepoint_site_id, sharepoint_item_id, sharepoint_sheet_name, - } = 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 }); -})); - -/** POST /api/settings/sharepoint/test — Vérifie la connexion Graph + accès au fichier Excel */ -router.post('/sharepoint/test', wrap(async (_req: AuthRequest, res: Response): Promise => { - try { - const { testSharepointConnection } = await import('../services/sharepoint'); - await testSharepointConnection(); - res.json({ success: true, message: 'Connexion SharePoint OK — fichier Excel accessible.' }); - } catch (err: any) { - res.status(400).json({ error: err.message }); - } -})); - -export default router; diff --git a/backend/src/scripts/initUsers.ts b/backend/src/scripts/initUsers.ts deleted file mode 100644 index b3f6563..0000000 --- a/backend/src/scripts/initUsers.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 deleted file mode 100644 index e9aee5f..0000000 --- a/backend/src/scripts/migrate.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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 deleted file mode 100644 index 863e8f8..0000000 --- a/backend/src/services/email.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index ea37df4..0000000 --- a/backend/src/services/pdf.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -// @ts-ignore -const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); - -import fs from 'fs/promises'; -import path from 'path'; - -export interface PdfGuest { - name: string; - company?: string | null; -} - -/** - * Génère le PDF de la facture. - * - Une page par image (ticket/reçu) - * - Si des invités sont présents, la liste est ajoutée EN BAS de la dernière - * page d'image (même page, sous le ticket), sans page séparée. - * - userName : prénom affiché dans l'en-tête "Invité par …" - */ -export async function generateInvoicePdf( - imagePaths: string[], - guests: PdfGuest[], - outputPath: string, - userName = 'Moi' -): Promise { - const pdfDoc = await PDFDocument.create(); - - const font = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.Helvetica) : null; - const fontBold = guests.length > 0 ? await pdfDoc.embedFont(StandardFonts.HelveticaBold) : null; - - for (let idx = 0; idx < imagePaths.length; idx++) { - const imgPath = imagePaths[idx]; - const imgBytes = await fs.readFile(imgPath); - const ext = path.extname(imgPath).toLowerCase(); - const image = ext === '.png' - ? await pdfDoc.embedPng(imgBytes) - : await pdfDoc.embedJpg(imgBytes); - - // Normaliser à une largeur A4 (595pt) pour que la section invités - // soit visible. Sans normalisation, image.width est en pixels - // (ex. 3024px), ce qui donne une page de 42 pouces de large et - // rend la section invités imperceptible en bas de page. - const PAGE_W = 595; - const scale = PAGE_W / image.width; - const imgW = PAGE_W; - const imgH = Math.round(image.height * scale); - - const isLast = idx === imagePaths.length - 1; - const addList = isLast && guests.length > 0; - - // Hauteur supplémentaire pour la liste d'invités (sous le ticket) - const PADDING = 24; // marge entre ticket et liste - const LINE_H = 44; // hauteur par ligne d'invité (×2) - const HEADER_H = 80; // titre + séparateur (×2) - const listH = addList - ? HEADER_H + guests.length * LINE_H + PADDING * 2 - : 0; - - const pageW = imgW; - const pageH = imgH + listH; - - const page = pdfDoc.addPage([pageW, pageH]); - - // ── Image (placée en haut de la page) ────────────────────── - page.drawImage(image, { x: 0, y: listH, width: imgW, height: imgH }); - - // ── Liste des invités (en bas, sous l'image) ─────────────── - if (addList && font && fontBold) { - const M = 30; // marge gauche/droite - let y = listH - PADDING; - - // Ligne de séparation entre ticket et liste - page.drawLine({ - start: { x: M, y: listH - 1 }, - end: { x: pageW - M, y: listH - 1 }, - thickness: 1, - color: rgb(0.8, 0.8, 0.8), - }); - - // Titre "Invité par " - page.drawText(`Invité par ${userName}`, { - x: M, y, - size: 26, - font: fontBold, - color: rgb(0.15, 0.15, 0.15), - }); - y -= HEADER_H - PADDING - 10; - - // Séparateur sous le titre - page.drawLine({ - start: { x: M, y }, - end: { x: pageW - M, y }, - thickness: 0.5, - color: rgb(0.75, 0.75, 0.75), - }); - y -= 22; - - // Lignes invités - for (const guest of guests) { - if (y < 4) break; - page.drawText(guest.name, { - x: M, y, - size: 22, - font: fontBold, - color: rgb(0.1, 0.1, 0.1), - maxWidth: guest.company ? (pageW - M * 2) / 2 - 10 : pageW - M * 2, - }); - if (guest.company) { - page.drawText(guest.company, { - x: M + (pageW - M * 2) / 2, - y, - size: 22, - font, - color: rgb(0.35, 0.35, 0.35), - maxWidth: (pageW - M * 2) / 2, - }); - } - y -= LINE_H; - } - } - } - - 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 deleted file mode 100644 index 31e3b5d..0000000 --- a/backend/src/services/sharepoint.ts +++ /dev/null @@ -1,172 +0,0 @@ -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; -} - -// ─── Test de connexion (sans écriture) ─────────────────────── - -/** - * Vérifie que le token Graph s'obtient et que la feuille Excel est accessible. - * Utilisé par POST /api/settings/sharepoint/test. - */ -export async function testSharepointConnection(): Promise { - const cfg = await getGraphConfig(); - const token = await getAccessToken(cfg); - - const baseUrl = `https://graph.microsoft.com/v1.0/sites/${cfg.sharepointSiteId}/drive/items/${cfg.sharepointItemId}/workbook/worksheets/${encodeURIComponent(cfg.sharepointSheet)}`; - const resp = await fetch(`${baseUrl}/usedRange?$select=rowCount`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!resp.ok) { - const body = await resp.text(); - throw new Error(`Impossible d'accéder à la feuille "${cfg.sharepointSheet}" (${resp.status}) : ${body.slice(0, 200)}`); - } -} - -// ─── 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 deleted file mode 100644 index 9ef84e3..0000000 --- a/backend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 319d73a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,84 +0,0 @@ -services: - - # ── PostgreSQL ────────────────────────────────────────────── - db: - image: postgres:16-alpine - environment: - POSTGRES_DB: notesfrais - POSTGRES_USER: notesfrais - POSTGRES_PASSWORD: 9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b - volumes: - - pgdata4:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"] - interval: 5s - timeout: 5s - retries: 12 - restart: unless-stopped - networks: - - internal - - # ── Backend Express ───────────────────────────────────────── - backend: - build: - context: ./backend - dockerfile: Dockerfile - environment: - NODE_ENV: production - PORT: 3001 - DATABASE_URL: postgresql://notesfrais:9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b@db:5432/notesfrais - JWT_SECRET: f1bec20689d176182a33b6904a236d9c207322fb5886e6599812bfbc236bd95bff4d1a05edb273a6ccdb6e06004e059241f7937ef91dee08b92fc25f0ea767c1 - APP_SECRET: bbec693632ddd25adeefaddfa64a3e8e1245a97f530cb492f1d3f496ae3a1936 - UPLOADS_DIR: /app/uploads - FRONTEND_URL: https://frais.domench.fr - GAEL_EMAIL: waltergael@1dotech.com - GAEL_PASSWORD: Changeme123! - GREG_EMAIL: waltergreg@1dotech.com - GREG_PASSWORD: Changeme123! - volumes: - - uploads:/app/uploads - depends_on: - db: - condition: service_healthy - restart: unless-stopped - networks: - internal: - aliases: - - notesfrais-backend - - # ── Frontend nginx ────────────────────────────────────────── - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - depends_on: - - backend - restart: unless-stopped - expose: - - "80" - networks: - - internal - - coolify - labels: - - "traefik.enable=true" - - "traefik.docker.network=coolify" - - "traefik.http.routers.notesfrais.rule=Host(`frais.domench.fr`)" - - "traefik.http.routers.notesfrais.entrypoints=https" - - "traefik.http.routers.notesfrais.tls=true" - - "traefik.http.routers.notesfrais.tls.certresolver=letsencrypt" - - "traefik.http.services.notesfrais.loadbalancer.server.port=80" - - "traefik.http.routers.notesfrais-http.rule=Host(`frais.domench.fr`)" - - "traefik.http.routers.notesfrais-http.entrypoints=http" - - "traefik.http.routers.notesfrais-http.middlewares=notesfrais-https-redirect" - - "traefik.http.middlewares.notesfrais-https-redirect.redirectscheme.scheme=https" - - "traefik.http.middlewares.notesfrais-https-redirect.redirectscheme.permanent=true" - -networks: - internal: - driver: bridge - coolify: - external: true - -volumes: - pgdata4: - uploads: diff --git a/frontend/.dockerignore b/frontend/.dockerignore deleted file mode 100644 index 3b24e4a..0000000 --- a/frontend/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.env -*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 5971b5a..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# ── 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 deleted file mode 100644 index 497d3e7..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - NotesFrais - - -
- - - diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e4f6248..027c743 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,6 +5,10 @@ server { root /usr/share/nginx/html; index index.html; + # Résolveur Docker interne — re-résolution DNS à chaque requête (évite le cache IP stale) + resolver 127.0.0.11 valid=30s ipv6=off; + resolver_timeout 5s; + # ── Compression ─────────────────────────────────────────── gzip on; gzip_vary on; @@ -22,7 +26,8 @@ server { # ── API → backend (service Docker interne) ──────────────── location /api/ { - proxy_pass http://notesfrais-backend:3001/api/; + set $upstream_backend http://notesfrais-backend:3001; + proxy_pass $upstream_backend/api/; proxy_http_version 1.1; proxy_set_header Host $host; @@ -34,11 +39,4 @@ server { 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; - } -} + pr \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index fb0f633..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index 2aa7205..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 4d934b7..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index e47afb1..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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 deleted file mode 100644 index cc94ee5..0000000 --- a/frontend/src/components/Camera.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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 deleted file mode 100644 index 15992ab..0000000 --- a/frontend/src/components/GuestManager.tsx +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Gestion de la liste d'invités pour une facture. - * - Autocomplete : suggestions depuis le répertoire contacts au fur et à mesure de la frappe - * - Panneau de sélection multiple : bouton "Choisir dans le répertoire" → liste cochable - * - Saisie libre toujours possible (invité hors répertoire) - */ -import { useState, useImperativeHandle, forwardRef, useRef, useEffect } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import api from '../api/client'; -import type { Guest, Contact } from '../types'; - -export interface GuestManagerHandle { - /** - * Si un invité est en cours de saisie (champ non validé), l'ajoute à la liste - * et retourne la liste complète (incluant ce nouvel invité). - * Retourne null si rien à flusher. - */ - flushPending: () => Guest[] | null; -} - -interface Props { - guests: Guest[]; - onChange: (guests: Guest[]) => void; -} - -const GuestManager = forwardRef(function GuestManager({ guests, onChange }, ref) { - const [name, setName] = useState(''); - const [company, setCompany] = useState(''); - const [showAC, setShowAC] = useState(false); // autocomplete dropdown visible - const [showPanel, setShowPanel] = useState(false); // panneau de sélection multiple - const [panelSearch, setPanelSearch] = useState(''); - const nameInputRef = useRef(null); - const acRef = useRef(null); - - // ── Chargement du répertoire ───────────────────────────────── - const { data: allContacts = [] } = useQuery({ - queryKey: ['contacts'], - queryFn: () => api.get('/contacts').then(r => r.data), - staleTime: 5 * 60 * 1000, - }); - - // ── Autocomplete ───────────────────────────────────────────── - const acSuggestions: Contact[] = name.trim().length >= 1 - ? allContacts.filter(c => - c.name.toLowerCase().includes(name.toLowerCase()) && - !guests.some(g => g.name.toLowerCase() === c.name.toLowerCase()) - ).slice(0, 6) - : []; - - // Ferme le dropdown si clic dehors - useEffect(() => { - function handleClick(e: MouseEvent) { - if (acRef.current && !acRef.current.contains(e.target as Node)) { - setShowAC(false); - } - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, []); - - // ── Panel : contacts déjà dans la liste (pour l'état coché) ── - const alreadyAdded = new Set(guests.map(g => g.name.toLowerCase())); - const panelFiltered = panelSearch.trim() - ? allContacts.filter(c => - c.name.toLowerCase().includes(panelSearch.toLowerCase()) || - (c.company ?? '').toLowerCase().includes(panelSearch.toLowerCase()) - ) - : allContacts; - - // ── Implémentation de flushPending (ref parent) ─────────────── - useImperativeHandle(ref, () => ({ - flushPending() { - const trimmed = name.trim(); - if (!trimmed) return null; - const updated: Guest[] = [ - ...guests, - { name: trimmed, company: company.trim() || null, sort_order: guests.length }, - ]; - onChange(updated); - setName(''); - setCompany(''); - return updated; - }, - })); - - // ── Actions ────────────────────────────────────────────────── - function addGuest(overrideName?: string, overrideCompany?: string | null) { - const n = (overrideName ?? name).trim(); - if (!n) return; - if (guests.some(g => g.name.toLowerCase() === n.toLowerCase())) { - setName(''); setCompany(''); - return; - } - onChange([ - ...guests, - { name: n, company: overrideCompany !== undefined ? overrideCompany : company.trim() || null, sort_order: guests.length }, - ]); - setName(''); - setCompany(''); - setShowAC(false); - } - - function removeGuest(index: number) { - onChange(guests.filter((_, i) => i !== index)); - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter') { e.preventDefault(); addGuest(); } - if (e.key === 'Escape') setShowAC(false); - } - - function selectFromAC(c: Contact) { - addGuest(c.name, c.company ?? null); - nameInputRef.current?.focus(); - } - - function toggleFromPanel(c: Contact) { - const lower = c.name.toLowerCase(); - if (alreadyAdded.has(lower)) { - // retirer - onChange(guests.filter(g => g.name.toLowerCase() !== lower)); - } else { - // ajouter - onChange([ - ...guests, - { name: c.name, company: c.company ?? null, sort_order: guests.length }, - ]); - } - } - - return ( -
- - {/* Invités déjà ajoutés */} - {guests.length > 0 && ( -
    - {guests.map((g, i) => ( -
  • -
    -

    {g.name}

    - {g.company &&

    {g.company}

    } -
    - -
  • - ))} -
- )} - - {/* Formulaire + autocomplete */} -
- - {/* Champ nom avec autocomplete */} -
- { setName(e.target.value); setShowAC(true); }} - onFocus={() => setShowAC(true)} - onKeyDown={handleKeyDown} - placeholder="Nom de l'invité *" - className="form-input text-sm py-2" - autoComplete="off" - /> - {/* Dropdown autocomplete */} - {showAC && acSuggestions.length > 0 && ( -
- {acSuggestions.map(c => ( - - ))} -
- )} -
- - setCompany(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Entreprise (optionnel)" - className="form-input text-sm py-2" - /> - - {/* Actions */} -
- - - {allContacts.length > 0 && ( - - )} -
-
- - {/* Panneau de sélection multiple (overlay) */} - {showPanel && ( -
{ if (e.target === e.currentTarget) setShowPanel(false); }}> -
- {/* Header */} -
-

Choisir des invités

- -
- - {/* Recherche */} -
- setPanelSearch(e.target.value)} - placeholder="Rechercher…" - className="form-input text-sm py-2" - /> -
- - {/* Liste */} -
- {panelFiltered.length === 0 ? ( -

Aucun contact trouvé

- ) : ( - panelFiltered.map(c => { - const checked = alreadyAdded.has(c.name.toLowerCase()); - return ( - - ); - }) - )} -
- - {/* Footer */} -
- -
-
-
- )} -
- ); -}); - -export default GuestManager; diff --git a/frontend/src/components/ImageCropper.tsx b/frontend/src/components/ImageCropper.tsx deleted file mode 100644 index 11f1d85..0000000 --- a/frontend/src/components/ImageCropper.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 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 deleted file mode 100644 index c1d16d2..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index 42cc10b..0000000 --- a/frontend/src/hooks/useOCR.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8351ad4..0000000 --- a/frontend/src/hooks/useOfflineQueue.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 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 deleted file mode 100644 index b0a75f6..0000000 --- a/frontend/src/hooks/useOnlineStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 78f99b1..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,66 +0,0 @@ -@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 deleted file mode 100644 index 2339d59..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 37c2b9a..0000000 --- a/frontend/src/pages/Login.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 5b9723e..0000000 --- a/frontend/src/pages/MyInvoices.tsx +++ /dev/null @@ -1,799 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7d51a32..0000000 --- a/frontend/src/pages/NewInvoice.tsx +++ /dev/null @@ -1,496 +0,0 @@ -/** - * Page "Nouvelle facture" - * Flux complet : capture → recadrage → OCR → formulaire → invités → envoi - */ -import { useState, lazy, Suspense, useRef, useEffect } 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, { type GuestManagerHandle } 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); - - // Ref vers GuestManager pour flusher l'invité en cours de saisie - const guestManagerRef = 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), - }); - - // Auto-ouvrir la section invités quand la catégorie est "Restaurant" - useEffect(() => { - const selected = categories.find((c) => c.id === parseInt(categoryId)); - if (selected?.name?.toLowerCase().includes('restaurant')) { - setShowGuests(true); - } - }, [categoryId, categories]); - - // ── 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; - - // Auto-ajouter l'invité en cours de saisie s'il n'a pas encore été - // validé via "Ajouter l'invité" (cas fréquent : l'utilisateur tape - // le nom et clique directement sur "Envoyer"). - // flushPending() retourne la liste complète si un guest a été flushé, - // null sinon — on utilise cette valeur pour éviter un souci de timing - // avec la mise à jour asynchrone du state React. - const guestsFlushed = guestManagerRef.current?.flushPending() ?? null; - const finalGuests = guestsFlushed ?? guests; - - 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: finalGuests, - }); - - // 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: (data: any) => { - toast.success('Facture envoyée avec succès !'); - if (data?.tracking_error) { - toast.error(`⚠️ Suivi SharePoint : ${data.tracking_error}`, { duration: 10000 }); - } - 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 */} -
- -