diff --git a/.dockerignore b/.dockerignore index 0a24edc..396bc4d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -93,4 +93,11 @@ ssl/ # Backup files *.bak -*.backup \ No newline at end of file +*.backup +# Compilation Rust locale (le binaire est construit dans l'image) +server-rs/target/ + +# Serveur Node de référence du banc (scripts/bench/reference-node.sh) +.bench-reference/ +# Interface construite en local : reconstruite dans l'image +dist/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f4691ec --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +# Décision R6 (docs/PLAN-MIGRATION-RUST.md) : le serveur Rust, le banc qui le +# vérifie et les fichiers dont il dépend sont relus par leur mainteneur. +/server-rs/ @R0m1k3 +/scripts/bench/ @R0m1k3 +/scripts/gen-rust-schema.ts @R0m1k3 +/shared/schema.ts @R0m1k3 +/shared/permissions.json @R0m1k3 +/Dockerfile @R0m1k3 +/docker-compose.yml @R0m1k3 +/.github/workflows/ @R0m1k3 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..03d3027 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,88 @@ +name: Serveur Rust + +# Contrôles du serveur Rust, contrôle de type et construction de l'interface, et +# banc de comparaison avec le serveur Node de référence (scripts/bench/README.md) + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server-rs + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server-rs + - name: Mise en forme + run: cargo fmt --check + - name: Clippy + run: cargo clippy --all-targets --locked -- -D warnings + - name: Tests + run: cargo test --locked + + client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Dépendances + run: npm ci + - name: Contrôle de type + run: npm run check + - name: Construction de l'interface + run: npx vite build + + bench: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: logiflow_admin + POSTGRES_PASSWORD: bench + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U logiflow_admin" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + BENCH_PG_URL: postgres://logiflow_admin:bench@localhost:5432 + steps: + - uses: actions/checkout@v4 + with: + # Le serveur Node de référence est construit depuis un commit passé + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: server-rs + - name: Dépendances + run: npm ci + - name: Serveur Node de référence (REFERENCE_NODE_COMMIT) + run: scripts/bench/reference-node.sh + - name: Serveur Rust + run: cargo build --release --locked --manifest-path server-rs/Cargo.toml + - name: Base modèle du banc + run: scripts/bench/setup.sh + - name: Banc Node de référence contre Rust + run: npx tsx scripts/bench/compare.ts diff --git a/.gitignore b/.gitignore index 3a35e07..4c34deb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules dist .DS_Store -server/public vite.config.ts.* *.tar.gz @@ -13,3 +12,6 @@ cookie*.txt # Artefacts de debug locaux *.log scratch/ + +# Serveur Node de référence du banc (scripts/bench/reference-node.sh) +.bench-reference/ diff --git a/Dockerfile b/Dockerfile index f781505..5fec78f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,122 +1,49 @@ -# Multi-stage build for optimized production image -FROM node:20-alpine AS build +# Image de production : interface construite par Vite + serveur Rust (server-rs/). +# Node ne sert qu'à construire l'interface ; il n'est pas dans l'image finale. -# Set working directory +# Interface (dist/public) +FROM node:20-alpine AS client-build WORKDIR /app - -# Copy package files COPY package*.json ./ - -# Install all dependencies (including dev dependencies needed for build) RUN npm ci && npm cache clean --force - -# Copy source code (excluding .env to avoid conflicts) -COPY package*.json ./ -COPY tsconfig.json ./ -COPY drizzle.config.ts ./ -COPY vite.config.ts ./ -COPY tailwind.config.ts ./ -COPY postcss.config.js ./ -COPY components.json ./ +COPY tsconfig.json vite.config.ts tailwind.config.ts postcss.config.js components.json ./ COPY client/ client/ -COPY server/ server/ COPY shared/ shared/ +RUN NODE_ENV=production npx vite build && test -f dist/public/index.html -# Vérifier la structure -RUN ls -la server/ +# Serveur : binaire statique (musl) pour l'image Alpine +FROM rust:1.97-alpine AS rust-build +RUN apk add --no-cache musl-dev +WORKDIR /build +# Dépendances d'abord : couche en cache tant que Cargo.lock ne change pas +COPY server-rs/Cargo.toml server-rs/Cargo.lock ./server-rs/ +RUN mkdir -p server-rs/src && echo "fn main() {}" > server-rs/src/main.rs && \ + cargo build --release --locked --manifest-path server-rs/Cargo.toml && \ + rm -rf server-rs/src +COPY server-rs/build.rs ./server-rs/build.rs +COPY server-rs/src ./server-rs/src +COPY shared/permissions.json ./shared/permissions.json +RUN touch server-rs/src/main.rs && \ + cargo build --release --locked --manifest-path server-rs/Cargo.toml -# Build the application -# Build frontend first -RUN NODE_ENV=production npx vite build - -# Vérifier que les fichiers sont construits -RUN echo "=== BUILD VERIFICATION ===" && \ - ls -la dist/ && \ - echo "Frontend files in dist/public:" && \ - ls -la dist/public/ && \ - echo "index.html exists:" && \ - ls -la dist/public/index.html - -# Build backend avec tous les modules externes -RUN npx esbuild server/index.production.ts \ - --platform=node \ - --bundle \ - --format=esm \ - --outfile=dist/index.js \ - --external:vite \ - --external:@vitejs/* \ - --external:@replit/* \ - --external:tsx \ - --external:openid-client \ - --external:@neondatabase/serverless \ - --external:ws \ - --external:drizzle-orm \ - --external:pg \ - --external:express \ - --external:connect-pg-simple \ - --external:passport \ - --external:passport-local \ - --external:express-session \ - --external:zod \ - --external:express-rate-limit \ - --external:memoizee \ - --external:nanoid \ - --external:date-fns \ - --external:nodemailer \ - --external:compression - -# Production stage -FROM node:20-alpine AS production - -# Create non-root user for security -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nextjs -u 1001 - -# Set working directory +FROM alpine:3.20 AS production +# pg_dump/psql pour les sauvegardes, wget pour la sonde de santé +RUN apk add --no-cache postgresql-client wget tzdata ca-certificates && \ + addgroup -g 1001 -S logiflow && adduser -S logiflow -u 1001 -G logiflow WORKDIR /app +COPY --from=rust-build /build/server-rs/target/release/logiflow /app/bin/logiflow +COPY --from=client-build /app/dist/public /app/dist/public +COPY migrations/ /app/migrations/ +RUN mkdir -p /app/backups && chown -R logiflow:logiflow /app/backups +USER logiflow -# Install production dependencies only -COPY --from=build /app/package*.json ./ -RUN npm ci --only=production && npm cache clean --force - -# Copy built application from build stage -COPY --from=build --chown=nextjs:nodejs /app/dist ./dist -COPY --from=build --chown=nextjs:nodejs /app/shared ./shared - -# Copy migration scripts and files -COPY --chown=nextjs:nodejs scripts/ ./scripts/ -COPY --chown=nextjs:nodejs migrations/ ./migrations/ -COPY --chown=nextjs:nodejs scripts/docker-entrypoint.sh ./scripts/docker-entrypoint.sh - -# Create symlink for public files to be accessible from dist -RUN ln -sf /app/dist/public /app/dist/public - -# Create uploads and backups directories with proper permissions -RUN mkdir -p /app/uploads /app/backups && chown -R nextjs:nodejs /app/uploads /app/backups - -# Switch to non-root user -USER nextjs - -# Expose port +ENV PORT=3000 \ + STATIC_DIR=/app/dist/public \ + BACKUP_DIR=/app/backups \ + NODE_ENV=production EXPOSE 3000 -ENV PORT=3000 -# Fixé ici car db.ts lit NODE_ENV dès l'import, avant le forçage de index.production.ts -ENV NODE_ENV=production -# Install wget for health check and postgresql-client for pg_dump -USER root -RUN apk add --no-cache wget postgresql-client -USER nextjs - -# Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 -# Make scripts executable -RUN chmod +x /app/scripts/auto-migrate-production.sh && \ - chmod +x /app/scripts/docker-entrypoint.sh && \ - ls -la /app/scripts/ && \ - test -f /app/scripts/docker-entrypoint.sh || echo "ERREUR: docker-entrypoint.sh manquant" - -# Apply DLC migration directly and start application -CMD ["sh", "-c", "echo 'Applying DLC migration...'; if [ -n \"$DATABASE_URL\" ]; then psql \"$DATABASE_URL\" -c 'ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp; CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise);' || echo 'Migration already applied or failed'; fi; node dist/index.js"] \ No newline at end of file +CMD ["/app/bin/logiflow"] diff --git a/README.md b/README.md index d84330e..bb2bc09 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ Une plateforme d'entreprise moderne pour la gestion logistique, le suivi des liv ## 🚀 Installation et Configuration ### Prérequis -- Node.js 18+ -- PostgreSQL 13+ +- Rust stable (serveur) et Node.js 20+ (construction de l'interface uniquement) +- PostgreSQL 13+ et `pg_dump` (sauvegardes) - Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/)) ### Installation @@ -61,13 +61,18 @@ npm install cp .env.example .env # Éditer .env avec vos paramètres de base de données -# Initialiser la base de données -npm run db:push +# Démarrer le serveur (API, migrations au démarrage) sur le port 3000 +npm run dev:server -# Démarrer l'application +# Dans un autre terminal : l'interface avec rechargement à chaud (Vite), +# qui relaie /api vers le serveur npm run dev ``` +En production : `npm run build` (interface dans `dist/public`, binaire dans +`server-rs/target/release/logiflow`) puis `npm start`, ou l'image Docker +(`docker compose up -d`). + ### Configuration de l'API Météo 1. Créez un compte gratuit sur [Visual Crossing](https://www.visualcrossing.com/) 2. Obtenez votre clé API @@ -122,11 +127,9 @@ npm run dev - **Wouter** : Routage léger et performant ### Backend -- **Node.js** : Runtime JavaScript haute performance -- **Express** : Framework web minimaliste -- **TypeScript** : Développement sécurisé côté serveur -- **Drizzle ORM** : ORM moderne et type-safe -- **Zod** : Validation de schémas robuste +- **Rust** (axum, tokio, sqlx) : serveur unique, binaire statique (voir `server-rs/README.md`) +- **Schéma** : `shared/schema.ts` (Drizzle) reste la source, d'où est généré `server-rs/src/schema.rs` +- **Zod** : validation côté interface ; mêmes règles et mêmes messages côté serveur ### Base de Données - **PostgreSQL** : Base de données relationnelle performante @@ -146,13 +149,16 @@ npm run dev │ │ ├── pages/ # Pages de l'application │ │ ├── lib/ # Utilitaires et configuration │ │ └── hooks/ # Hooks React personnalisés -├── server/ # Backend Express -│ ├── routes.ts # Définition des routes API -│ ├── storage.ts # Interface de stockage -│ ├── weatherService.ts # Service météorologique -│ └── index.ts # Point d'entrée du serveur +├── server-rs/ # Serveur Rust (API, interface, migrations, tâches de fond) +│ └── src/ +│ ├── router.rs # Table des adresses de l'API +│ ├── modules/ # Une famille de routes par fichier +│ ├── startup.rs # Migrations au démarrage +│ └── jobs.rs # Sauvegardes automatiques, purge des caches ├── shared/ # Code partagé -│ └── schema.ts # Schémas de base de données +│ ├── schema.ts # Schémas de base de données (source de schema.rs) +│ └── permissions.json # Matrice des droits (interface et serveur) +├── scripts/bench/ # Banc de comparaison avec l'ancien serveur Node ├── migrations/ # Migrations de base de données └── backups/ # Dossier des sauvegardes ``` @@ -168,8 +174,13 @@ DATABASE_URL=postgresql://user:password@localhost:5432/dbname VISUAL_CROSSING_API_KEY=votre_clé_api_ici # Application -NODE_ENV=production PORT=3000 +SESSION_SECRET=… # signe le cookie de session +ENCRYPTION_KEY=… # chiffre les secrets en base (repli : SESSION_SECRET) +STATIC_DIR=dist/public # interface construite par Vite +BACKUP_DIR=/app/backups +ALLOWED_ORIGINS= # autres origines acceptées pour les écritures (facultatif) +RUST_LOG=info ``` ### Personnalisation diff --git a/client/src/lib/navigation.ts b/client/src/lib/navigation.ts index 3342ad3..c3425c5 100644 --- a/client/src/lib/navigation.ts +++ b/client/src/lib/navigation.ts @@ -35,7 +35,7 @@ export interface NavItem { } export interface NavSection { - // Pas de titre pour l'entrée Accueil, affichée en tête + // Pas de titre pour les entrées Accueil et Calendrier, affichées en tête title?: string; items: NavItem[]; } @@ -44,6 +44,7 @@ export const NAV_SECTIONS: NavSection[] = [ { items: [ { path: "/", label: "Accueil", icon: House, roles: ALL_ROLES }, + { path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES }, ], }, { @@ -68,7 +69,6 @@ export const NAV_SECTIONS: NavSection[] = [ { title: "Planning et infos", items: [ - { path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES }, { path: "/publicities", label: "Publicités", icon: Megaphone, roles: ALL_ROLES }, { path: "/contacts", label: "Contacts", icon: BookUser, roles: ALL_ROLES }, { path: "/analytics", label: "Statistiques", icon: ChartLine, roles: ["admin", "directeur", "manager"] }, diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 24cfc62..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Fichier de configuration Docker Compose pour le développement et la production -# Utilise les variables d'environnement pour la configuration - -version: '3.8' - -services: - app: - volumes: - # Monter les scripts de migration - - ./scripts:/app/scripts:ro - environment: - # Forcer l'exécution des migrations au démarrage - - AUTO_MIGRATE=true - # Variables de debug - - DEBUG_MIGRATIONS=true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ef37976..f9ee19a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,22 @@ version: '3.8' +# logiflow : serveur Rust (server-rs/), qui sert l'API et l'interface, applique +# les migrations au démarrage et lance les tâches de fond (sauvegardes, purge). + +x-logiflow-env: &logiflow-env + NODE_ENV: production + DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db + SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production + USE_LOCAL_AUTH: "true" + POSTGRES_USER: logiflow_admin + POSTGRES_PASSWORD: LogiFlow2025! + POSTGRES_DB: logiflow_db + BACKUP_DIR: /app/backups + # Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée) + EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-} + # Origines acceptées en plus de l'adresse du site pour les écritures (décision R4) + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} + services: logiflow: build: @@ -8,17 +25,9 @@ services: target: production restart: unless-stopped environment: - NODE_ENV: production - DATABASE_URL: postgresql://logiflow_admin:LogiFlow2025!@logiflow-db:5432/logiflow_db - SESSION_SECRET: LogiFlow_Super_Secret_Session_Key_2025_Production + <<: *logiflow-env PORT: 3000 - USE_LOCAL_AUTH: "true" - POSTGRES_USER: logiflow_admin - POSTGRES_PASSWORD: LogiFlow2025! - POSTGRES_DB: logiflow_db - BACKUP_DIR: /app/backups - # Clé(s) de l'API externe de rapprochement, lues depuis le .env de l'hôte (vide = API désactivée) - EXTERNAL_API_KEYS: ${EXTERNAL_API_KEYS:-} + RUST_LOG: info ports: - "3000:3000" networks: @@ -27,13 +36,24 @@ services: logiflow-db: condition: service_healthy volumes: - - ./migrations:/app/migrations - backup_data:/app/backups healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] interval: 30s timeout: 10s retries: 3 + start_period: 10s + + logiflow-db: + condition: service_healthy + volumes: + - ./migrations:/app/migrations + - backup_data:/app/backups + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"] + interval: 30s + timeout: 10s + retries: 3 start_period: 40s logiflow-db: @@ -66,4 +86,4 @@ volumes: networks: nginx_default: - external: true \ No newline at end of file + external: true diff --git a/docs/PLAN-MIGRATION-RUST.md b/docs/PLAN-MIGRATION-RUST.md new file mode 100644 index 0000000..e91412f --- /dev/null +++ b/docs/PLAN-MIGRATION-RUST.md @@ -0,0 +1,383 @@ +# Plan de migration du serveur LogiFlow vers Rust + +*Plan rédigé le 15 octobre 2026. **Migration faite** : voir [Où en est la migration](#où-en-est-la-migration) ; la suite du document est le plan d'origine, gardé tel quel.* + +**Sommaire** : [Où en est la migration](#où-en-est-la-migration) · [Résumé](#résumé) · [Ce qui change, ce qui ne change pas](#ce-qui-change-ce-qui-ne-change-pas) · [Le serveur actuel](#le-serveur-actuel) · [Architecture cible](#architecture-cible) · [Stratégie : une adresse à la fois](#stratégie--une-adresse-à-la-fois) · [Feuille de route](#feuille-de-route) · [Compatibilité : les pièges](#compatibilité--les-pièges) · [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) · [Vérification](#vérification) · [Articulation avec le plan du webUI](#articulation-avec-le-plan-du-webui) · [Risques](#risques) · [Décisions à prendre](#décisions-à-prendre) + +## Où en est la migration + +**Le serveur Node a quitté le dépôt.** Le serveur Rust (`server-rs/`) est le seul serveur : il sert les 148 adresses de l'API et l'interface, applique les migrations au démarrage et lance les tâches de fond (sauvegarde automatique, purge des caches, compte `admin` par défaut). L'image Docker ne contient plus Node : un binaire statique, l'interface construite et `pg_dump`. Node ne sert plus qu'à construire l'interface (Vite) et à faire tourner le banc. + +**Vérification.** Le banc rejoue 2 025 requêtes (148 adresses, 5 comptes, sans session, clé d'API ; lectures et scénarios d'écriture avec l'état des tables et les appels aux services simulés) sur le serveur Node de référence — le dernier commit qui le contient, `REFERENCE_NODE_COMMIT` — et sur Rust seul : **0 écart**, hors les écarts déclarés ci-dessous. Il tourne dans la CI (`.github/workflows/rust.yml`), avec `cargo fmt`, `clippy -D warnings` et les tests unitaires. + +**Écarts voulus** (déclarés au banc) : + +| Code | Changement | +|---|---| +| USERS-02 | L'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte. | +| RAPPRO-21 | L'envoi de facture n'appelle plus une adresse de webhook quelconque : seulement celle d'un magasin ou du webhook BAP actif (400 sinon). | +| R4 | Requête d'écriture venue d'un autre site refusée (403). | +| R5 | « Aujourd'hui » à l'heure de Paris : différent de Node seulement entre minuit et 1 h (2 h l'été), heure de Paris ; le banc le signale quand il tourne dans cette fenêtre. | + +Tout le reste, défauts compris, est reproduit à l'identique et signalé dans le code par l'identifiant de l'audit : c'est le point de départ des corrections, une par une, chacune déclarée au banc. + +**Ce qui a différé du plan** : + +- **Ordre** : sur demande, le serveur Node a été retiré dès la fin du portage, sans les deux semaines de production avec Node en secours prévues au Lot 5. La première mise en production du serveur Rust seul est donc la bascule : garder l'image précédente sous la main pour revenir en arrière (même base, mêmes sessions, aucune migration de données). +- **Requêtes SQL** vérifiées à l'exécution (`sqlx::query_as`) et non à la compilation : les vérifier à la compilation demande une base au moment du build. Le banc couvre ce que la compilation aurait vérifié. +- **Schéma** : `shared/schema.ts` reste la source ; `server-rs/src/schema.rs` en est **généré** (`npm run gen:rust-schema`), l'inverse de ce que prévoyait le Lot 5. L'interface garde ses types et ses schémas zod sans changement. +- **Migrations** : celles du démarrage de Node, reprises telles quelles dans `src/startup.rs` (idempotentes), plutôt qu'une table `_sqlx_migrations`. +- **Sessions** : table `sessions` (celle de `shared/schema.ts`), cookie `connect.sid` inchangé : les sessions ouvertes avant la bascule restent valides. + +**Décisions** : + +| | Statut | +|---|---| +| R1 | Prise : le serveur seul, l'interface React reste. | +| R2 | Sans objet : le portage s'est fait d'un bloc, sans gel prolongé. | +| R3 | Rien n'a été retiré : toutes les adresses, y compris l'exécution de SQL et la réinitialisation d'urgence, sont portées à l'identique. La liste [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas) reste à trancher, adresse par adresse. | +| R4 | **Faite.** Cookie `SameSite=Lax`, et toute requête `/api` qui n'est pas une lecture est refusée (403) si le navigateur indique qu'elle vient d'un autre site : `Sec-Fetch-Site`, à défaut `Origin` comparé à l'hôte de la requête (`Host` ou `X-Forwarded-Host`). Sans en-tête (n8n, curl) elle passe ; `/api/ext/` (clé d'API) n'est pas concerné ; `ALLOWED_ORIGINS` ajoute des origines. Code : `server-rs/src/origin.rs`. | +| R5 | **Faite.** « Aujourd'hui » se calcule à Paris (`server-rs/src/clock.rs`) : produits DLC expirés ou proches, tâches à venir, météo du jour et de l'an dernier, année et mois par défaut des statistiques, numéro de ticket SAV, nom de l'export CSV, libellé des sauvegardes, et sauvegarde automatique à partir de 2 h, heure de Paris. Les instants enregistrés restent en UTC. | +| R6 | **Faite.** Mainteneur et relecteur : @R0m1k3, propriétaire du dépôt (`.github/CODEOWNERS` : `server-rs/`, banc, schéma, droits, Docker, CI). Toute modification passe par une PR avec la CI verte (mise en forme, clippy, tests, banc). À activer dans les réglages GitHub : protection de `main` avec relecture des CODEOWNERS et vérifications obligatoires. Un second relecteur connaissant Rust reste souhaitable. | + +## Résumé + +- **Quoi.** Réécrire le serveur (Express + TypeScript + Drizzle, environ 18 800 lignes dans `server/` et `shared/`, 148 adresses d'API) en Rust. **L'interface React reste telle quelle** : elle ne parle au serveur qu'en JSON, sous les mêmes adresses. +- **Comment.** Progressivement, jamais d'un bloc. Un serveur Rust se place devant l'actuel : il sert l'application et les adresses déjà réécrites, et transmet toutes les autres au serveur Node. Module par module, chaque adresse passe en Rust quand elle rend **exactement** les mêmes réponses (vérifié au banc de mesure), puis disparaît du code Node. À la fin, Node ne sert plus qu'à construire l'interface. +- **Ce que ça apporte.** Des types vérifiés de bout en bout, requêtes SQL comprises : les 88 erreurs TypeScript actuelles de `server/storage.ts` (comparaisons de texte et de date, champs possiblement vides) ne compileraient pas. Un serveur plus sobre en mémoire et plus rapide à démarrer, livré comme un seul binaire, sans `node_modules` en production. Et l'occasion de poser enfin des tests : le dépôt n'en a aucun. +- **Ce que ça coûte.** **4 à 6 mois pour un développeur à plein temps qui connaît Rust** (compter 1 à 2 mois de plus pour l'apprendre), pendant lesquels le serveur évolue moins vite ; deux serveurs à faire tourner pendant la transition ; une compétence Rust à garder dans l'équipe pour la maintenance. +- **Avant de commencer** (Lot 0, environ 3 semaines) : le banc de mesure versionné dans le dépôt, les sessions enregistrées dans PostgreSQL au lieu de la mémoire du serveur, le schéma réel de la base de production relevé, et les corrections de sécurité urgentes de la Phase 1a faites dans le serveur actuel, sans attendre Rust. +- Six décisions sont à prendre avant le Lot 0 ([Décisions à prendre](#décisions-à-prendre)) ; chacune a une proposition par défaut. + +## Ce qui change, ce qui ne change pas + +| | Change | Ne change pas | +|---|---|---| +| **Équipes en magasin** | Rien de visible, sauf les corrections décidées (fuseau horaire, protections). | Écrans, adresses, favoris, identifiants et mots de passe, session en cours au moment de la bascule. | +| **Interface** (`client/`) | En développement : `vite` lancé seul, qui renvoie `/api` vers le serveur Rust, au lieu de Vite intégré à Express (`server/vite.ts`). | React, Vite, Tailwind, React Query, le code des pages. | +| **Serveur** (`server/`) | Réécrit en Rust dans `server-rs/`, puis supprimé. | Les adresses, les réponses JSON, les codes d'erreur. | +| **Types partagés** (`shared/`) | Générés à partir du code Rust à la fin de la migration (Lot 5). | Le paquet `shared/` reste en TypeScript pour l'interface : 33 fichiers de `client/src` l'importent. | +| **Base de données** | Migrations suivies par une table (`_sqlx_migrations`) au lieu de scripts lancés à la main. | PostgreSQL, les tables, les données. Aucune migration de données. | +| **Déploiement** | Image Docker plus légère : un binaire, sans `node_modules`. | Docker Compose, le réseau nginx, le port 3000, `/api/health`, les sauvegardes `pg_dump`. | + +**Pourquoi garder React.** Réécrire aussi l'interface en Rust (Leptos, Yew, Dioxus) reviendrait à refaire une trentaine de pages et leurs versions téléphone, au moment où le plan du webUI les refait déjà (Phases 2 et 3). Cela doublerait le coût sans rien apporter aux équipes : la lenteur venait du serveur et des volumes échangés (P4 du plan du webUI), pas de React. C'est la décision R1. + +## Le serveur actuel + +Inventaire au 15 octobre 2026, qui fixe le périmètre à reporter. Les numéros de ligne renvoient au code de cette date. + +### Taille + +| Fichier | Lignes | Contenu | +|---|---|---| +| `server/routes.ts` | 6 077 | 134 adresses, contrôles de droits écrits dans chaque gestionnaire | +| `server/storage.ts` | 5 914 | Accès aux données : environ 127 méthodes. Seule `DatabaseStorage` (lignes 387 à 3509) est à reporter ; `MemStorage` (stockage en mémoire de développement) disparaît. | +| `shared/schema.ts` | 1 047 | 26 tables Drizzle, schémas zod | +| `server/invoiceVerification.ts` | 922 | Vérification des factures dans NocoDB, avec cache | +| Les 27 autres fichiers | environ 4 900 | Authentification, API externe, sauvegardes, météo, courriels, chiffrement, migrations | + +### Les 148 adresses, par module + +| Module | Adresses | Lot | +|---|---|---| +| Santé (`/api/health`) | 1 | 1 | +| Contacts | 4 | 1 | +| Fiches fournisseurs | 4 | 1 | +| Informations (annonces) | 4 | 2 | +| Publicités (`/api/ad-campaigns`) | 7 | 2 | +| Météo | 6 | 2 | +| Statistiques (`/api/stats`) | 4 | 2 | +| Statistiques détaillées (`/api/analytics`, export CSV) | 5 | 2 | +| Tâches | 5 | 3 | +| DLC | 11 | 3 | +| Commandes clients | 6 | 3 | +| SAV | 7 | 3 | +| Commandes fournisseurs | 6 | 4 | +| Livraisons | 7 | 4 | +| Rapprochement BL / factures (commentaires, courriels fournisseurs, vérification de facture, cache) | 13 | 4 | +| Avoirs | 8 | 4 | +| Échéancier (dont export CSV) | 2 | 4 | +| Configuration NocoDB | 5 | 4 | +| Bon à payer (BAP) et webhook | 4 | 4 | +| API externe (`/api/ext/v1`) et gestion de ses clés | 8 | 4 | +| Relais ffnancy | 2 | 4 | +| Authentification | 6 | 5 | +| Utilisateurs | 8 | 5 | +| Magasins (`/api/groups`) | 5 | 5 | +| Sauvegardes | 4 | 5 | +| Paramètres (`/api/utilities`) | 2 | 5 | +| Administration et diagnostic (SQL, schéma, migration d'urgence) | 4 | 5 (décision R3) | +| **Total** | **148** | | + +Le Calendrier n'a pas d'adresse à lui : il lit les commandes, les livraisons et les publicités. Il est entièrement servi par Rust à la fin du Lot 4. + +### Ce que l'inventaire a révélé + +Ces constats changent la façon de migrer ; certains sont déjà dans l'audit du webUI. + +- **La production n'utilise pas `localAuth.production.ts`.** `server/routes.ts:4` importe `./localAuth` et c'est lui qui s'exécute : sessions dans la mémoire du processus (perdues à chaque redémarrage, AUTH-06), cookie `connect.sid` valable 24 heures, sans `sameSite`. `localAuth.production.ts`, `db.production.ts` et `monitoring.ts` ne sont jamais exécutés. +- **Aucune protection CSRF, aucune limite de débit, aucun en-tête de sécurité en production** : `server/security.ts` n'est branché que dans `server/index.ts`, qui n'est pas le point d'entrée de l'image Docker (`server/index.production.ts`). L'interface n'envoie d'ailleurs aucun jeton CSRF. +- **Mots de passe** : `scrypt` de Node (N = 16384, r = 8, p = 1, 64 octets), stockés en `empreinte_hex.sel_hex`, le sel hexadécimal étant utilisé tel quel comme texte (`server/localAuth.ts:20-57`). Le paquet `scrypt` de Rust les vérifie à l'identique : **aucun mot de passe à réinitialiser**. +- **Secrets chiffrés** (mot de passe SMTP des magasins, jeton NocoDB) : AES-256-GCM, clé = SHA-256 de `ENCRYPTION_KEY` (à défaut `SESSION_SECRET`), format `enc:v1:iv:tag:données` en base64 (`server/crypto.ts`). Reproductible à l'identique avec le paquet `aes-gcm`. +- **Droits** : une seule adresse utilise un intergiciel de droits (`requireAdmin`, `server/routes.ts:130`). Les autres contrôlent le rôle à la main (environ 131 comparaisons `user.role` et 14 appels `hasPermission`), et DLC, Tâches et Commandes clients ne contrôlent que le magasin. +- **Base** : aucune migration n'est suivie en production. `init.sql` ne crée que 25 tables et ignore des colonnes ajoutées depuis à la main (`contacts`, `suppliers.email`, `customer_orders.notified_at`…). Seul le schéma réel de la production fait foi. +- **Aucune transaction** dans tout le serveur, et un numéro de SAV calculé en « nombre de tickets + 1 » (`server/storage.ts:2824`), qui peut produire des doublons. +- **Dépendances inutilisées** : `xlsx`, `iconv-lite`, `multer`, `busboy`, `node-cron`, `csrf-csrf`, `openid-client`, `memoizee` ne sont importés nulle part. Pas de génération Excel ni PDF côté serveur ; les exports sont en CSV. +- **Aucun test** dans le dépôt. La seule intégration continue construit l'image Docker. + +## Architecture cible + +### Choix techniques + +| Besoin | Aujourd'hui (Node) | En Rust | +|---|---|---| +| Serveur HTTP | Express | `axum` sur `tokio` | +| Compression, fichiers statiques, limite de taille, journal des requêtes | `compression`, `express.static`, `express.json({ limit: '10mb' })` | `tower-http` (`CompressionLayer`, `ServeDir`, `RequestBodyLimitLayer`, `TraceLayer`) | +| Base de données | Drizzle + `pg` | `sqlx` (PostgreSQL) : requêtes SQL écrites en clair, **vérifiées à la compilation** contre le schéma ; cache `.sqlx/` versionné pour compiler sans base | +| Migrations | scripts au démarrage + `psql` dans le `CMD` du Dockerfile | `sqlx::migrate!`, fichiers SQL numérotés, table de suivi | +| Validation des entrées | zod | `serde` (forme) + `validator` ou contrôles écrits à la main (règles) | +| Sessions, connexion | `express-session` + `passport-local` | intergiciel maison, d'environ 200 lignes : lecture du cookie signé, table `session` (voir [Sessions](#sessions-partagées-entre-node-et-rust)) | +| Mots de passe | `crypto.scrypt` | `scrypt` (mêmes paramètres) | +| Chiffrement des secrets | `crypto` AES-256-GCM | `aes-gcm`, `sha2`, `base64` | +| Clés de l'API externe | SHA-256 + `timingSafeEqual` | `sha2` + `subtle` | +| Appels sortants (NocoDB, Visual Crossing, Nominatim, ffnancy, webhooks n8n) | `node-fetch` | `reqwest` (TLS `rustls`), délai maximal sur chaque appel | +| Courriels fournisseurs | `nodemailer` | `lettre` (logo du magasin en pièce jointe intégrée `cid:logo`) | +| Sauvegardes | `exec('pg_dump … > fichier')` dans un shell | `tokio::process::Command` avec arguments séparés, sans shell | +| Tâches de fond | `setInterval` | tâches `tokio` (`tokio::time::interval`) | +| Dates et fuseau | `Date` de JavaScript | `chrono` + `chrono-tz` (`Europe/Paris`, décision R5) | +| Montants | texte renvoyé par `pg` | `rust_decimal`, sérialisé en texte (voir [Compatibilité](#compatibilité--les-pièges)) | +| Journaux | environ 150 `console.log` dans `routes.ts` | `tracing`, à niveaux, sans corps de requête ni données clients | +| Types pour l'interface | `shared/schema.ts` | `ts-rs` : les types TypeScript sont générés depuis les structures Rust (Lot 5) | + +Les versions sont figées dans `Cargo.lock`, versionné. Une seule édition de Rust et une version minimale (`rust-version`) inscrites dans `Cargo.toml`. + +### Organisation du code + +Un seul paquet (`crate`) pour commencer, découpé en modules qui reprennent ceux de l'application. On ne le découpe en plusieurs paquets que si la compilation devient trop lente. + +``` +server-rs/ + Cargo.toml + migrations/ fichiers SQL numérotés (sqlx), le premier = schéma de production relevé + .sqlx/ requêtes vérifiées, pour compiler sans base (SQLX_OFFLINE=true) + src/ + main.rs configuration, démarrage, migrations, tâches de fond + router.rs table des adresses servies par Rust ; tout le reste part vers Node + proxy.rs relais vers le serveur Node (supprimé au Lot 5) + auth/ session, cookie signé, utilisateur courant, connexion + access.rs droits (rôle × module) et cloisonnement par magasin + json.rs formats partagés : dates, montants, erreurs { message } + crypto.rs secrets chiffrés (compatible enc:v1) + modules/ un fichier par module : contacts.rs, dlc.rs, deliveries.rs… + chacun avec ses gestionnaires HTTP et ses requêtes SQL + integrations/ nocodb.rs, mail.rs, weather.rs, webhooks.rs, ffnancy.rs, backup.rs + jobs.rs sauvegarde quotidienne, purge du cache des factures + tests/ tests d'intégration contre une base PostgreSQL jetable +``` + +**Une seule table de droits.** La matrice de `shared/permissions.ts` passe dans un fichier de données, `shared/permissions.json`, lu par le serveur Rust (`include_str!` + `serde`) et par l'interface (import JSON). C'est la « table de droits unique » de la Phase 2 du plan du webUI, sans recopie entre les deux langages. Chaque gestionnaire Rust déclare son module et son action ; le contrôle est fait par un extracteur `axum`, plus dans le corps de chaque fonction. + +### Sessions partagées entre Node et Rust + +Pendant la transition, un utilisateur connecté par Node doit être reconnu par Rust, et inversement. Les sessions en mémoire du processus Node ne le permettent pas : **il faut d'abord les enregistrer dans PostgreSQL** (Lot 0). C'est aussi la correction d'AUTH-06 (sessions perdues à chaque redéploiement), déjà prévue par le plan du webUI. + +- **Côté Node (Lot 0)** : `connect-pg-simple` (déjà dans les dépendances), table `session (sid, sess, expire)`. Les utilisateurs se reconnectent une fois, à cette livraison-là. +- **Côté Rust (Lot 1)** : lire le cookie `connect.sid` (`s:` + identifiant + `.` + signature HMAC-SHA256 avec `SESSION_SECRET`, en base64 sans `=`), vérifier la signature en temps constant, lire `sess->'passport'->>'user'` dans la table `session` si `expire` n'est pas dépassé, puis charger l'utilisateur et ses magasins. Une seule lecture de l'utilisateur par requête (règle « utilisateur courant » de P4 § 5.2). +- **Connexion et déconnexion** restent dans Node jusqu'au Lot 5. Rust les reprend alors en écrivant des sessions **au même format** : aucune reconnexion à la bascule, et un retour à Node reste possible. + +## Stratégie : une adresse à la fois + +``` + navigateur (PC, téléphone) + │ + nginx (réseau nginx_default) + │ + ┌───────────────────▼────────────────────┐ + │ logiflow : serveur Rust, port 3000 │ + │ /assets, index.html, adresses portées │ + └───────────────────┬────────────────────┘ + │ toute autre adresse /api + ┌───────────────────▼────────────────────┐ + │ logiflow-node : serveur actuel │ + │ port 3001, interne, non exposé │ + └───────────────────┬────────────────────┘ + │ + PostgreSQL (logiflow-db), table session commune +``` + +- **Le serveur Rust est devant dès le premier jour**, avec zéro adresse portée : il sert l'interface (`/assets` avec cache d'un an, `index.html` pour toutes les autres adresses de l'application) et relaie tout `/api` à Node. Cette première livraison ne change rien pour les équipes et valide la tuyauterie en production. +- **Le relais** transmet la méthode, l'adresse, les en-têtes (cookie, `X-API-Key`, `Authorization`, type de contenu) et le corps en flux, sans le lire (l'envoi de facture en `multipart` passe tel quel). Il rend la réponse telle quelle, `Set-Cookie` et redirections 302 comprises (la déconnexion redirige vers `/auth`), sans les suivre. Délai maximal de 120 secondes : certains appels attendent NocoDB ou un webhook jusqu'à 60 secondes. +- **La table des adresses est dans le code** (`router.rs`) : une adresse est servie par Rust si elle y figure, par Node sinon. Faire revenir une adresse à Node, c'est retirer sa ligne et redéployer. +- **Une adresse vit à un seul endroit.** Quand elle passe en Rust, son code Node n'est plus appelé ; on le supprime après deux semaines en production sans régression, dans une PR dédiée. +- **Les tâches de fond ne tournent jamais dans les deux serveurs** : sauvegarde quotidienne, purge du cache des factures, configuration météo et création des index restent dans Node jusqu'au Lot 5, puis passent toutes ensemble en Rust. +- **Le schéma ne bouge pas pendant qu'un module migre.** Un changement de schéma passe par un fichier de `server-rs/migrations/`, appliqué par Rust au démarrage. Les migrations de démarrage de Node (`server/migrations.production.ts`, toutes en `IF NOT EXISTS`) continuent jusqu'au Lot 5, puis deviennent des fichiers SQL. + +## Feuille de route + +Effort pour un développeur à plein temps qui connaît Rust ; échelle **S** / **M** / **L** du plan du webUI. Chaque lot se termine par une livraison en production et deux semaines de suivi, qui recouvrent le début du lot suivant. + +| Lot | Contenu | Adresses | Effort | Prérequis | +|---|---|---|---|---| +| [0. Préparation](#lot-0--préparation) | Banc, sessions en base, schéma relevé, squelette Rust devant Node | 0 | 3 semaines | Décisions R1 à R6 ; Phase 1a du webUI | +| [1. Socle](#lot-1--socle) | Session, droits, magasin, formats JSON, premiers modules | 9 | 2 à 3 semaines | Lot 0 en production | +| [2. Informations et planning](#lot-2--informations-et-planning) | Annonces, Publicités, Météo, Statistiques | 26 | 3 semaines | Lot 1 | +| [3. Au quotidien](#lot-3--au-quotidien) | Tâches, DLC, Commandes clients, SAV | 29 | 3 à 4 semaines | Lot 1 | +| [4. Fournisseurs](#lot-4--fournisseurs) | Commandes, Livraisons, Rapprochement, Avoirs, Échéancier, NocoDB, BAP, API externe | 55 | 6 à 8 semaines | Lot 1 | +| [5. Administration et bascule](#lot-5--administration-et-bascule) | Connexion, Utilisateurs, Magasins, Sauvegardes, Paramètres, tâches de fond ; arrêt de Node | 29 | 3 à 4 semaines | Lots 2 à 4 | +| **Total** | | **148** | **20 à 25 semaines** | | + +Les lots 2, 3 et 4 sont indépendants entre eux : un second développeur peut en prendre un en parallèle. L'ordre proposé va du plus simple au plus délicat : les premiers lots rodent les outils sur des modules à faible risque, et le Lot 4, qui porte l'essentiel de la logique métier (factures, NocoDB, rapprochement), arrive quand la méthode est éprouvée. + +### Lot 0 — Préparation + +**Objectif.** Pouvoir prouver, adresse par adresse, que Rust répond comme Node ; et poser le serveur Rust en production sans rien changer. + +1. **Verser le banc de mesure dans le dépôt** (`scripts/bench/`, c'est le lot 2, n° 0 du plan du webUI), et l'étendre : + - les 148 adresses, pour 5 comptes : administrateur, directeur multi-magasins, manager, employé, compte sans magasin ; + - des scénarios d'écriture (créer, modifier, valider, supprimer) rejoués sur une copie neuve de la base, avec comparaison de l'état des tables touchées après coup ; + - la comparaison porte sur le code HTTP, le corps JSON (valeurs, ordre des listes, champs `null`), et les en-têtes qui comptent (`Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`) ; + - une horloge figée pour les réponses qui dépendent de « aujourd'hui ». +2. **Sessions dans PostgreSQL** côté Node (`connect-pg-simple`, table `session`), cookie `sameSite: 'lax'`. Corrige AUTH-06. +3. **Relever le schéma réel de la production** (`pg_dump --schema-only` sur une copie) ; il devient `server-rs/migrations/0001_schema_initial.sql`, marqué comme déjà appliqué sur les bases existantes. Noter les écarts avec `init.sql`. +4. **Squelette Rust** : configuration (mêmes variables d'environnement : `DATABASE_URL`, `SESSION_SECRET`, `ENCRYPTION_KEY`, `EXTERNAL_API_KEYS`, `VISUAL_CROSSING_API_KEY`, `BACKUP_DIR`, `PORT`), `/api/health`, fichiers statiques, renvoi de `index.html`, relais vers Node, journaux `tracing`, arrêt propre. +5. **Docker** : un service `logiflow` (Rust, port 3000) et un service `logiflow-node` (port 3001, non exposé) dans `docker-compose.yml` ; Dockerfile en étapes : interface construite avec Node, binaire Rust compilé, image finale avec le binaire, `dist/public` et `postgresql-client` (pour `pg_dump`). +6. **Intégration continue** : `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` avec un service PostgreSQL, compilation en `SQLX_OFFLINE=true`, en plus de la construction de l'image existante. + +**Critères de réussite.** + +- Le banc passe sur l'ensemble des 148 adresses, avec Rust devant Node : 0 différence de statut, de corps ou d'en-tête par rapport à Node seul. +- En production : connexion, déconnexion (302 vers `/auth`), envoi d'une facture en `multipart`, envoi d'un BAP (PDF de plusieurs Mo), appel de l'API externe avec sa clé, téléchargement d'une sauvegarde : tout fonctionne à travers le relais. +- Un redéploiement ne déconnecte plus personne. + +### Lot 1 — Socle + +**Objectif.** Tout ce dont chaque module aura besoin, éprouvé sur trois modules simples. + +- **Utilisateur courant** : extracteur `axum` qui lit la session et charge l'utilisateur avec ses magasins, une fois par requête ; 401 `{ "message": … }` sinon. +- **Droits et magasins** : `shared/permissions.json` (voir [Organisation du code](#organisation-du-code)) ; extracteurs « module + action » et « magasin autorisé ». Les règles appliquées sont celles du serveur Node à ce moment-là, y compris les corrections de la Phase 1a. +- **Formats JSON** communs ([Compatibilité](#compatibilité--les-pièges)) : dates, horodatages, montants, noms de champs, `smtpPasswordSet` à la place du mot de passe SMTP, `Cache-Control: private, no-cache` sur `/api`. +- **Erreurs** : `{ "message": "…" }` en français, avec les mêmes codes HTTP que Node. +- **Chiffrement** compatible `enc:v1`, testé sur des valeurs chiffrées par le code Node. +- **Modules** : Santé (1), Contacts (4), Fiches fournisseurs (4). + +**Critères de réussite.** 0 différence au banc sur les 9 adresses pour les 5 comptes ; un secret chiffré par Node est relu par Rust et inversement ; tests unitaires des droits pour chaque case du tableau rôle × module. + +### Lot 2 — Informations et planning + +Annonces (4), Publicités (7), Météo (6, avec Visual Crossing et Nominatim), Statistiques (4) et Statistiques détaillées (5, dont l'export CSV). Ce sont surtout des lectures et des agrégats : c'est le lot qui rode `sqlx`, les requêtes construites selon les filtres (aujourd'hui assemblées en texte dans `server/storage.ts:3337-3386` et `:3450-3487`, à remplacer par des requêtes paramétrées) et les exports CSV. + +**Critères de réussite.** 0 différence au banc sur les 26 adresses ; mêmes octets pour les exports CSV ; aucun appel externe sans délai maximal. + +### Lot 3 — Au quotidien + +Tâches (5), DLC (11), Commandes clients (6), SAV (7). Les modules les plus utilisés en magasin, mais à la logique simple. Le numéro de SAV est attribué par la base (séquence ou `INSERT … RETURNING` dans une transaction) au lieu du comptage actuel, sans changer son format `SAV-AAAA-0001`. + +**Critères de réussite.** 0 différence au banc sur les 29 adresses et les scénarios d'écriture (création, modification, validation, suppression, marquage d'une DLC traitée, client prévenu). + +### Lot 4 — Fournisseurs + +Commandes fournisseurs (6), Livraisons (7), Rapprochement BL / factures (13), Avoirs (8), Échéancier (2), Configuration NocoDB (5), BAP et webhook (4), API externe et ses clés (8), relais ffnancy (2). Le lot le plus lourd : + +- `server/invoiceVerification.ts` (922 lignes) : recherche dans NocoDB par référence de facture puis par numéro de BL, rapprochement du nom de fournisseur, cache avec ses trois durées (6 heures, 12 heures, quasi permanent une fois rapproché). Reporter aussi le second client NocoDB (`server/nocodbService.ts`) ou, si `/api/verify-invoices` n'a pas d'appelant, le supprimer (décision R3) ; +- normalisation des échéances NocoDB (`server/dateUtils.ts`, jour avant mois par défaut) ; +- courriel de demande de documents au fournisseur (`shared/supplierMail.ts`, logo intégré), avec son journal ; +- API externe : clés `lf_…` (seules l'empreinte SHA-256 et un préfixe sont stockés), montants saisis à la française (« 1 234,56 »), règles de gel d'un rapprochement ; documentation `docs/API-RAPPROCHEMENT.md` inchangée ; +- les écritures qui touchent plusieurs tables (validation d'une livraison, rapprochement) passent dans une transaction. + +**Critères de réussite.** 0 différence au banc sur les 55 adresses ; NocoDB et les webhooks remplacés au banc par un faux service qui enregistre les appels reçus, identiques entre Node et Rust ; l'outil externe qui appelle `/api/ext/v1` fonctionne sans changement de sa part. + +### Lot 5 — Administration et bascule + +- **Connexion et déconnexion** (6 adresses, dont la réinitialisation d'urgence : décision R3), **Utilisateurs** (8), **Magasins** (5), **Sauvegardes** (4), **Paramètres** (2), **Administration et diagnostic** (4, décision R3). +- **Tâches de fond** en Rust : sauvegarde quotidienne après 2 h (heure de Paris), purge du cache des factures toutes les 6 heures, configuration météo au démarrage ; création des index et migrations de démarrage converties en fichiers de `server-rs/migrations/`. +- **Types partagés** : `ts-rs` génère `shared/api/*.ts` depuis les structures Rust ; les imports de types de `@shared/schema` dans `client/src` sont remplacés ; les trois schémas zod de formulaire utilisés par l'interface (`insertTaskSchema`, `insertCustomerOrderFrontendSchema`, `insertAnnouncementSchema`) restent écrits à la main dans `shared/forms.ts`. +- **Arrêt de Node** : suppression de `server/`, du relais, du service `logiflow-node`, de Drizzle et des dépendances serveur de `package.json` ; l'image finale ne contient plus Node. + +**Critères de réussite.** 0 différence au banc sur les 148 adresses, Rust seul ; un utilisateur connecté avant la bascule reste connecté après ; une sauvegarde automatique est créée la nuit suivante et téléchargeable ; `grep -r "server/" Dockerfile package.json` ne renvoie plus rien. + +## Compatibilité : les pièges + +L'interface lit les réponses JSON telles que Node les produit aujourd'hui. Chaque écart ci-dessous casserait un écran sans erreur visible ; le banc les détecte, mais autant les traiter dès le Lot 1. + +| Sujet | Ce que Node renvoie | Ce qu'il faut en Rust | +|---|---|---| +| Montants (`decimal(10,2)`, 7 colonnes) | du texte : `"12.50"` | `rust_decimal` sérialisé en texte, échelle conservée ; jamais un nombre JSON | +| Dates (`date`, 10 colonnes) | `"2026-10-15"` | `chrono::NaiveDate` (format par défaut identique) | +| Horodatages (`timestamp` sans fuseau, 62 colonnes) | `"2026-10-15T08:30:00.000Z"` : lus comme UTC, millisecondes et `Z` | sérialiseur dédié `%Y-%m-%dT%H:%M:%S%.3fZ` ; le format par défaut de `chrono` n'a ni `Z` ni millisecondes fixes | +| Noms de champs | `camelCase` (Drizzle) ; certaines requêtes brutes rendent du `snake_case` | `#[serde(rename_all = "camelCase")]` par défaut, exceptions relevées au banc | +| Champs vides | présents avec `null` | ne jamais omettre un champ `None` | +| Objets imbriqués | `group`, `supplier`, `creator` dans la plupart des listes ; `dlcDate` en plus de `expiryDate` pour les DLC | mêmes structures, construites en une requête avec jointures | +| Identifiants | entiers (`serial`), sauf `users.id` (texte, ex. `admin_local`) et `database_backups.id` (nanoid) | mêmes types | +| Mot de passe SMTP | remplacé par `smtpPasswordSet: true/false` dans toute réponse | jamais sélectionné dans les requêtes ; `smtpPasswordSet` calculé en SQL | +| Erreurs | `{ "message": "…" }`, 401 si non connecté | identique | +| Déconnexion | redirection 302 vers `/auth` | identique | +| « Aujourd'hui » | `new Date().toISOString().split('T')[0]` : date UTC, fausse entre minuit et 2 h l'été | heure de Paris (décision R5), écart déclaré au banc | +| Corps des requêtes | jusqu'à 10 Mo (logos en data URI, PDF en base64) | même limite | + +## Ce qu'on ne reporte pas + +Une migration est l'occasion de ne pas recopier ce qui est dangereux ou mort. Chaque ligne est à confirmer (décision R3) ; les adresses sans appelant connu sont d'abord cherchées dans les journaux d'accès de production et les workflows n8n. + +| Élément | Où | Proposition | +|---|---|---| +| Exécution de SQL arbitraire | `POST /api/admin/execute-sql` (`server/routes.ts:5797`), SQL-01 | Ne pas reporter : la base s'administre avec `psql`. | +| Réinitialisation d'urgence de l'administrateur | `POST /api/emergency-admin-reset` (`server/index.production.ts:87`) | Remplacer par une commande du binaire (`logiflow reset-admin`), lancée dans le conteneur. | +| Compte `admin` / `admin` créé au démarrage, mot de passe `admin.salt` accepté | `server/localAuth.ts:30`, `:59`, AUTH-04 | Ne pas reporter : premier administrateur créé par la commande ci-dessus. | +| Adresse de webhook fournie par le navigateur | `POST /api/reconciliation/send-invoice` (`server/routes.ts:823`), RAPPRO-21 | Adresse lue en base uniquement (configuration du magasin). | +| Nom de fichier de sauvegarde non contrôlé | `/api/backups/:filename` (`server/routes.ts:4916`, `:4938`), BACKUP-09 | Identifiant de sauvegarde lu en base, jamais un chemin. | +| Géolocalisation météo modifiable par tout utilisateur connecté | `POST /api/weather/geolocation` (`server/routes.ts:5740`) | Réservée à l'administrateur. | +| Routes masquées par une route identique déclarée avant | `POST /api/users/:id/groups`, `DELETE /api/users/:id/groups/:groupId` (`server/routes.ts:4179`, `:4199`) | Ne pas reporter (jamais atteintes). | +| Adresses sans appelant dans `client/src` | `bap/send-webhook`, `sync-order-delivery-status`, `cache/diagnosis`, `cache/update-reconciled`, `verify-invoice`, `verify-invoices`, `avoirs/:id/webhook-status`, `nocodb-config/active`, `ad-campaigns/debug`, `admin/emergency-migration`, diagnostic du schéma | Reporter seulement celles qui ont un appelant en production. | +| Stockage en mémoire de développement | `MemStorage`, `server/storage.ts:3510-5909` | Ne pas reporter : le développement utilise une base PostgreSQL locale (Docker). | +| Pilote Neon par WebSocket | `server/db.ts:55` | Ne pas reporter : PostgreSQL direct partout. | + +**Protection des requêtes d'écriture** (décision R4). La production n'en a aucune aujourd'hui. Proposition : cookie `SameSite=Lax` et contrôle de l'en-tête `Origin` sur toute requête qui n'est pas une lecture, sauf `/api/ext/` (clé d'API). Les navigateurs envoient cet en-tête d'eux-mêmes : rien à changer dans l'interface. + +## Vérification + +- **Le banc fait foi.** Une adresse passe en Rust quand le banc ne montre aucune différence avec Node pour les 5 comptes, en lecture et en écriture. Une différence voulue (fuseau horaire, faille fermée) est inscrite dans la liste des écarts attendus du banc, avec sa raison. +- **Bug pour bug, sauf décision.** On reporte le comportement de Node tel qu'il est au moment du portage. Un bug découvert en route est corrigé d'abord dans Node (et vérifié au banc), ou inscrit comme écart attendu ; jamais corrigé en silence dans Rust. +- **Tests Rust** : + - unitaires sur les règles pures : droits (chaque case du tableau rôle × module), cloisonnement par magasin, recherche de facture, normalisation des dates, montants à la française, exports CSV ; + - de compatibilité, sur des valeurs produites par le code Node : empreintes de mots de passe, secrets `enc:v1`, cookies signés, clés d'API ; + - d'intégration, contre une base PostgreSQL jetable, par module. +- **Requêtes SQL vérifiées à la compilation** par `sqlx` : une colonne renommée ou un type faux ne compile plus. +- **Mesures de performance** au banc, Node puis Rust, à chaque lot : temps de réponse (médiane et 95ᵉ centile) par adresse, mémoire du serveur, temps de démarrage, taille de l'image. Objectif : aucune adresse plus lente qu'avec Node. Les gains sont à mesurer, pas à supposer. +- **Avec chaque rôle, sur PC et téléphone**, comme pour toute livraison (règles communes du plan du webUI). + +## Articulation avec le plan du webUI + +La migration ne remplace pas le plan d'optimisation du webUI (`docs/PLAN-OPTIMISATION-WEBUI.md`) ; elle change l'endroit où se fait sa partie serveur. + +| Phase du plan du webUI | Avec la migration | +|---|---| +| **1a — Corrections critiques** | **À faire dans Node, avant le Lot 0**, sans attendre : ce sont des fuites de données et des failles en production aujourd'hui. Rust reprend ensuite le comportement corrigé. | +| **1b — Corrections rapides de l'interface** | Inchangée : elle ne touche que `client/`. | +| **2 — Socle** | La partie interface (menu, design system, composants) avance en parallèle. Les règles serveur T2 à T7 et la table de droits unique se font **dans Rust**, module par module, au lieu d'être écrites deux fois. | +| **3 — Refonte des pages** | En parallèle. Quand une page a besoin d'une nouvelle adresse (synthèse de l'accueil DASH-05, listes paginées), elle s'écrit en Rust si son module est déjà porté, et son module passe en tête de file sinon. | +| **4 — Performance, lots 2 et 3** | Le banc (lot 2, n° 0) devient le Lot 0 de la migration. Les chantiers côté serveur (requêtes ciblées, échéancier en SQL, journal à niveaux) se font en Rust, lors du portage du module concerné. | +| **5 — Dette** | La partie serveur (point d'entrée unique, suppression de `localAuth.production.ts`, `console.log`, SQL paramétré, identifiants générés par le serveur) est absorbée par la migration. Restent la fusion PC / téléphone et la dette de l'interface. | + +## Risques + +| Risque | Conséquence | Parade | +|---|---|---| +| Effort sous-estimé, surtout au Lot 4 | Deux serveurs plus longtemps que prévu | Lots indépendants, chacun livré seul : la migration peut s'arrêter après n'importe quel lot sans rien casser, Node servant le reste. | +| Écart de format JSON non vu | Un écran affiche une valeur fausse ou vide, sans erreur | Banc sur 5 comptes, en écriture comme en lecture ; types de l'interface générés depuis Rust au Lot 5. | +| Sessions incompatibles | Utilisateurs déconnectés, ou pire, connectés sous un autre compte | Lecture du cookie signé testée sur des cookies produits par Node ; une session illisible vaut « non connecté », jamais un autre utilisateur. | +| Tâche de fond lancée deux fois | Deux sauvegardes, purges concurrentes | Toutes les tâches restent dans Node jusqu'au Lot 5, puis passent ensemble. | +| Changement de schéma pendant la transition | Node et Rust en désaccord sur une table | Gel du schéma du module en cours ; tout changement passe par `server-rs/migrations/`. | +| Compétence Rust dans l'équipe | Personne pour maintenir le serveur | Décision R6 avant de commencer ; code simple, sans abstraction gratuite, commentaires en français comme le reste du dépôt. | +| Nouvelles demandes métier pendant la migration | Tentation d'écrire les fonctions deux fois | Gel des nouvelles fonctions serveur pour le module en cours de portage seulement (décision R2). | +| Mémoire : deux serveurs pendant la transition | Plus de mémoire consommée sur l'hôte | Le serveur Rust ajoute peu ; à mesurer au Lot 0. | + +## Décisions à prendre + +| N° | Question | Proposition par défaut | +|---|---|---| +| R1 | **Périmètre** : le serveur seul, ou aussi l'interface en Rust ? | **Le serveur seul.** L'interface React reste, et sa refonte suit le plan du webUI. | +| R2 | **Gel des fonctions** : peut-on suspendre les évolutions serveur d'un module pendant son portage (1 à 4 semaines) ? | Oui, module par module ; les urgences passent dans Node puis sont reportées au banc. | +| R3 | **Ce qu'on ne reporte pas** : la liste de [Ce qu'on ne reporte pas](#ce-quon-ne-reporte-pas). | Tout ce qui y est proposé, après lecture des journaux d'accès de production pour les adresses sans appelant connu. | +| R4 | **Protection des requêtes d'écriture** : l'ajouter à la migration ? | Oui : `SameSite=Lax` et contrôle de `Origin`, sans changement de l'interface. | +| R5 | **Fuseau horaire** : « aujourd'hui » et la sauvegarde de 2 h à l'heure de Paris ? | Oui (c'est T5 du plan du webUI) ; écart déclaré au banc. | +| R6 | **Qui écrit et maintient le code Rust**, et qui le relit ? | À désigner avant le Lot 0 ; si personne ne connaît Rust, prévoir 1 à 2 mois d'apprentissage, ou ne pas lancer la migration. | diff --git a/docs/PLAN-OPTIMISATION-WEBUI.md b/docs/PLAN-OPTIMISATION-WEBUI.md index ee7ba3f..cc41ddc 100644 --- a/docs/PLAN-OPTIMISATION-WEBUI.md +++ b/docs/PLAN-OPTIMISATION-WEBUI.md @@ -380,17 +380,17 @@ Aujourd'hui : 19 entrées en 5 sections pour un admin, dont une section d'une se | Section | Ordre | Libellé exact | Icône (lucide) | Adresse | Libellé actuel PC / mobile | |---|---|---|---|---|---| | *(en tête, sans titre)* | 1 | Accueil | `Home` | `/` | Tableau de bord / Accueil | -| **Au quotidien** | 2 | Tâches | `ListTodo` | `/tasks` | Tâches (icône `CheckSquare` sur mobile) | -| | 3 | DLC | `CalendarClock` | `/dlc` | Gestion DLC / DLC | -| | 4 | Commandes clients | `ShoppingCart` | `/customer-orders` | Commandes Client / Cmd Client | -| | 5 | SAV | `Wrench` | `/sav` | SAV | -| **Fournisseurs** | 6 | Commandes fournisseurs | `Package` | `/orders` | Commandes | -| | 7 | Livraisons | `Truck` | `/deliveries` | Livraisons | -| | 8 | Rapprochement BL / factures | `FileCheck` | `/bl-reconciliation` | Rapprochement (absent du mobile) | -| | 9 | Avoirs | `Receipt` | `/avoirs` | Avoirs | -| | 10 | Échéancier | `CreditCard` | `/payment-schedule` | Échéance (absent du mobile) | -| **Planning et infos** | 11 | Calendrier | `CalendarDays` | `/calendar` | Calendrier (« Agenda » sur l'accueil mobile, DASH-31) | -| | 12 | Publicités | `Megaphone` | `/publicities` | Publicités | +| | 2 | Calendrier | `CalendarDays` | `/calendar` | Calendrier (« Agenda » sur l'accueil mobile, DASH-31) | +| **Au quotidien** | 3 | Tâches | `ListTodo` | `/tasks` | Tâches (icône `CheckSquare` sur mobile) | +| | 4 | DLC | `CalendarClock` | `/dlc` | Gestion DLC / DLC | +| | 5 | Commandes clients | `ShoppingCart` | `/customer-orders` | Commandes Client / Cmd Client | +| | 6 | SAV | `Wrench` | `/sav` | SAV | +| **Fournisseurs** | 7 | Commandes fournisseurs | `Package` | `/orders` | Commandes | +| | 8 | Livraisons | `Truck` | `/deliveries` | Livraisons | +| | 9 | Rapprochement BL / factures | `FileCheck` | `/bl-reconciliation` | Rapprochement (absent du mobile) | +| | 10 | Avoirs | `Receipt` | `/avoirs` | Avoirs | +| | 11 | Échéancier | `CreditCard` | `/payment-schedule` | Échéance (absent du mobile) | +| **Planning et infos** | 12 | Publicités | `Megaphone` | `/publicities` | Publicités | | | 13 | Contacts | `BookUser` | `/contacts` | Contacts (absent du mobile, CONT-09) | | | 14 | Statistiques | `LineChart` | `/analytics` | Statistiques (icône `BarChart3` en double ; titre de page « Tableau de bord Analytics », ANA-15) | | **Administration** | 15 | Utilisateurs | `UserCog` | `/users` | Utilisateurs | @@ -400,6 +400,7 @@ Aujourd'hui : 19 entrées en 5 sections pour un admin, dont une section d'une se Règles associées : +- **Accueil et Calendrier en tête du menu**, les deux pages consultées le plus souvent, avant les sections (décision du 15 octobre 2026). - **Chaque icône n'apparaît qu'une fois dans le menu.** `Store` sert aujourd'hui de logo dans l'en-tête du menu (Sidebar.tsx:400) : le logo passe à une autre icône (ex. `Boxes`) ou à une image, pour que `Store` désigne uniquement « Magasins ». (SHELL-12) - **Titres de section** en casse normale, 12 px semi-gras, sans majuscules forcées (aujourd'hui `text-[11px] uppercase tracking-widest`, SHELL-22). - **Les adresses ne changent pas** (liens existants, favoris). `/` et `/dashboard` activent tous deux « Accueil » (SHELL-13). @@ -414,6 +415,7 @@ Légende : **Gérer** = voir, créer, modifier, supprimer · **Voir** = consulta | Module | Admin | Directeur | Manager | Employé | Incohérences actuelles corrigées | |---|---|---|---|---|---| | Accueil | ✓ | ✓ | ✓ | ✓ | — | +| Calendrier | Gérer | Gérer | Créer, modifier | Voir | case cliquable même sans droit (CAL-08) | | Tâches | Gérer | Gérer | Créer, modifier, terminer | Voir + terminer (D6) | « Supprimer » proposé aux managers sur PC, « Terminer » à tous sur mobile, serveur sans contrôle de rôle (TASKS-13, TASKS-26) | | DLC | Gérer | Gérer | Créer, modifier, retirer | Voir, ajouter + retirer du rayon (D7) | « Valider » proposé puis refusé en silence sur mobile (DLC-13) | | Commandes clients | Gérer | Gérer | Créer, modifier | Voir, créer + « client prévenu », « retirée » (D8) | serveur ne vérifie pas le rôle en modification (CMDCLI-07, CMDCLI-10) | @@ -423,7 +425,6 @@ Légende : **Gérer** = voir, créer, modifier, supprimer · **Voir** = consulta | Rapprochement BL / factures | Gérer | Gérer sauf supprimer | **D3** — proposé : — | — | message de la page « managers », menu sans manager, serveur sans manager (RAPPRO-17) | | Avoirs | Gérer | Gérer | Voir, créer | **D2** — proposé : — | visible pour tous sur mobile, masqué sur PC (SHELL-11, AVOIRS-04) | | Échéancier | Voir | Voir | — | — | inaccessible sur mobile (SHELL-10) | -| Calendrier | Gérer | Gérer | Créer, modifier | Voir | case cliquable même sans droit (CAL-08) | | Publicités | Gérer | Voir | Voir | Voir | « + » mobile sans action, visible pour tous (PUB-11) | | Contacts | Gérer | Gérer | Gérer | Voir | employé bloqué sans magasin (CONT-04), absent du mobile (CONT-09) | | Statistiques | Voir | Voir | Voir | **D4** — proposé : — | API ouverte à tous, sans filtre par magasin pour un bloc (ANA-05) | diff --git a/migrations/migrate.js b/migrations/migrate.js deleted file mode 100644 index 358f15b..0000000 --- a/migrations/migrate.js +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node - -/** - * Database Migration Script - * Automatically runs database migrations for production deployment - */ - -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -// Database connection -const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false -}); - -async function runMigrations() { - console.log('🔄 Starting database migrations...'); - - try { - // Create migrations table if it doesn't exist - await pool.query(` - CREATE TABLE IF NOT EXISTS migrations ( - id SERIAL PRIMARY KEY, - filename VARCHAR(255) UNIQUE NOT NULL, - executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - `); - console.log('✅ Migrations table ready'); - - // Get list of executed migrations - const { rows: executedMigrations } = await pool.query( - 'SELECT filename FROM migrations ORDER BY executed_at' - ); - const executedSet = new Set(executedMigrations.map(row => row.filename)); - - // Read migration files - const migrationsDir = path.join(__dirname); - const files = fs.readdirSync(migrationsDir) - .filter(file => file.endsWith('.sql')) - .sort(); - - console.log(`📁 Found ${files.length} migration files`); - - let appliedCount = 0; - - for (const file of files) { - if (executedSet.has(file)) { - console.log(`⏭️ Skipping ${file} (already executed)`); - continue; - } - - console.log(`🔄 Executing migration: ${file}`); - - const filePath = path.join(migrationsDir, file); - const sql = fs.readFileSync(filePath, 'utf8'); - - // Execute migration in a transaction - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query(sql); - await client.query( - 'INSERT INTO migrations (filename) VALUES ($1)', - [file] - ); - await client.query('COMMIT'); - console.log(`✅ Successfully executed: ${file}`); - appliedCount++; - } catch (error) { - await client.query('ROLLBACK'); - throw new Error(`Failed to execute ${file}: ${error.message}`); - } finally { - client.release(); - } - } - - if (appliedCount === 0) { - console.log('✅ All migrations are up to date'); - } else { - console.log(`✅ Successfully applied ${appliedCount} migration(s)`); - } - - } catch (error) { - console.error('❌ Migration failed:', error.message); - process.exit(1); - } finally { - await pool.end(); - } -} - -// Run migrations if this file is executed directly -if (require.main === module) { - runMigrations(); -} - -module.exports = { runMigrations }; \ No newline at end of file diff --git a/migrations/production_fix_announcements.js b/migrations/production_fix_announcements.js deleted file mode 100644 index 5474f03..0000000 --- a/migrations/production_fix_announcements.js +++ /dev/null @@ -1,103 +0,0 @@ -// Script de migration production pour créer la table announcements -// Usage: node migrations/production_fix_announcements.js - -import { drizzle } from 'drizzle-orm/node-postgres'; -import pg from 'pg'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; -import { readFileSync } from 'fs'; - -const { Pool } = pg; - -async function createAnnouncementsTable() { - const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - }); - - const db = drizzle(pool); - - try { - console.log('🔄 Création de la table announcements...'); - - // SQL pour créer la table announcements - const sql = ` - -- Création de la table announcements - CREATE TABLE IF NOT EXISTS announcements ( - id SERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - priority VARCHAR(20) NOT NULL DEFAULT 'normal' CHECK (priority IN ('normal', 'important', 'urgent')), - author_id VARCHAR(255) NOT NULL, - group_id INTEGER, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - CONSTRAINT announcements_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT announcements_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE - ); - - -- Création des index - CREATE INDEX IF NOT EXISTS idx_announcements_priority ON announcements(priority); - CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_announcements_author_id ON announcements(author_id); - CREATE INDEX IF NOT EXISTS idx_announcements_group_id ON announcements(group_id); - - -- Commentaires - COMMENT ON TABLE announcements IS 'Admin-managed announcements/information system'; - COMMENT ON COLUMN announcements.priority IS 'Priority level: normal, important, urgent'; - COMMENT ON COLUMN announcements.group_id IS 'NULL for global announcements, specific group_id for store-specific announcements'; - `; - - await pool.query(sql); - - console.log('✅ Table announcements créée avec succès!'); - - // Vérification que la table existe - const checkResult = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'announcements' - ); - `); - - if (checkResult.rows[0].exists) { - console.log('✅ Vérification: La table announcements existe bien'); - - // Afficher la structure de la table - const structure = await pool.query(` - SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_name = 'announcements' - ORDER BY ordinal_position; - `); - - console.log('📋 Structure de la table:'); - structure.rows.forEach(row => { - console.log(` ${row.column_name}: ${row.data_type}${row.is_nullable === 'NO' ? ' NOT NULL' : ''}`); - }); - } else { - console.log('❌ Erreur: La table announcements n\'a pas été créée'); - } - - } catch (error) { - console.error('❌ Erreur lors de la création de la table:', error); - throw error; - } finally { - await pool.end(); - } -} - -// Exécution -if (import.meta.url === `file://${process.argv[1]}`) { - createAnnouncementsTable() - .then(() => { - console.log('🎉 Migration terminée avec succès'); - process.exit(0); - }) - .catch((error) => { - console.error('💥 Échec de la migration:', error); - process.exit(1); - }); -} - -export { createAnnouncementsTable }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d86353d..1ca640e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { - "name": "rest-express", + "name": "logiflow", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "rest-express", + "name": "logiflow", "version": "1.0.0", "license": "MIT", "dependencies": { "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", - "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -40,49 +39,20 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", - "@types/bcrypt": "^6.0.0", - "@types/busboy": "^1.5.4", - "@types/cookie-parser": "^1.4.10", - "@types/form-data": "^2.2.1", - "@types/memoizee": "^0.4.12", - "@types/multer": "^2.0.0", "@types/pg": "^8.15.4", - "@types/validator": "^13.15.10", - "bcrypt": "^6.0.0", - "busboy": "^1.6.0", "caniuse-lite": "^1.0.30001727", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "compression": "^1.8.2", - "connect-pg-simple": "^10.0.0", - "cookie-parser": "^1.4.7", - "csrf-csrf": "^4.0.3", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", - "encoding": "^0.1.13", - "express": "^4.21.2", - "express-rate-limit": "^7.5.1", - "express-session": "^1.18.1", - "form-data": "^4.0.4", "framer-motion": "^11.13.1", - "iconv-lite": "^0.6.3", "input-otp": "^1.4.2", "jsbarcode": "^3.12.1", "lucide-react": "^0.453.0", - "memoizee": "^0.4.17", - "memorystore": "^1.6.7", - "multer": "^2.0.2", - "nanoid": "^5.1.5", "next-themes": "^0.4.6", - "node-cron": "^4.2.1", - "node-fetch": "^3.3.2", - "nodemailer": "^9.0.5", - "openid-client": "^6.7.1", - "passport": "^0.7.0", - "passport-local": "^1.0.0", "pg": "^8.16.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -94,43 +64,26 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", - "validator": "^13.15.26", "vaul": "^1.1.2", "wouter": "^3.3.5", - "ws": "^8.18.0", - "xlsx": "^0.18.5", - "zod": "^3.24.2", - "zod-validation-error": "^3.4.0" + "zod": "^3.24.2" }, "devDependencies": { "@replit/vite-plugin-cartographer": "^0.2.7", "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/compression": "^1.8.1", - "@types/connect-pg-simple": "^7.0.3", - "@types/express": "^4.17.21", - "@types/express-session": "^1.18.0", "@types/node": "^20.16.11", - "@types/node-fetch": "^2.6.13", - "@types/nodemailer": "^8.0.1", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", - "@types/ws": "^8.5.13", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.30.4", - "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", "tsx": "^4.19.1", "typescript": "5.6.3", "vite": "^5.4.19" - }, - "optionalDependencies": { - "bufferutil": "^4.0.8" } }, "node_modules/@alloc/quick-lru": { @@ -1452,6 +1405,8 @@ "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/pg": "8.11.6" } @@ -1461,6 +1416,8 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -1472,6 +1429,8 @@ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", @@ -1490,6 +1449,8 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -1499,6 +1460,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "obuf": "~1.1.2" }, @@ -1511,6 +1474,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -1520,6 +1485,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -3474,6 +3441,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -3617,75 +3644,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/busboy": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", - "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-pg-simple": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", - "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/express-session": "*", - "@types/pg": "*" - } - }, - "node_modules/@types/cookie-parser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", - "license": "MIT", - "peerDependencies": { - "@types/express": "*" - } - }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -3756,76 +3714,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/form-data": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", - "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/memoizee": { - "version": "0.4.12", - "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz", - "integrity": "sha512-EdtpwNYNhe3kZ+4TlXj/++pvBoU0KdrAICMzgI7vjWgu9sIvvUhu9XR8Ks4L6Wh3sxpZ22wkZR7yCLAqUjnZuQ==", - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/node": { "version": "20.16.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", @@ -3835,60 +3723,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/passport": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", - "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/passport-local": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", - "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, - "node_modules/@types/passport-strategy": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", - "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*" - } - }, "node_modules/@types/pg": { "version": "8.15.4", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", @@ -3907,18 +3741,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", @@ -3940,43 +3762,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3998,28 +3783,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -4063,12 +3826,6 @@ "node": ">= 8" } }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4087,18 +3844,6 @@ "node": ">=10" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -4143,20 +3888,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4169,86 +3900,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "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/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "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/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -4307,71 +3958,9 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, - "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "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", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "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/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4401,19 +3990,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4497,15 +4073,6 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4524,18 +4091,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4545,113 +4100,6 @@ "node": ">= 6" } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", - "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "destroy": "1.2.0", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/connect-pg-simple": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", - "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", - "license": "MIT", - "dependencies": { - "pg": "^8.12.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=22.0.0" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4659,55 +4107,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/cookie-parser/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4743,15 +4142,6 @@ "node": ">= 8" } }, - "node_modules/csrf-csrf": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", - "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", - "license": "ISC", - "dependencies": { - "http-errors": "^2.0.0" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4770,19 +4160,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -4904,15 +4281,6 @@ "node": ">=12" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -4927,6 +4295,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4946,34 +4315,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -5586,32 +4927,12 @@ "zod": ">=3.0.0" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.187", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", @@ -5653,24 +4974,6 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -5698,103 +5001,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "license": "ISC", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, "node_modules/esbuild": { "version": "0.25.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.7.tgz", @@ -5860,186 +5066,12 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "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.15.1", - "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/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express-session/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "license": "ISC", - "dependencies": { - "type": "^2.7.2" - } - }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", @@ -6086,29 +5118,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6121,39 +5130,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "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.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6170,52 +5146,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6257,15 +5187,6 @@ } } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6333,30 +5254,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "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-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6366,19 +5263,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -6424,18 +5308,6 @@ "node": ">=10.13.0" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6443,33 +5315,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6482,40 +5327,6 @@ "node": ">= 0.4" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -6535,15 +5346,6 @@ "node": ">=12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6610,12 +5412,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -6651,15 +5447,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", - "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7004,15 +5791,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "license": "MIT", - "dependencies": { - "es5-ext": "~0.10.2" - } - }, "node_modules/lucide-react": { "version": "0.453.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", @@ -7032,81 +5810,6 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memoizee": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", - "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "es5-ext": "^0.10.64", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/memorystore": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", - "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.0", - "lru-cache": "^4.0.3" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/memorystore/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "license": "ISC", - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/memorystore/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "license": "ISC" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7116,15 +5819,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7138,39 +5832,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -7256,27 +5917,9 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, - "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "type-is": "^1.6.18" - }, - "engines": { - "node": ">= 10.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -7288,33 +5931,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nanoid": { - "version": "5.1.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", - "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7325,79 +5941,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "license": "ISC" - }, - "node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-cron": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", - "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -7405,15 +5948,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemailer": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", - "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7433,15 +5967,6 @@ "node": ">=0.10.0" } }, - "node_modules/oauth4webapi": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.1.tgz", - "integrity": "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7460,57 +5985,13 @@ "node": ">= 6" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/openid-client": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.7.1.tgz", - "integrity": "sha512-kOiE4q0kNogr90hXsxPrKeEDuY+V0kkZazvZScOwZkYept9slsaQ3usXTaKkm6I04vLNuw5caBoX7UfrwC6x8w==", - "license": "MIT", - "dependencies": { - "jose": "^6.1.0", - "oauth4webapi": "^3.8.0" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } + "optional": true, + "peer": true }, "node_modules/package-json-from-dist": { "version": "1.0.1", @@ -7518,52 +5999,6 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/passport": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", - "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "license": "MIT", - "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7601,17 +6036,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" - }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -7666,6 +6090,8 @@ "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -7964,7 +6390,9 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -7983,41 +6411,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "license": "ISC" - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8038,80 +6431,6 @@ ], "license": "MIT" }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "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/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "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/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8311,20 +6630,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8481,32 +6786,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -8526,75 +6805,6 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8629,78 +6839,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "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", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "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", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "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", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "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/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8752,44 +6890,6 @@ "node": ">= 10.x" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -9057,19 +7157,6 @@ "node": ">=0.8" } }, - "node_modules/timers-ext": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", - "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9088,15 +7175,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9138,31 +7216,6 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -9177,33 +7230,12 @@ "node": ">=14.17" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -9293,33 +7325,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -9845,15 +7850,6 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -9870,24 +7866,6 @@ "node": "^16.13.0 || >=18.0.0" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/wouter": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz", @@ -9993,48 +7971,6 @@ "node": ">=8" } }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -10074,18 +8010,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-validation-error": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.3.tgz", - "integrity": "sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } } } } diff --git a/package.json b/package.json index 8276aaa..676d8aa 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,20 @@ { - "name": "rest-express", + "name": "logiflow", "version": "1.0.0", "type": "module", "license": "MIT", "scripts": { - "dev": "NODE_ENV=development tsx server/index.ts", - "build": "vite build && tsc --project tsconfig.server.json", - "start": "NODE_ENV=production node dist/server/index.js", + "dev": "vite", + "dev:server": "cargo run --manifest-path server-rs/Cargo.toml", + "build": "vite build && cargo build --release --manifest-path server-rs/Cargo.toml", + "start": "./server-rs/target/release/logiflow", "check": "tsc", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "gen:rust-schema": "tsx scripts/gen-rust-schema.ts" }, "dependencies": { "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", - "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -42,49 +43,20 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", - "@types/bcrypt": "^6.0.0", - "@types/busboy": "^1.5.4", - "@types/cookie-parser": "^1.4.10", - "@types/form-data": "^2.2.1", - "@types/memoizee": "^0.4.12", - "@types/multer": "^2.0.0", "@types/pg": "^8.15.4", - "@types/validator": "^13.15.10", - "bcrypt": "^6.0.0", - "busboy": "^1.6.0", "caniuse-lite": "^1.0.30001727", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "compression": "^1.8.2", - "connect-pg-simple": "^10.0.0", - "cookie-parser": "^1.4.7", - "csrf-csrf": "^4.0.3", "date-fns": "^3.6.0", "drizzle-orm": "^0.39.1", "drizzle-zod": "^0.7.0", "embla-carousel-react": "^8.6.0", - "encoding": "^0.1.13", - "express": "^4.21.2", - "express-rate-limit": "^7.5.1", - "express-session": "^1.18.1", - "form-data": "^4.0.4", "framer-motion": "^11.13.1", - "iconv-lite": "^0.6.3", "input-otp": "^1.4.2", "jsbarcode": "^3.12.1", "lucide-react": "^0.453.0", - "memoizee": "^0.4.17", - "memorystore": "^1.6.7", - "multer": "^2.0.2", - "nanoid": "^5.1.5", "next-themes": "^0.4.6", - "node-cron": "^4.2.1", - "node-fetch": "^3.3.2", - "nodemailer": "^9.0.5", - "openid-client": "^6.7.1", - "passport": "^0.7.0", - "passport-local": "^1.0.0", "pg": "^8.16.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -96,42 +68,25 @@ "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.2.5", - "validator": "^13.15.26", "vaul": "^1.1.2", "wouter": "^3.3.5", - "ws": "^8.18.0", - "xlsx": "^0.18.5", - "zod": "^3.24.2", - "zod-validation-error": "^3.4.0" + "zod": "^3.24.2" }, "devDependencies": { "@replit/vite-plugin-cartographer": "^0.2.7", "@replit/vite-plugin-runtime-error-modal": "^0.0.3", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/compression": "^1.8.1", - "@types/connect-pg-simple": "^7.0.3", - "@types/express": "^4.17.21", - "@types/express-session": "^1.18.0", "@types/node": "^20.16.11", - "@types/node-fetch": "^2.6.13", - "@types/nodemailer": "^8.0.1", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", - "@types/ws": "^8.5.13", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.30.4", - "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", "tsx": "^4.19.1", "typescript": "5.6.3", "vite": "^5.4.19" - }, - "optionalDependencies": { - "bufferutil": "^4.0.8" } } diff --git a/replit.md b/replit.md index 542494c..6de3688 100644 --- a/replit.md +++ b/replit.md @@ -21,7 +21,7 @@ The platform is built with a clear separation of concerns, utilizing a modern fu **Technical Implementations & Design Patterns:** - **Frontend:** React.js for interactive user interfaces, with React Query for data fetching and state management. -- **Backend:** Node.js Express (ESM) provides a robust API layer. Data validation is performed using Zod schemas. +- **Backend:** Rust server (axum, tokio, sqlx) in `server-rs/` serves the API and the built UI, runs startup migrations and background jobs. Validation keeps the Zod rules and messages of `shared/schema.ts`, from which `server-rs/src/schema.rs` is generated. - **Database:** PostgreSQL as the primary data store, with Drizzle ORM facilitating interaction and database migrations ensuring schema consistency. - **Authentication:** Secure role-based access control (RBAC) for granular permissions. - **Multi-tenancy:** Supports multi-tenant operations with group and permission management, ensuring data isolation between stores/groups. diff --git a/scripts/bench/README.md b/scripts/bench/README.md new file mode 100644 index 0000000..fbd3364 --- /dev/null +++ b/scripts/bench/README.md @@ -0,0 +1,50 @@ +# Banc de comparaison Node / Rust + +Le serveur Node a quitté le dépôt ; il reste la référence du comportement attendu. Le banc rejoue les mêmes requêtes sur le serveur Node de référence (construit depuis le commit `REFERENCE_NODE_COMMIT`) et sur le serveur Rust et compare, requête par requête : code HTTP, corps (valeurs et ordre des listes), en-têtes utiles (`Content-Type`, `Cache-Control`, `Location`, `Content-Disposition`, attributs des cookies), état des tables après les écritures, et appels faits aux services externes. Tout changement du serveur Rust qui ne doit pas changer son comportement passe par lui ; un changement voulu y est déclaré comme écart (`deviation`). + +## Préparer + +1. PostgreSQL local, avec un rôle qui peut créer des bases : + ```bash + sudo -u postgres psql -c "CREATE ROLE logiflow_admin LOGIN SUPERUSER PASSWORD 'bench'" + ``` + Autre adresse : variable `BENCH_PG_URL` (par défaut `postgres://logiflow_admin:bench@localhost:5432`). +2. Construire le serveur Node de référence : `scripts/bench/reference-node.sh`. Il en fait une copie de travail à part (`.bench-reference/`, ignorée par git) au commit `REFERENCE_NODE_COMMIT`, avec son interface ; à refaire seulement quand ce commit change. Autre dossier : variable `BENCH_NODE_DIR`. +3. Construire le serveur Rust : `cargo build --release --manifest-path server-rs/Cargo.toml` (autre binaire : `RUST_BIN`). +4. Créer la base modèle `bench_seed` : `scripts/bench/setup.sh`. Elle contient le schéma de production (`schema.sh`), les index créés au premier démarrage du Node de référence et les données de `seed.ts` : 3 magasins, 5 comptes, quelques lignes par table, datées par rapport au jour du passage. + +## Lancer + +```bash +npx tsx scripts/bench/compare.ts # Node de référence contre Rust +npx tsx scripts/bench/compare.ts --only dlc,tasks # seulement ces modules +npx tsx scripts/bench/compare.ts --mode node # Node contre Node : le banc est-il stable ? +``` + +Les deux serveurs servent la même interface (celle de la référence). Plusieurs bancs en parallèle : `BENCH_ID` (bases distinctes) et `BENCH_PORT_BASE` (ports distincts). + +Le résultat liste les écarts, puis un tableau par module. Les réponses complètes et les écarts sont écrits dans le dossier de journaux affiché au début (`gauche.json`, `droite.json`, `ecarts.json`), avec les journaux des serveurs. + +## Ajouter des requêtes + +Une liste par module dans `cases/` (lectures et scénarios d'écriture, voir `types.ts`), déclarée dans `cases/index.ts`. + +- **Lecture** : rejouée pour les 5 comptes et sans session, sur la même base. +- **Écriture** : base remise à zéro avant chaque compte ; les tables listées sont comparées à la fin. +- **Gabarits** : `{{today}}`, `{{today+3}}`, `{{year-1}}`, `{{month}}` ; `{{0.id}}` reprend l'`id` de la réponse de l'étape 0. +- Les horodatages proches du passage sont remplacés par `` ; `ignore` masque d'autres champs propres à chaque passage. +- **Écart voulu** (faille fermée, décision du plan) : `deviation: { fields, reason }` retire ces champs des deux côtés avant la comparaison ; le banc rappelle la raison en fin de rapport. + +## Comptes et services simulés + +| Compte | Rôle | Magasins | +|---|---|---| +| `admin` | admin | aucun (voit tout) | +| `directeur` | directeur | Nancy, Metz | +| `manager` | manager | Nancy | +| `employe` | employee | Nancy | +| `sans-magasin` | employee | aucun | + +Plus `anonyme` (sans session) et `api` (clé de l'API externe). Mots de passe et clé : `config.ts`. + +`stub.ts` simule NocoDB (table de factures), les webhooks (avoirs, BAP) et un serveur SMTP ; les magasins du banc pointent vers lui. La météo (Visual Crossing) et ffnancy, dont les adresses sont écrites dans le code, ne sont pas rejouées. diff --git a/scripts/bench/REFERENCE_NODE_COMMIT b/scripts/bench/REFERENCE_NODE_COMMIT new file mode 100644 index 0000000..6491a6d --- /dev/null +++ b/scripts/bench/REFERENCE_NODE_COMMIT @@ -0,0 +1 @@ +8f4d799016e6dcd75cbef17ad27193f47b79300a diff --git a/scripts/bench/cases/administration.ts b/scripts/bench/cases/administration.ts new file mode 100644 index 0000000..5a1fa6f --- /dev/null +++ b/scripts/bench/cases/administration.ts @@ -0,0 +1,299 @@ +// Lot 5 : connexion et session, utilisateurs, magasins, sauvegardes, paramètres +// généraux, diagnostic du schéma (docs/PLAN-MIGRATION-RUST.md). Les lectures +// de /api/user, /api/default-credentials-check (socle.ts, « auth »), +// /api/users et /api/groups (referentiel.ts) sont déjà au banc. +import type { ReadCase, WriteScenario } from "../types"; +import { LOGGED_ACCOUNTS } from "../types"; +import { STUB_SMTP_PORT } from "../config"; + +// Le hash renvoyé par Node après POST / PUT /api/users (sel aléatoire) n'est pas +// renvoyé par Rust : écart voulu, retiré de la comparaison (réponses et table users) +const PASSWORD_HASH = { + fields: ["password"], + reason: "USERS-02 : l'empreinte du mot de passe n'est plus renvoyée après création ou modification d'un compte", +}; + +// Session d'une connexion : identifiant et échéance propres à chaque passage +// originalMaxAge : express-session le recalcule à la milliseconde près quand il +// réenregistre une session (86399999 ou 86400000 selon l'instant) +const SESSION_IGNORE = ["sid", "expire", "expires", "originalMaxAge"]; + +const BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; + +// Magasin dont le serveur SMTP est le service simulé du banc +const SMTP = { + smtpEnabled: true, + smtpHost: "127.0.0.1", + smtpPort: STUB_SMTP_PORT, + smtpSecure: false, + smtpUser: "toul@banc.test", + smtpSenderEmail: "toul@banc.test", + smtpSenderName: "Magasin Toul", +}; + +export const reads: ReadCase[] = [ + { module: "backups", path: "/api/backups" }, + { module: "backups", path: "/api/backups/backup_banc_1.sql/download" }, + { module: "backups", path: "/api/backups/backup_banc_500.sql/download", accounts: ["admin", "manager"] }, + { module: "backups", path: "/api/backups/absente.sql/download", accounts: ["admin", "manager"] }, + { module: "utilities", path: "/api/utilities" }, + { module: "admin", path: "/api/debug/log-schema" }, + { module: "admin", path: "/api/debug/download-schema" }, +]; + +export const writes: WriteScenario[] = [ + // ---------- Session ---------- + { + module: "session", + name: "connexion réussie : réponse, cookie et session enregistrée", + accounts: ["anonyme"], + steps: [ + { method: "POST", path: "/api/login", body: { username: "directeur", password: "banc-directeur" } }, + { method: "GET", path: "/api/user" }, + ], + tables: ["sessions"], + ignore: SESSION_IGNORE, + }, + { + module: "session", + name: "connexion par formulaire ou par l'adresse", + accounts: ["anonyme"], + steps: [ + { method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe" }, bodyType: "form" }, + { method: "POST", path: "/api/login?username=manager&password=banc-manager" }, + { method: "POST", path: "/api/login?username=sansmagasin", body: { password: "banc-sans-magasin" } }, + ], + tables: [], + }, + { + module: "session", + name: "connexion refusée : identifiants mal formés", + accounts: ["anonyme"], + steps: [ + { method: "POST", path: "/api/login", body: { username: "manager", password: 123 } }, + { method: "POST", path: "/api/login", body: { username: "manager", password: true } }, + { method: "POST", path: "/api/login", body: { username: "manager", password: ["banc-manager"] } }, + { method: "POST", path: "/api/login", body: { username: ["manager"], password: "banc-manager" } }, + { method: "POST", path: "/api/login", body: { username: "manager", password: "" } }, + { method: "POST", path: "/api/login", body: { username: "MANAGER", password: "banc-manager" } }, + { method: "POST", path: "/api/login", body: { username: 0, password: "x" } }, + { method: "POST", path: "/api/login", body: { username: 12, password: "x" } }, + { method: "POST", path: "/api/login", body: ["manager", "banc-manager"] }, + { method: "POST", path: "/api/login?username=manager&username=directeur&password=banc-manager" }, + { method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } }, + ], + tables: ["sessions"], + }, + { + module: "session", + name: "connexion par-dessus une session ouverte", + steps: [ + { method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } }, + // L'ancienne session est détruite : le cookie du compte ne vaut plus rien + { method: "GET", path: "/api/user" }, + ], + tables: ["sessions"], + ignore: SESSION_IGNORE, + }, + { + module: "session", + name: "déconnexion (GET et POST, selon Accept) puis lecture de l'utilisateur", + accounts: [...LOGGED_ACCOUNTS, "anonyme"], + steps: [ + { method: "GET", path: "/api/logout", headers: { accept: BROWSER_ACCEPT } }, + { method: "GET", path: "/api/user" }, + { method: "POST", path: "/api/logout", headers: { accept: "application/json" } }, + { method: "POST", path: "/api/logout" }, + ], + tables: ["sessions"], + }, + { + // Session ouverte par la connexion, lue par l'adresse de test SMTP + module: "session", + name: "session acceptée par le test SMTP d'un magasin", + steps: [{ method: "POST", path: "/api/groups/1/test-smtp" }], + tables: [], + }, + + // ---------- Utilisateurs ---------- + { + module: "users", + name: "créer, modifier, affecter, retirer et supprimer un utilisateur", + steps: [ + { + method: "POST", + path: "/api/users", + body: { id: "bench_nouveau", username: "nouveau", email: "nouveau@banc.test", firstName: "Noé", lastName: "Nouveau", password: "banc-nouveau", role: "employee" }, + }, + { method: "PUT", path: "/api/users/bench_nouveau", body: { firstName: "Noémie", lastName: "", email: "", role: "manager", password: "banc-nouveau-2" } }, + { method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 2 } }, + { method: "POST", path: "/api/users/bench_nouveau/groups", body: { groupId: 3 } }, + { method: "DELETE", path: "/api/users/bench_nouveau/groups/2" }, + { method: "GET", path: "/api/users" }, + { method: "PUT", path: "/api/users/bench_employee", body: { password: "banc-employe-2" } }, + { method: "DELETE", path: "/api/users/bench_nouveau" }, + { method: "GET", path: "/api/users" }, + // Le mot de passe modifié est vérifiable (en dernier : la connexion + // remplace la session du compte qui rejoue le scénario) + { method: "POST", path: "/api/login", body: { username: "employe", password: "banc-employe-2" } }, + ], + tables: ["users", "user_groups"], + deviation: PASSWORD_HASH, + }, + { + module: "users", + name: "créer un utilisateur sans identifiant ni adresse", + accounts: ["admin", "manager"], + steps: [ + { method: "POST", path: "/api/users", body: { username: "sansid", password: "banc-sans-id", email: "" } }, + { method: "POST", path: "/api/users", body: { username: "blanc", password: "x", email: " " } }, + { method: "POST", path: "/api/users", body: { id: "", username: "sansid2", password: "x", email: null, firstName: "", role: "directeur" } }, + // Le mot de passe du compte créé est vérifiable + { method: "POST", path: "/api/login", body: { username: "sansid", password: "banc-sans-id" } }, + ], + tables: ["users"], + // Identifiant généré : manual__ + ignore: ["id"], + deviation: PASSWORD_HASH, + }, + { + module: "users", + name: "utilisateurs invalides, en double ou introuvables", + steps: [ + { method: "POST", path: "/api/users", body: {} }, + { method: "POST", path: "/api/users", body: { username: "", password: "", email: 5, role: "chef" } }, + { method: "POST", path: "/api/users", body: { username: "a", password: "b", email: "pas-un-courriel", firstName: null } }, + { method: "POST", path: "/api/users", body: { username: "manager", password: "x" } }, + { method: "POST", path: "/api/users", body: { username: "autre", password: "x", email: "manager@banc.test" } }, + { method: "POST", path: "/api/users", body: { id: "bench_manager", username: "autre2", password: "x" } }, + { method: "POST", path: "/api/users", body: ["x"] }, + { method: "PUT", path: "/api/users/bench_employee", body: { role: "chef", firstName: 5 } }, + { method: "PUT", path: "/api/users/bench_employee", body: { username: "manager" } }, + { method: "PUT", path: "/api/users/bench_employee", body: { email: "directeur@banc.test" } }, + { method: "PUT", path: "/api/users/inconnu", body: { firstName: "X" } }, + // Noms effacés ; adresse vide ignorée (non effacée, comme Node) + { method: "PUT", path: "/api/users/bench_employee", body: { firstName: null, lastName: "", email: "" } }, + { method: "DELETE", path: "/api/users/admin_local" }, + { method: "DELETE", path: "/api/users/bench_employee" }, + { method: "POST", path: "/api/users/bench_manager/groups", body: { groupId: "2" } }, + { method: "POST", path: "/api/users/bench_manager/groups", body: {} }, + { method: "DELETE", path: "/api/users/bench_manager/groups/abc" }, + { method: "DELETE", path: "/api/users/bench_manager/groups/1" }, + // Mot de passe vide enregistré tel quel (défaut de Node) : connexion + // impossible (en dernier : une connexion réussie remplacerait la session) + { method: "PUT", path: "/api/users/bench_sans_magasin", body: { password: "" } }, + { method: "POST", path: "/api/login", body: { username: "sansmagasin", password: "banc-sans-magasin" } }, + ], + tables: ["users", "user_groups"], + deviation: PASSWORD_HASH, + }, + + // ---------- Magasins ---------- + { + module: "groups", + name: "créer, modifier, tester et supprimer un magasin", + steps: [ + { + method: "POST", + path: "/api/groups", + body: { name: "Magasin Toul", color: "#7B1FA2", address: "", phone: "", logo: "", nocodbConfigId: null, ...SMTP, smtpPassword: "secret-toul" }, + }, + // Node relit le mot de passe chiffré par l'autre serveur + { method: "POST", path: "/api/groups/{{0.id}}/test-smtp" }, + { method: "PUT", path: "/api/groups/{{0.id}}", body: { name: "Magasin de Toul", phone: "03 83 00 00 05", smtpPassword: "" } }, + { method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpPassword: "nouveau-secret", smtpPort: String(STUB_SMTP_PORT) } }, + { method: "POST", path: "/api/groups/{{0.id}}/test-smtp" }, + { method: "PUT", path: "/api/groups/{{0.id}}", body: { smtpSenderEmail: "", smtpPort: null } }, + { method: "POST", path: "/api/groups/{{0.id}}/test-smtp" }, + { method: "GET", path: "/api/groups" }, + { method: "DELETE", path: "/api/groups/{{0.id}}" }, + ], + tables: ["groups"], + // Chiffré avec un vecteur aléatoire + ignore: ["smtp_password"], + }, + { + module: "groups", + name: "tester le SMTP : configuration incomplète, serveur injoignable, magasin introuvable", + steps: [ + { method: "POST", path: "/api/groups/2/test-smtp" }, + { method: "POST", path: "/api/groups/999/test-smtp" }, + { method: "POST", path: "/api/groups/abc/test-smtp" }, + // Port fermé : connexion refusée (modification réservée à l'admin et au manager) + { method: "PUT", path: "/api/groups/2", body: { smtpHost: "127.0.0.1", smtpPort: 1, smtpSenderEmail: "metz@banc.test" } }, + { method: "POST", path: "/api/groups/2/test-smtp" }, + ], + tables: [], + }, + { + module: "groups", + name: "magasins invalides, introuvables ou encore utilisés", + steps: [ + { method: "POST", path: "/api/groups", body: {} }, + { method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: 5 } }, + { method: "POST", path: "/api/groups", body: { name: "X", smtpSenderEmail: "abc", smtpPort: "0" } }, + { method: "POST", path: "/api/groups", body: { name: "X", smtpPort: "abc", phone: 12 } }, + { method: "PUT", path: "/api/groups/1", body: { name: 5 } }, + { method: "PUT", path: "/api/groups/999", body: { name: "Fantôme" } }, + { method: "PUT", path: "/api/groups/abc", body: { name: "X" } }, + // Un manager modifie un magasin qui n'est pas le sien (comme Node) + { method: "PUT", path: "/api/groups/2", body: { color: "#000000", smtpPassword: null } }, + { method: "DELETE", path: "/api/groups/1" }, + { method: "DELETE", path: "/api/groups/999" }, + { method: "DELETE", path: "/api/groups/abc" }, + ], + tables: ["groups"], + ignore: ["smtp_password"], + }, + + // ---------- Sauvegardes ---------- + { + module: "backups", + name: "créer une sauvegarde (pg_dump), ménage des anciennes", + steps: [ + { method: "POST", path: "/api/backups" }, + { method: "GET", path: "/api/backups" }, + { method: "DELETE", path: "/api/backups/{{0.filename}}" }, + { method: "GET", path: "/api/backups" }, + ], + // Les deux plus anciennes (sans fichier) ont été supprimées par le ménage + tables: ["database_backups"], + // Identifiant aléatoire, nom horodaté, taille du fichier pg_dump + ignore: ["id", "filename", "size"], + }, + { + module: "backups", + name: "supprimer une sauvegarde et son fichier", + steps: [ + { method: "DELETE", path: "/api/backups/backup_banc_500.sql" }, + { method: "GET", path: "/api/backups/backup_banc_500.sql/download" }, + { method: "DELETE", path: "/api/backups/backup_banc_500.sql" }, + { method: "DELETE", path: "/api/backups/absente.sql" }, + { method: "GET", path: "/api/backups" }, + ], + tables: ["database_backups"], + }, + + // ---------- Paramètres généraux ---------- + { + module: "utilities", + name: "modifier les paramètres généraux", + steps: [ + { method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "https://analyse.banc.test/nouveau" } }, + { method: "POST", path: "/api/utilities", body: { automaticBackupsEnabled: true, autre: 1 } }, + { method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: null } }, + { method: "GET", path: "/api/utilities" }, + ], + tables: ["utilities"], + }, + { + module: "utilities", + name: "paramètres généraux invalides", + steps: [ + { method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: 5 } }, + { method: "POST", path: "/api/utilities", body: { salesAnalysisUrl: "x".repeat(501), automaticBackupsEnabled: "oui" } }, + { method: "POST", path: "/api/utilities", body: [] }, + ], + tables: ["utilities"], + }, +]; diff --git a/scripts/bench/cases/fournisseurs-commandes.ts b/scripts/bench/cases/fournisseurs-commandes.ts new file mode 100644 index 0000000..80db5b7 --- /dev/null +++ b/scripts/bench/cases/fournisseurs-commandes.ts @@ -0,0 +1,365 @@ +// Lot 4, commandes et livraisons : commandes fournisseurs, livraisons (validation, +// contrôle, rapprochement automatique, échéance relue dans NocoDB), API externe +// de rapprochement (/api/ext/v1, clé d'API) et relais ffnancy (sans réseau : seul +// le refus sans session est rejoué). NocoDB est simulé par stub.ts. +import { BENCH_API_KEY } from "../config"; +import type { AccountKey, ReadCase, WriteScenario } from "../types"; + +// Clé d'API (en-tête X-API-Key), et deux sessions qui n'y donnent pas accès +const EXT_ACCOUNTS: AccountKey[] = ["api", "anonyme", "admin"]; + +// Durée de vie des entrées du cache des factures : relative à l'heure du passage +const CACHE_IGNORE = ["expires_at"]; + +const ext = (path: string): ReadCase => ({ module: "external-api-v1", path: `/api/ext/v1${path}`, accounts: EXT_ACCOUNTS }); + +export const reads: ReadCase[] = [ + // ---------- Commandes (Orders.tsx, Dashboard.tsx, Calendar.tsx, modales, mobile) ---------- + { module: "orders", path: "/api/orders" }, + { module: "orders", path: "/api/orders?storeId=1" }, + { module: "orders", path: "/api/orders?storeId=2" }, + { module: "orders", path: "/api/orders?storeId=3" }, + { module: "orders", path: "/api/orders?storeId=abc" }, + { module: "orders", path: "/api/orders?" }, + { module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}" }, + { module: "orders", path: "/api/orders?startDate={{today-30}}&endDate={{today+30}}&storeId=1" }, + { module: "orders", path: "/api/orders?storeId=2&startDate={{today}}&endDate={{today}}" }, + { module: "orders", path: "/api/orders?startDate={{today-400}}&endDate={{today+400}}&storeId=2" }, + { module: "orders", path: "/api/orders?startDate={{today}}" }, + { module: "orders", path: "/api/orders?startDate=abc&endDate=xyz" }, + { module: "orders", path: "/api/orders/1" }, + { module: "orders", path: "/api/orders/2" }, + { module: "orders", path: "/api/orders/4" }, + { module: "orders", path: "/api/orders/6" }, + { module: "orders", path: "/api/orders/600" }, + { module: "orders", path: "/api/orders/999" }, + { module: "orders", path: "/api/orders/abc" }, + + // ---------- Livraisons (Deliveries.tsx, BLReconciliation.tsx, Calendar.tsx, mobile) ---------- + { module: "deliveries", path: "/api/deliveries" }, + { module: "deliveries", path: "/api/deliveries?storeId=1" }, + { module: "deliveries", path: "/api/deliveries?storeId=2" }, + { module: "deliveries", path: "/api/deliveries?storeId=3" }, + { module: "deliveries", path: "/api/deliveries?storeId=abc" }, + { module: "deliveries", path: "/api/deliveries?" }, + { module: "deliveries", path: "/api/deliveries?status=delivered" }, + { module: "deliveries", path: "/api/deliveries?status=delivered&storeId=2" }, + { module: "deliveries", path: "/api/deliveries?status=inconnu" }, + { module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}" }, + { module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&storeId=1" }, + { module: "deliveries", path: "/api/deliveries?storeId=2&startDate={{today}}&endDate={{today}}" }, + { module: "deliveries", path: "/api/deliveries?startDate={{today-30}}&endDate={{today+30}}&status=planned" }, + { module: "deliveries", path: "/api/deliveries?startDate=abc&endDate=xyz" }, + { module: "deliveries", path: "/api/deliveries?withBL=true" }, + { module: "deliveries", path: "/api/deliveries?withBL=true&storeId=1" }, + { module: "deliveries", path: "/api/deliveries/1" }, + { module: "deliveries", path: "/api/deliveries/3" }, + { module: "deliveries", path: "/api/deliveries/5" }, + { module: "deliveries", path: "/api/deliveries/6" }, + { module: "deliveries", path: "/api/deliveries/7" }, + { module: "deliveries", path: "/api/deliveries/600" }, + { module: "deliveries", path: "/api/deliveries/601" }, + { module: "deliveries", path: "/api/deliveries/999" }, + { module: "deliveries", path: "/api/deliveries/abc" }, + + // ---------- API externe (docs/API-RAPPROCHEMENT.md) ---------- + ext("/stores"), + ext("/suppliers"), + ext("/deliveries"), + ext("/deliveries?storeId=1"), + ext("/deliveries?storeId=2&reconciled=true"), + ext("/deliveries?supplierId=2&hasInvoice=false"), + ext("/deliveries?blNumber=%20bl-1002%20"), + ext("/deliveries?blNumber=BL-1002&blNumber=BL-1004"), + ext("/deliveries?hasBl=true&from={{today-30}}&to={{today-2}}"), + ext("/deliveries?to={{today-6}}&reconciled=false"), + ext("/deliveries?limit=2&offset=1"), + ext("/deliveries?limit=1e3&offset=0x2"), + ext("/deliveries?offset=50&limit="), + ext("/deliveries?storeId=0&supplierId=1.5&reconciled=oui&hasBl=1&hasInvoice=&from=2026-1-1&to=x&limit=0&offset=-1"), + ext("/deliveries?storeId=1&storeId=2"), + ext("/deliveries?storeId=99999999999"), + ext("/deliveries/1"), + ext("/deliveries/2"), + ext("/deliveries/3"), + ext("/deliveries/602"), + ext("/deliveries/999"), + ext("/deliveries/abc"), + ext("/deliveries/0"), + ext("/deliveries/1.5"), + ext("/deliveries/99999999999"), + ext(""), + ext("/"), + ext("/inconnue"), + ext("/stores/extra"), + // Variantes qu'Express accepte (casse, barre finale) : relayées à Node + ext("/stores/"), + ext("/SUPPLIERS"), + + // ---------- Relais ffnancy : sans session seulement (les autres appels partent sur Internet) ---------- + { module: "ffnancy", path: "/api/ffnancy/articles?ean=3245678901234&limit=1", accounts: ["anonyme"] }, + { module: "ffnancy", path: "/api/ffnancy/mouvements/entrees?artNoId=1&limit=1&dateDebut=2000-01-01", accounts: ["anonyme"] }, +]; + +const NEW_DELIVERY = { supplierId: 1, groupId: 1, scheduledDate: "{{today+4}}", quantity: 1, unit: "colis" }; + +export const writes: WriteScenario[] = [ + // ---------- Commandes ---------- + { + module: "orders", + name: "commandes : créer, modifier, supprimer", + steps: [ + { method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today+7}}", notes: "Commande du banc" } }, + { method: "PUT", path: "/api/orders/{{0.id}}", body: { plannedDate: "{{today+8}}", notes: "Modifiée", quantity: 3, unit: "palettes" } }, + { method: "GET", path: "/api/orders/{{0.id}}" }, + { method: "DELETE", path: "/api/orders/{{0.id}}" }, + { method: "POST", path: "/api/orders", body: { supplierId: 2, groupId: 2, plannedDate: "{{today+1}}" } }, + { method: "PUT", path: "/api/orders/4", body: { status: "planned" } }, + { method: "PUT", path: "/api/orders/6", body: { notes: "Épinal" } }, + { method: "DELETE", path: "/api/orders/6" }, + { method: "DELETE", path: "/api/orders/3" }, + // Commande qui a des livraisons : clé étrangère + { method: "DELETE", path: "/api/orders/2" }, + { method: "GET", path: "/api/orders?storeId=1" }, + ], + tables: ["orders"], + }, + { + module: "orders", + name: "commandes : corps invalides et identifiants inconnus", + steps: [ + { method: "POST", path: "/api/orders", body: {} }, + { method: "POST", path: "/api/orders", body: { supplierId: "1", groupId: 1.5, plannedDate: 5, quantity: "2" } }, + { method: "POST", path: "/api/orders", body: [] }, + { method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "pas une date" } }, + { method: "POST", path: "/api/orders", body: { supplierId: 999, groupId: 1, plannedDate: "{{today}}" } }, + { method: "POST", path: "/api/orders", body: { supplierId: 1, groupId: 1, plannedDate: "{{today}}", createdBy: "admin_local", status: "delivered", id: 999, createdAt: "{{today}}" } }, + { method: "PUT", path: "/api/orders/1", body: { groupId: "x" } }, + { method: "PUT", path: "/api/orders/1", body: [] }, + { method: "PUT", path: "/api/orders/1", body: {} }, + // Commande déplacée dans un autre magasin, créateur changé + { method: "PUT", path: "/api/orders/3", body: { groupId: 3, createdBy: "bench_employee" } }, + { method: "PUT", path: "/api/orders/999", body: {} }, + { method: "PUT", path: "/api/orders/abc", body: {} }, + { method: "DELETE", path: "/api/orders/999" }, + { method: "DELETE", path: "/api/orders/abc" }, + { method: "GET", path: "/api/orders/3" }, + ], + tables: ["orders"], + }, + { + module: "orders", + name: "synchroniser les statuts commandes / livraisons", + steps: [ + { method: "POST", path: "/api/sync-order-delivery-status" }, + { method: "POST", path: "/api/sync-order-delivery-status" }, + { method: "GET", path: "/api/orders/600" }, + ], + tables: ["orders"], + }, + + // ---------- Livraisons ---------- + { + module: "deliveries", + name: "livraisons : créer, modifier, supprimer", + steps: [ + // CreateDeliveryModal : commande 3 en attente, passée à « planned » + { method: "POST", path: "/api/deliveries", body: { orderId: "3", supplierId: 3, groupId: 1, scheduledDate: "{{today+3}}", quantity: 2, unit: "palettes", status: "planned", notes: "Livraison du banc", createdBy: "admin_local" } }, + // EditDeliveryModal + { method: "PUT", path: "/api/deliveries/{{0.id}}", body: { supplierId: 3, groupId: 1, orderId: null, scheduledDate: "{{today+5}}", quantity: 4, unit: "palettes", notes: "Modifiée" } }, + { method: "GET", path: "/api/deliveries/{{0.id}}" }, + { method: "DELETE", path: "/api/deliveries/{{0.id}}" }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "none", groupId: 2 } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 4 } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 999 } }, + { method: "PUT", path: "/api/deliveries/5", body: { orderId: 1 } }, + { method: "PUT", path: "/api/deliveries/5", body: { orderId: 999 } }, + { method: "PUT", path: "/api/deliveries/5", body: { notes: "Metz" } }, + { method: "PUT", path: "/api/deliveries/601", body: { orderId: 3 } }, + { method: "DELETE", path: "/api/deliveries/7" }, + // Commentaires supprimés avec la livraison (cascade) + { method: "DELETE", path: "/api/deliveries/2" }, + { method: "GET", path: "/api/deliveries?storeId=1" }, + ], + tables: ["deliveries", "orders", "reconciliation_comments"], + }, + { + module: "deliveries", + name: "livraisons : corps invalides et identifiants inconnus", + steps: [ + { method: "POST", path: "/api/deliveries", body: {} }, + { method: "POST", path: "/api/deliveries", body: [] }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "abc" } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "12abc" } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: 0 } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, orderId: "" } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, deliveredDate: "{{today}}" } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, status: "", blAmount: "12.5", blNumber: "BL-9000" } }, + { method: "POST", path: "/api/deliveries", body: { ...NEW_DELIVERY, groupId: 3, orderId: 6 } }, + { method: "PUT", path: "/api/deliveries/1", body: { invoiceAmountTTC: 5 } }, + { method: "PUT", path: "/api/deliveries/1", body: { dueDate: "pas une date" } }, + { method: "PUT", path: "/api/deliveries/4", body: { deliveredDate: "{{today}}" } }, + { method: "PUT", path: "/api/deliveries/4", body: { validatedAt: "", blAmount: 99.5, invoiceAmount: true, notes: null } }, + { method: "PUT", path: "/api/deliveries/4", body: { blAmount: 99.5, notes: null, blNumber: "" } }, + { method: "PUT", path: "/api/deliveries/4", body: [] }, + { method: "PUT", path: "/api/deliveries/999", body: {} }, + { method: "PUT", path: "/api/deliveries/abc", body: {} }, + { method: "DELETE", path: "/api/deliveries/999" }, + { method: "DELETE", path: "/api/deliveries/abc" }, + { method: "GET", path: "/api/deliveries/4" }, + ], + tables: ["deliveries", "orders"], + }, + { + module: "deliveries", + name: "livraisons : référence de facture et échéance (NocoDB)", + steps: [ + { method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002", invoiceAmount: "310.00" } }, + { method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-2002" } }, + { method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "FAC-9999" } }, + { method: "PUT", path: "/api/deliveries/2", body: { invoiceReference: "", dueDate: "{{today+60}}" } }, + { method: "PUT", path: "/api/deliveries/4", body: { invoiceReference: "AV-3001" } }, + { method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2006" } }, + { method: "PUT", path: "/api/deliveries/6", body: { invoiceReference: "FAC-2001", dueDate: null } }, + // Validation rapide et dévalidation (BLReconciliation.tsx) + { method: "PUT", path: "/api/deliveries/4", body: { reconciled: true, validatedAt: "{{today}}T08:00:00.000Z" } }, + { method: "PUT", path: "/api/deliveries/1", body: { reconciled: false, validatedAt: null } }, + { method: "GET", path: "/api/deliveries/2" }, + ], + tables: ["deliveries", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "deliveries", + name: "livraisons : rapprochement automatique à la modification", + steps: [ + // Bonduelle : livrée avec un BL, rapprochée d'office (la réponse reste la première mise à jour) + { method: "PUT", path: "/api/deliveries/601", body: { status: "delivered", blNumber: "BL-6010" } }, + { method: "PUT", path: "/api/deliveries/3", body: { blNumber: "BL-3000" } }, + // Fournisseur changé : relu dans la table + { method: "PUT", path: "/api/deliveries/3", body: { supplierId: 1, status: "delivered" } }, + { method: "PUT", path: "/api/deliveries/600", body: { supplierId: 2, blNumber: "BL-6000" } }, + { method: "GET", path: "/api/deliveries/601" }, + ], + tables: ["deliveries"], + }, + { + module: "deliveries", + name: "livraisons : valider", + steps: [ + // Bonduelle, commande 2 : livraison rapprochée d'office, commande livrée + { method: "POST", path: "/api/deliveries/3/validate", body: { blNumber: " BL-3001 ", blAmount: 150.5 } }, + { method: "POST", path: "/api/deliveries/5/validate", body: { blNumber: "BL-5001" } }, + { method: "POST", path: "/api/deliveries/7/validate", body: { blNumber: "BL-7001", blAmount: "" } }, + // Cache : référence et BL de la livraison marqués « rapprochés » + { method: "POST", path: "/api/deliveries/4/validate", body: { blNumber: "BL-1004" } }, + { method: "POST", path: "/api/deliveries/8/validate", body: { blNumber: "BL-0900", blAmount: null } }, + { method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: " " } }, + { method: "POST", path: "/api/deliveries/2/validate", body: {} }, + { method: "POST", path: "/api/deliveries/2/validate", body: [] }, + { method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: 5 } }, + { method: "POST", path: "/api/deliveries/2/validate", body: { blNumber: "BL-1002", blAmount: "abc" } }, + { method: "POST", path: "/api/deliveries/999/validate", body: { blNumber: "x" } }, + { method: "POST", path: "/api/deliveries/abc/validate", body: { blNumber: "x" } }, + { method: "GET", path: "/api/deliveries/3" }, + ], + tables: ["deliveries", "orders", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "deliveries", + name: "livraisons : contrôle effectué", + steps: [ + { method: "PUT", path: "/api/deliveries/4/control" }, + { method: "PUT", path: "/api/deliveries/6/control" }, + { method: "PUT", path: "/api/deliveries/999/control" }, + { method: "PUT", path: "/api/deliveries/abc/control" }, + { method: "PUT", path: "/api/deliveries/99999999999/control" }, + { method: "GET", path: "/api/deliveries?storeId=2" }, + ], + tables: ["deliveries"], + }, + + // ---------- API externe ---------- + { + module: "external-api-v1", + name: "API externe : écrire la facture", + accounts: ["api"], + steps: [ + // Bonduelle (automatique), non rapprochée : échéance reprise de NocoDB + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: " FAC-2002 ", invoiceAmount: "1 234,56", invoiceAmountTTC: 1481.47 } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "FAC-2002" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { dueDate: "{{today+45}}", reconciled: true } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "10" } }, + // Lactalis rapprochée : figée, sauf dévalidation dans la même requête + { method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5 } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceAmount: 5, reconciled: false } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { invoiceReference: "" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/1", body: { reconciled: true } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/602", body: { invoiceReference: "FAC-2006", invoiceAmountTTC: null } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/3", body: { invoiceReference: "X" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { invoiceReference: "FAC-9999" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-02-30" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/4", body: { dueDate: "2026-13-45" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/999", body: { reconciled: true } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: { reconciled: true } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/99999999999", body: { reconciled: true } }, + { method: "GET", path: "/api/ext/v1/deliveries?storeId=1&hasInvoice=true" }, + ], + tables: ["deliveries", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "external-api-v1", + name: "API externe : corps invalides et routes inconnues", + accounts: ["api"], + steps: [ + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: {} }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: [] }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { foo: 1, 2: 3 } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: "abc", dueDate: "15/10/2026", reconciled: "oui", extra: 1 } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceAmount: true, invoiceAmountTTC: "1e9" } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: "x".repeat(101) } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { invoiceReference: 5, reconciled: null } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/abc", body: {} }, + { method: "POST", path: "/api/ext/v1/deliveries", body: {} }, + { method: "PUT", path: "/api/ext/v1/deliveries/2", body: {} }, + { method: "DELETE", path: "/api/ext/v1/stores" }, + { method: "POST", path: "/api/ext/v1/inconnue", body: { a: 1 } }, + { method: "GET", path: "/api/ext/v1/deliveries/2" }, + ], + tables: ["deliveries"], + }, + { + module: "external-api-v1", + name: "API externe : clés refusées ou passées autrement", + accounts: ["api"], + steps: [ + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "lf_cle_revoquee" } }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": "mauvaise" } }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Bearer ${BENCH_API_KEY}` } }, + { method: "GET", path: "/api/ext/v1/suppliers", headers: { "x-api-key": "", authorization: `bearer ${BENCH_API_KEY} ` } }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: "Bearer " } }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": " ", authorization: `Basic ${BENCH_API_KEY}` } }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": "mauvaise" } }, + { method: "POST", path: "/api/ext/v1/inconnue", headers: { "x-api-key": "mauvaise" } }, + ], + tables: ["deliveries"], + }, + { + module: "external-api-v1", + name: "API externe : désactivée sans clé active", + accounts: ["admin"], + steps: [ + // La session ne donne pas accès à l'API externe + { method: "GET", path: "/api/ext/v1/stores" }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } }, + { method: "DELETE", path: "/api/external-api/keys/1" }, + { method: "GET", path: "/api/ext/v1/stores" }, + { method: "GET", path: "/api/ext/v1/stores", headers: { "x-api-key": BENCH_API_KEY } }, + { method: "GET", path: "/api/ext/v1/inconnue" }, + { method: "PATCH", path: "/api/ext/v1/deliveries/2", body: { reconciled: true }, headers: { "x-api-key": BENCH_API_KEY } }, + ], + tables: ["external_api_keys"], + }, +]; diff --git a/scripts/bench/cases/fournisseurs-rapprochement.ts b/scripts/bench/cases/fournisseurs-rapprochement.ts new file mode 100644 index 0000000..4a0f91a --- /dev/null +++ b/scripts/bench/cases/fournisseurs-rapprochement.ts @@ -0,0 +1,410 @@ +// Lot 4, rapprochement : avoirs (et leur webhook), échéancier des paiements (lecture +// complétée par NocoDB, export CSV), envoi d'une facture au webhook du magasin, +// SQL et migration d'urgence de l'administration. NocoDB et les webhooks sont +// simulés par stub.ts. Données complémentaires : seeds/lot4-rapprochement.ts. +import { STUB_URL } from "../config"; +import type { ReadCase, WriteScenario } from "../types"; + +// Mois « AAAA-MM » relatif au mois courant (sélecteur de l'échéancier) +function month(offset: number): string { + const date = new Date(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + offset); + return date.toISOString().slice(0, 7); +} + +// Durée de vie des entrées du cache : relative à l'heure du passage, différente +// entre les deux serveurs +const CACHE_IGNORE = ["expires_at"]; + +const ALL_METHODS = ["virement", "prelevement", "cheque", "Non défini"]; + +// Fichier envoyé par BLReconciliation.tsx avec les champs du formulaire +const INVOICE_FILE = [{ field: "file", filename: "facture BL-1001.pdf", contentType: "application/pdf", size: 2048 }]; + +export const reads: ReadCase[] = [ + // ---------- Avoirs (Avoirs.tsx, mobile/AvoirsPage.tsx : storeId du magasin choisi) ---------- + { module: "avoirs", path: "/api/avoirs" }, + { module: "avoirs", path: "/api/avoirs?storeId=1" }, + { module: "avoirs", path: "/api/avoirs?storeId=2" }, + { module: "avoirs", path: "/api/avoirs?storeId=3" }, + { module: "avoirs", path: "/api/avoirs?storeId=" }, + { module: "avoirs", path: "/api/avoirs?storeId=abc" }, + { module: "avoirs", path: "/api/avoirs?storeId=99999999999" }, + { module: "avoirs", path: "/api/avoirs/1" }, + { module: "avoirs", path: "/api/avoirs/3" }, + { module: "avoirs", path: "/api/avoirs/701" }, + { module: "avoirs", path: "/api/avoirs/999" }, + { module: "avoirs", path: "/api/avoirs/abc" }, + + // ---------- Échéancier (PaymentSchedulePage.tsx : groupId du magasin choisi) ---------- + // La lecture complète en base les échéances et montants manquants (NocoDB) : + // les comptes suivants voient les livraisons déjà complétées + { module: "payment-schedule", path: "/api/payment-schedule?groupId=1" }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=2" }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=3" }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=999" }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=abc", accounts: ["admin", "directeur", "manager"] }, + { module: "payment-schedule", path: "/api/payment-schedule", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=0", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=1.5", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=-1", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=%201%20", accounts: ["admin", "directeur"] }, + { module: "payment-schedule", path: "/api/payment-schedule?groupId=99999999999", accounts: ["admin", "directeur"] }, +]; + +export const writes: WriteScenario[] = [ + // ---------- Avoirs ---------- + { + module: "avoirs", + name: "créer un avoir (webhook du magasin à la création)", + steps: [ + // Formulaire du bureau + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4001", amount: 12.5, comment: "Casse", commercialProcessed: false, status: "En attente de demande" } }, + { method: "GET", path: "/api/avoirs/{{0.id}}" }, + // Formulaire mobile : montant forcé à 0, sans référence ni commentaire + { method: "POST", path: "/api/avoirs", body: { supplierId: 2, groupId: 1, amount: 0, status: "Demandé", createdBy: "pirate" } }, + // Metz : sans webhook + { method: "POST", path: "/api/avoirs", body: { supplierId: 3, groupId: 2, invoiceReference: "", amount: "-7.25", comment: "", commercialProcessed: true, status: "Reçu" } }, + // Épinal : aucun compte du banc n'y est affecté (sauf l'administrateur) + { method: "POST", path: "/api/avoirs", body: { supplierId: 4, groupId: 3, amount: null } }, + { method: "GET", path: "/api/avoirs" }, + ], + tables: ["avoirs"], + }, + { + // Fiche du magasin modifiable par l'admin et le manager + module: "avoirs", + name: "webhook du magasin en erreur, injoignable ou invalide", + accounts: ["admin", "manager"], + steps: [ + { method: "PUT", path: "/api/groups/1", body: { webhookUrl: `${STUB_URL}/inconnu` } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4101", amount: 1 } }, + { method: "PUT", path: "/api/groups/1", body: { webhookUrl: "http://127.0.0.1:1/webhook" } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4102", amount: 2 } }, + { method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } }, + { method: "PUT", path: "/api/groups/1", body: { webhookUrl: "pas une adresse" } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-4103", amount: 3 } }, + ], + tables: ["avoirs"], + }, + { + module: "avoirs", + name: "avoirs invalides à la création", + steps: [ + { method: "POST", path: "/api/avoirs", body: {} }, + { method: "POST", path: "/api/avoirs", body: { supplierId: "1", groupId: 1.5 } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: "abc", status: "Perdu", commercialProcessed: "oui" } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: null, comment: null, commercialProcessed: null } }, + { method: "POST", path: "/api/avoirs", body: ["x"] }, + // Erreurs de la base : fournisseur ou magasin inconnu, référence trop longue, montant trop grand + { method: "POST", path: "/api/avoirs", body: { supplierId: 999, groupId: 1 } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 999 } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, invoiceReference: "R".repeat(300) } }, + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 1, amount: 1e9 } }, + // Montant vide : Number("") = 0 + { method: "POST", path: "/api/avoirs", body: { supplierId: 1, groupId: 2, amount: "" } }, + ], + tables: ["avoirs"], + }, + { + module: "avoirs", + name: "modifier un avoir (passage à « Reçu » et webhook)", + steps: [ + // Formulaire complet : passage à « Reçu », webhook du magasin + { method: "PUT", path: "/api/avoirs/1", body: { supplierId: 1, groupId: 1, invoiceReference: "AV-3001", amount: 50, comment: "Reçu par courrier", commercialProcessed: true, status: "Reçu" } }, + // Déjà « Reçu » : pas de webhook ; montant, référence et commentaire effacés (AVOIRS-API-06) + { method: "PUT", path: "/api/avoirs/1", body: { status: "Reçu" } }, + // Metz (sans webhook) + { method: "PUT", path: "/api/avoirs/3", body: { status: "Demandé", amount: "15.5", invoiceReference: "AV-3003", comment: "" } }, + { method: "PUT", path: "/api/avoirs/700", body: { status: "Reçu", invoiceReference: "FAC-2006", amount: 10.5 } }, + // Changement de magasin et de créateur acceptés (AVOIRS-API-07) + { method: "PUT", path: "/api/avoirs/2", body: { groupId: 2, createdBy: "pirate", invoiceReference: "AV-3002" } }, + // Épinal + { method: "PUT", path: "/api/avoirs/701", body: { status: "Reçu" } }, + { method: "GET", path: "/api/avoirs/1" }, + ], + tables: ["avoirs"], + }, + { + module: "avoirs", + name: "avoirs invalides ou introuvables à la modification", + steps: [ + { method: "PUT", path: "/api/avoirs/1", body: { status: "Perdu" } }, + { method: "PUT", path: "/api/avoirs/1", body: { amount: "abc", supplierId: "2" } }, + { method: "PUT", path: "/api/avoirs/1", body: { invoiceReference: null } }, + { method: "PUT", path: "/api/avoirs/1", body: { supplierId: 999 } }, + { method: "PUT", path: "/api/avoirs/999", body: { status: "Reçu" } }, + { method: "PUT", path: "/api/avoirs/abc", body: { status: "Reçu" } }, + { method: "PUT", path: "/api/avoirs/1", body: [] }, + ], + tables: ["avoirs"], + }, + { + module: "avoirs", + name: "supprimer des avoirs", + steps: [ + { method: "DELETE", path: "/api/avoirs/2" }, + { method: "DELETE", path: "/api/avoirs/700" }, + { method: "DELETE", path: "/api/avoirs/701" }, + { method: "DELETE", path: "/api/avoirs/999" }, + { method: "DELETE", path: "/api/avoirs/abc" }, + { method: "GET", path: "/api/avoirs" }, + ], + tables: ["avoirs"], + }, + { + module: "avoirs", + name: "vérifier la facture d'un avoir dans NocoDB", + steps: [ + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: false } }, + // Depuis le cache, puis en forçant + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } }, + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " av-3001 ", forceRefresh: "oui" } }, + { method: "POST", path: "/api/avoirs/2/verify-invoice", body: { invoiceReference: "AV-INCONNU" } }, + { method: "POST", path: "/api/avoirs/700/verify-invoice", body: { invoiceReference: "FAC-2006" } }, + // Épinal : sans configuration NocoDB + { method: "POST", path: "/api/avoirs/701/verify-invoice", body: { invoiceReference: "FAC-2004" } }, + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: {} }, + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: " " } }, + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: 2001 } }, + { method: "POST", path: "/api/avoirs/999/verify-invoice", body: { invoiceReference: "AV-3001" } }, + { method: "POST", path: "/api/avoirs/abc/verify-invoice", body: { invoiceReference: "AV-3001" } }, + ], + tables: ["invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "avoirs", + name: "valider un avoir et l'état de son webhook", + steps: [ + // Entrée du cache à marquer « rapprochée » à la validation + { method: "POST", path: "/api/avoirs/1/verify-invoice", body: { invoiceReference: "AV-3001" } }, + { method: "PUT", path: "/api/avoirs/1/nocodb-verification", body: { verified: true } }, + { method: "PUT", path: "/api/avoirs/2/nocodb-verification", body: { verified: false } }, + // Référence absente ou blanche : rien à marquer ; autre magasin accepté (AVOIRS-API-08) + { method: "PUT", path: "/api/avoirs/3/nocodb-verification", body: { verified: true } }, + { method: "PUT", path: "/api/avoirs/702/nocodb-verification", body: { verified: "yes" } }, + { method: "PUT", path: "/api/avoirs/700/nocodb-verification", body: {} }, + { method: "PUT", path: "/api/avoirs/701/nocodb-verification", body: { verified: "oui" } }, + { method: "PUT", path: "/api/avoirs/999/nocodb-verification", body: { verified: true } }, + { method: "PUT", path: "/api/avoirs/abc/nocodb-verification", body: { verified: true } }, + { method: "PUT", path: "/api/avoirs/1/webhook-status", body: { webhookSent: false } }, + { method: "PUT", path: "/api/avoirs/2/webhook-status", body: { webhookSent: 1 } }, + { method: "PUT", path: "/api/avoirs/700/webhook-status", body: {} }, + { method: "PUT", path: "/api/avoirs/701/webhook-status", body: { webhookSent: null } }, + { method: "PUT", path: "/api/avoirs/3/webhook-status", body: { webhookSent: "oui" } }, + { method: "PUT", path: "/api/avoirs/999/webhook-status", body: { webhookSent: true } }, + { method: "PUT", path: "/api/avoirs/abc/webhook-status", body: { webhookSent: true } }, + ], + tables: ["avoirs", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + + // ---------- Échéancier ---------- + { + module: "payment-schedule", + name: "échéancier : échéances et montants complétés par NocoDB", + steps: [ + { method: "GET", path: "/api/payment-schedule?groupId=1" }, + // Seconde lecture : le repli « TTC » recommence (montant TTC jamais écrit) + { method: "GET", path: "/api/payment-schedule?groupId=1" }, + { method: "GET", path: "/api/payment-schedule?groupId=2" }, + { method: "GET", path: "/api/payment-schedule?groupId=3" }, + ], + tables: ["deliveries", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "payment-schedule", + name: "export CSV de l'échéancier", + steps: [ + // Formulaire d'export : mois courant, tous les modes de paiement, HT et TTC + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: "1", month: month(0), paymentMethods: ALL_METHODS, includeHT: true, includeTTC: true } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", "cheque"], includeHT: false, includeTTC: true } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(1), paymentMethods: ALL_METHODS, includeHT: "non", includeTTC: 0 } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 2, month: month(0), paymentMethods: ALL_METHODS, includeHT: true } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 3, month: month(0), paymentMethods: ALL_METHODS, includeTTC: true } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2020-13", paymentMethods: ALL_METHODS, includeHT: true } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 999, month: month(0), paymentMethods: ALL_METHODS } }, + ], + tables: [], + }, + { + module: "payment-schedule", + name: "export CSV : paramètres invalides", + accounts: ["admin", "directeur", "manager"], + steps: [ + { method: "POST", path: "/api/payment-schedule/export", body: {} }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: "abc", month: month(0), paymentMethods: ALL_METHODS } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1.5, month: month(0), paymentMethods: ALL_METHODS } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: "2026-1", paymentMethods: ALL_METHODS } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: 202610, paymentMethods: ALL_METHODS } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: [] } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: "virement" } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0), paymentMethods: ["virement", 5, null] } }, + { method: "POST", path: "/api/payment-schedule/export", body: { groupId: 1, month: month(0) } }, + ], + tables: [], + }, + + // ---------- Envoi d'une facture au webhook (BLReconciliation.tsx) ---------- + { + module: "send-invoice", + name: "envoyer une facture au webhook du magasin", + steps: [ + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis", blNumber: "BL-1001", type: "Facture" }, + files: INVOICE_FILE, + }, + // Webhook BAP configuré ; champs facultatifs absents + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: `${STUB_URL}/webhook/bap` }, + files: [{ field: "file", filename: "scan échéance.png", contentType: "image/PNG", size: 10 }], + }, + // Fichier ou adresse manquants + { method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: `${STUB_URL}/webhook/avoirs`, supplier: "Lactalis" } }, + { method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { supplier: "Lactalis" }, files: INVOICE_FILE }, + { method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { webhookUrl: "" }, files: INVOICE_FILE }, + // Champ « file » en texte + { method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "pas un fichier", webhookUrl: `${STUB_URL}/webhook/avoirs` } }, + { method: "POST", path: "/api/reconciliation/send-invoice", bodyType: "multipart", body: { file: "", webhookUrl: `${STUB_URL}/webhook/avoirs` } }, + // Pas de multipart + { method: "POST", path: "/api/reconciliation/send-invoice", body: { webhookUrl: `${STUB_URL}/webhook/avoirs` } }, + { method: "POST", path: "/api/reconciliation/send-invoice" }, + ], + tables: [], + }, + { + module: "send-invoice", + name: "webhook de facture en erreur ou injoignable", + accounts: ["admin"], + steps: [ + // Adresses enregistrées sur la fiche du magasin d'Épinal + { method: "PUT", path: "/api/groups/3", body: { webhookUrl: `${STUB_URL}/inconnu` } }, + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: `${STUB_URL}/inconnu`, supplier: "Jardin & Co", blNumber: "", type: "Avoir" }, + files: INVOICE_FILE, + }, + { method: "PUT", path: "/api/groups/3", body: { webhookUrl: "http://127.0.0.1:1/webhook" } }, + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: "http://127.0.0.1:1/webhook", supplier: "Jardin & Co" }, + files: INVOICE_FILE, + }, + ], + tables: [], + }, + { + // RAPPRO-21 : Node envoie le fichier à toute adresse fournie par le navigateur + module: "send-invoice", + name: "envoyer une facture à une adresse non configurée", + // Les autres comptes sont refusés avant (403), comme dans le premier scénario + accounts: ["admin", "directeur"], + steps: [ + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: `${STUB_URL}/webhook/non-configure`, supplier: "Lactalis", blNumber: "BL-1001" }, + files: INVOICE_FILE, + }, + // Webhook BAP désactivé (par l'admin) : son adresse n'est plus acceptée + { method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } }, + { + method: "POST", + path: "/api/reconciliation/send-invoice", + bodyType: "multipart", + body: { webhookUrl: `${STUB_URL}/webhook/bap`, supplier: "Lactalis" }, + files: INVOICE_FILE, + }, + ], + tables: [], + deviation: { + fields: ["status", "body", "appels"], + reason: + "RAPPRO-21 : l'adresse du webhook doit être celle d'un magasin ou du webhook BAP actif ; toute autre adresse est refusée (400) au lieu d'être appelée par le serveur", + }, + }, + + // ---------- Administration : SQL et migration d'urgence (SQLExecutor.tsx) ---------- + { + module: "admin-sql", + name: "exécuter du SQL : lectures, types de colonnes et erreurs", + steps: [ + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, name, color, nocodb_config_id FROM groups ORDER BY id" } }, + { + method: "POST", + path: "/api/admin/execute-sql", + body: { + sql: "SELECT count(*) AS total, sum(amount) AS somme, min(created_at) AS premier, min(created_at)::date AS jour, bool_and(true) AS vrai, " + + "1.5::float8 AS flottant, 2::float8 AS entier, 7::int2 AS petit, 12345678901::int8 AS grand, NULL AS rien, '{\"a\": [1, \"b\"]}'::jsonb AS objet, " + + "ARRAY[1, 2] AS tableau, ARRAY['a', NULL] AS textes, 'x'::bytea AS octets, '1 day 02:00'::interval AS duree, " + + "'2026-01-01 10:00+02'::timestamptz AS horodate, 'f'::bool AS faux, 'é\"\\\\'::text AS texte FROM avoirs", + }, + }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 'a' AS x, 'b' AS x, 1" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT n, n * 1.5 AS demi FROM generate_series(1, 12) AS n" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 WHERE false" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELECT 2 AS b" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1 AS a; SELEC 2" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "ANALYZE groups" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: " " } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELEC 1" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT 1/0" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: 5 } }, + { method: "POST", path: "/api/admin/execute-sql", body: {} }, + ], + tables: [], + }, + { + module: "admin-sql", + name: "exécuter du SQL : modification d'une ligne", + steps: [ + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = '#123456' WHERE id = 3" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET color = color WHERE id = 999" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE avoirs SET comment = 'modifié' WHERE id = 1 RETURNING id, comment, amount" } }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, color FROM groups WHERE id = 3" } }, + ], + tables: ["groups", "avoirs"], + }, + { + module: "admin-sql", + name: "migration d'urgence : secrets en clair chiffrés", + steps: [ + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "UPDATE groups SET smtp_password = 'secret-en-clair' WHERE id = 2" } }, + { method: "POST", path: "/api/admin/emergency-migration" }, + { method: "POST", path: "/api/admin/execute-sql", body: { sql: "SELECT id, smtp_password LIKE 'enc:v1:%' AS chiffre FROM groups ORDER BY id" } }, + ], + tables: [], + }, + { + // N'existe que si EMERGENCY_SECRET est défini (sinon 404) : le banc rejoue le + // secret de son propre environnement + module: "admin-sql", + name: "réinitialisation d'urgence du compte admin", + accounts: ["anonyme", "manager"], + steps: [ + { method: "POST", path: "/api/emergency-admin-reset", body: { secret: "mauvais-secret" } }, + { method: "POST", path: "/api/emergency-admin-reset", body: {} }, + { method: "POST", path: "/api/emergency-admin-reset", body: { secret: process.env.EMERGENCY_SECRET || "secret-absent" } }, + { method: "POST", path: "/api/login", body: { username: "admin", password: "admin" } }, + ], + tables: ["users"], + // Empreinte au sel aléatoire + ignore: ["password"], + }, +]; diff --git a/scripts/bench/cases/fournisseurs-services.ts b/scripts/bench/cases/fournisseurs-services.ts new file mode 100644 index 0000000..a5e5b1a --- /dev/null +++ b/scripts/bench/cases/fournisseurs-services.ts @@ -0,0 +1,313 @@ +// Lot 4, services partagés : rapprochement BL / factures (commentaires, courriels +// fournisseurs, vérification des factures, cache), configuration NocoDB, BAP et +// clés de l'API externe. NocoDB, les webhooks et le SMTP sont simulés par stub.ts. +import { STUB_SMTP_PORT, STUB_URL } from "../config"; +import type { ReadCase, WriteScenario } from "../types"; + +export const reads: ReadCase[] = [ + // Commentaires de rapprochement (ReconciliationComments.tsx) + { module: "reconciliation", path: "/api/deliveries/2/reconciliation-comments" }, + { module: "reconciliation", path: "/api/deliveries/4/reconciliation-comments" }, + { module: "reconciliation", path: "/api/deliveries/6/reconciliation-comments" }, + { module: "reconciliation", path: "/api/deliveries/999/reconciliation-comments" }, + { module: "reconciliation", path: "/api/deliveries/abc/reconciliation-comments" }, + // Historique des relances (BLReconciliation.tsx : « ? » seul, ou storeId pour admin et directeur) + { module: "reconciliation", path: "/api/supplier-mail-logs?" }, + { module: "reconciliation", path: "/api/supplier-mail-logs?storeId=1" }, + { module: "reconciliation", path: "/api/supplier-mail-logs?storeId=2" }, + { module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=2" }, + { module: "reconciliation", path: "/api/supplier-mail-logs?deliveryId=abc" }, + { module: "reconciliation", path: "/api/supplier-mail-logs?storeId=abc" }, + // Diagnostic du cache (sans appelant dans l'interface ; supprime au passage une entrée expirée) + { module: "reconciliation", path: "/api/cache/diagnosis" }, + // Configuration NocoDB (NocoDBConfig.tsx, Groups.tsx) + { module: "nocodb-config", path: "/api/nocodb-config" }, + { module: "nocodb-config", path: "/api/nocodb-config/active" }, + // Webhook BAP (WebhookBAPConfig.tsx, BapUploadModal.tsx) + { module: "bap", path: "/api/webhook-bap-config" }, + // Clés de l'API externe (ExternalApiSettings.tsx) + { module: "external-api", path: "/api/external-api/keys" }, +]; + +// Durée de vie des entrées du cache : relative à l'heure du passage, différente +// entre les deux serveurs (vérifiée par les tests unitaires de Rust) +const CACHE_IGNORE = ["expires_at"]; + +export const writes: WriteScenario[] = [ + // ---------- Commentaires ---------- + { + module: "reconciliation", + name: "commentaires : ajouter, modifier, supprimer", + steps: [ + { method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Relance faite", type: "warning" } }, + { method: "PUT", path: "/api/reconciliation-comments/{{0.id}}", body: { content: "Relance faite (modifié)", type: "success" } }, + { method: "GET", path: "/api/deliveries/2/reconciliation-comments" }, + { method: "DELETE", path: "/api/reconciliation-comments/{{0.id}}" }, + { method: "POST", path: "/api/deliveries/6/reconciliation-comments", body: { content: "Commentaire à Metz" } }, + { method: "PUT", path: "/api/reconciliation-comments/1", body: { content: "Modifié par un autre compte" } }, + { method: "DELETE", path: "/api/reconciliation-comments/2" }, + { method: "GET", path: "/api/deliveries/4/reconciliation-comments" }, + ], + tables: ["reconciliation_comments"], + }, + { + module: "reconciliation", + name: "commentaires : corps invalides et identifiants inconnus", + steps: [ + { method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: {} }, + { method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "x", type: "bizarre" } }, + { method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: 5 } }, + { method: "POST", path: "/api/deliveries/2/reconciliation-comments", body: { content: "Forcé", deliveryId: 6, groupId: 2, authorId: "admin_local" } }, + { method: "POST", path: "/api/deliveries/999/reconciliation-comments", body: { content: "x" } }, + { method: "POST", path: "/api/deliveries/abc/reconciliation-comments", body: { content: "x" } }, + { method: "PUT", path: "/api/reconciliation-comments/1", body: { type: "inconnu" } }, + { method: "PUT", path: "/api/reconciliation-comments/1", body: { deliveryId: 4, authorId: "admin_local" } }, + { method: "PUT", path: "/api/reconciliation-comments/2", body: { deliveryId: 99999 } }, + { method: "PUT", path: "/api/reconciliation-comments/999", body: {} }, + { method: "PUT", path: "/api/reconciliation-comments/abc", body: {} }, + { method: "DELETE", path: "/api/reconciliation-comments/999" }, + { method: "DELETE", path: "/api/reconciliation-comments/abc" }, + ], + tables: ["reconciliation_comments"], + }, + + // ---------- Courriel au fournisseur ---------- + { + module: "reconciliation", + name: "courriel au fournisseur : envoi et refus", + steps: [ + { method: "POST", path: "/api/deliveries/2/send-supplier-mail" }, + { method: "POST", path: "/api/deliveries/4/send-supplier-mail" }, + { method: "POST", path: "/api/deliveries/6/send-supplier-mail" }, + { method: "POST", path: "/api/deliveries/7/send-supplier-mail" }, + { method: "POST", path: "/api/deliveries/999/send-supplier-mail" }, + { method: "POST", path: "/api/deliveries/abc/send-supplier-mail" }, + { method: "GET", path: "/api/supplier-mail-logs?deliveryId=2" }, + ], + tables: ["supplier_mail_logs"], + ignore: ["messageId", "message_id"], + }, + { + module: "reconciliation", + name: "courriel au fournisseur : SMTP incomplet ou injoignable", + steps: [ + // PUT /api/groups/:id reste servi par Node (lot 5) : admin et manager + { method: "PUT", path: "/api/groups/2", body: { smtpEnabled: true } }, + { method: "POST", path: "/api/deliveries/6/send-supplier-mail" }, + // Metz : sans logo, sans nom d'expéditeur ni authentification + { method: "PUT", path: "/api/groups/2", body: { smtpHost: " 127.0.0.1 ", smtpPort: STUB_SMTP_PORT, smtpSenderEmail: "metz@banc.test", smtpUser: null } }, + { method: "POST", path: "/api/deliveries/6/send-supplier-mail" }, + // Nancy : serveur injoignable + { method: "PUT", path: "/api/groups/1", body: { smtpPort: 1 } }, + { method: "POST", path: "/api/deliveries/2/send-supplier-mail" }, + { method: "GET", path: "/api/supplier-mail-logs?" }, + { method: "GET", path: "/api/supplier-mail-logs?deliveryId=6&storeId=2" }, + ], + tables: ["supplier_mail_logs"], + ignore: ["messageId", "message_id"], + }, + + // ---------- Vérification des factures ---------- + { + module: "reconciliation", + name: "vérifier la facture d'une livraison", + steps: [ + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: false } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: false } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002" } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1004" } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-9999", blNumber: "BL-1002" } }, + { method: "POST", path: "/api/deliveries/3/verify-invoice", body: { invoiceReference: " BL-1001 " } }, + { method: "POST", path: "/api/deliveries/1/verify-invoice", body: { invoiceReference: "FAC-2001" } }, + { method: "POST", path: "/api/deliveries/6/verify-invoice", body: { invoiceReference: "FAC-2006", forceRefresh: true } }, + { method: "POST", path: "/api/deliveries/7/verify-invoice", body: { invoiceReference: "FAC-3000" } }, + { method: "POST", path: "/api/deliveries/8/verify-invoice", body: { invoiceReference: "AV-3001", forceRefresh: true } }, + ], + tables: ["deliveries", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "reconciliation", + name: "vérifier la facture d'une livraison : corps invalides", + steps: [ + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: {} }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: "" } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: 12 } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: " ", blNumber: true } }, + { method: "POST", path: "/api/deliveries/999/verify-invoice", body: { invoiceReference: "FAC-2004" } }, + { method: "POST", path: "/api/deliveries/abc/verify-invoice", body: { invoiceReference: "FAC-2004" } }, + ], + tables: ["deliveries", "invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + { + module: "reconciliation", + name: "vérifier une facture : NocoDB injoignable, en erreur ou désactivé", + steps: [ + { method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "http://127.0.0.1:1" } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } }, + { method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: `${STUB_URL}/autre` } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { invoiceReference: "FAC-2002", forceRefresh: true } }, + { method: "POST", path: "/api/deliveries/2/verify-invoice", body: { blNumber: "BL-1002", forceRefresh: true } }, + { method: "PUT", path: "/api/nocodb-config/1", body: { baseUrl: "pas une adresse" } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } }, + { method: "PUT", path: "/api/nocodb-config/1", body: { isActive: false } }, + { method: "POST", path: "/api/deliveries/4/verify-invoice", body: { invoiceReference: "FAC-2004", forceRefresh: true } }, + ], + tables: ["invoice_verification_cache", "nocodb_config"], + ignore: CACHE_IGNORE, + }, + { + module: "reconciliation", + name: "vérifier des factures (adresses sans magasin contrôlé)", + steps: [ + { method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: "FAC-2002" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: "1", invoiceReference: "fac-2002" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 2, invoiceReference: "FAC-2001" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 3, invoiceReference: "FAC-2001" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 99, invoiceReference: "FAC-2001" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: "abc", invoiceReference: "FAC-2001" } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: 5 } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 1, invoiceReference: " " } }, + { method: "POST", path: "/api/verify-invoice", body: { groupId: 1 } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "fac-2001", deliveryId: 10, supplierName: "lactalis" }, { groupId: 3, invoiceReference: "X", deliveryId: 2 }] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, invoiceReference: "FAC-2001", deliveryId: 1, supplierName: "Bonduelle" }] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: 1, deliveryId: 3 }] } }, + // Une seule clé non numérique par appel : Node les range dans l'ordre où ses + // vérifications parallèles se terminent + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ deliveryId: "a" }, { groupId: 99, deliveryId: 5 }] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [7] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [{ groupId: "abc", deliveryId: 4 }] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: [null] } }, + { method: "POST", path: "/api/verify-invoices", body: { invoiceReferences: "FAC-2001" } }, + ], + tables: ["invoice_verification_cache"], + ignore: CACHE_IGNORE, + }, + + // ---------- Cache des factures ---------- + { + module: "reconciliation", + name: "cache : marquer rapproché, mise à jour en lot, diagnostic", + steps: [ + { method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: " FAC-2004 ", groupId: 1 } }, + { method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-1900", groupId: "1" } }, + { method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: 5, groupId: 1 } }, + { method: "POST", path: "/api/cache/mark-reconciled", body: { invoiceReference: "FAC-2004" } }, + { method: "POST", path: "/api/cache/update-reconciled" }, + { method: "GET", path: "/api/cache/diagnosis" }, + ], + tables: ["invoice_verification_cache"], + }, + + // ---------- Configuration NocoDB ---------- + { + module: "nocodb-config", + name: "configurations NocoDB : créer, modifier, supprimer", + steps: [ + { method: "POST", path: "/api/nocodb-config", body: { name: "NocoDB secours", baseUrl: `${STUB_URL}/nocodb`, projectId: "psecours", apiToken: "jeton-neuf", description: "Seconde base", isActive: false, createdBy: "pirate" } }, + { method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: { name: "NocoDB renommée", apiToken: "jeton-modifié" } }, + { method: "PUT", path: "/api/nocodb-config/{{0.id}}", body: {} }, + { method: "GET", path: "/api/nocodb-config" }, + { method: "GET", path: "/api/nocodb-config/active" }, + { method: "DELETE", path: "/api/nocodb-config/{{0.id}}" }, + { method: "DELETE", path: "/api/nocodb-config/999" }, + { method: "PUT", path: "/api/nocodb-config/999", body: { name: "Absente" } }, + { method: "POST", path: "/api/nocodb-config", body: { name: "Sans jeton", baseUrl: "http://x.test", projectId: "p", apiToken: "" } }, + ], + tables: ["nocodb_config"], + ignore: ["api_token"], + }, + { + module: "nocodb-config", + name: "configurations NocoDB : corps invalides", + steps: [ + { method: "POST", path: "/api/nocodb-config", body: {} }, + { method: "POST", path: "/api/nocodb-config", body: { name: "x", baseUrl: 1, projectId: "p", apiToken: "t" } }, + { method: "PUT", path: "/api/nocodb-config/1", body: { isActive: "oui" } }, + { method: "PUT", path: "/api/nocodb-config/abc", body: {} }, + { method: "DELETE", path: "/api/nocodb-config/abc" }, + ], + tables: ["nocodb_config"], + ignore: ["api_token"], + }, + + // ---------- BAP ---------- + { + module: "bap", + name: "webhook BAP : configuration", + steps: [ + { method: "POST", path: "/api/webhook-bap-config", body: { name: "BAP banc", webhookUrl: `${STUB_URL}/webhook/bap-nouveau`, description: "Modifiée", isActive: true, id: 7 } }, + { method: "GET", path: "/api/webhook-bap-config" }, + { method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: 12 } }, + { method: "POST", path: "/api/webhook-bap-config", body: {} }, + { method: "POST", path: "/api/webhook-bap-config", body: { name: "x".repeat(101), webhookUrl: "u", isActive: "oui" } }, + ], + tables: ["webhook_bap_config"], + }, + { + module: "bap", + name: "webhook BAP : test de connexion", + steps: [ + { method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/webhook/bap` } }, + { method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: `${STUB_URL}/autre` } }, + { method: "POST", path: "/api/webhook-bap-config/test", body: {} }, + { method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "pas-une-url" } }, + { method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "http://127.0.0.1:1" } }, + { method: "POST", path: "/api/webhook-bap-config/test", body: { webhookUrl: "ftp://x.test/a" } }, + ], + tables: [], + }, + { + module: "bap", + name: "envoi d'un BAP au webhook", + steps: [ + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQKJcOkw7zDtsOf", fileName: "Facture-123.PDF", recipient: "Laurie" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.pdf", recipient: "Paul" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "a.txt", recipient: "Jeremy" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: 5, fileName: "a.pdf", recipient: "Jeremy" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: [1, 2], fileName: "a.pdf", recipient: "Jeremy" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "x", fileName: 5, recipient: "Laurie" } }, + { method: "POST", path: "/api/bap/send-webhook", body: { fileName: "a.pdf" } }, + { method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/autre` } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "b.pdf", recipient: "Jeremy" } }, + { method: "POST", path: "/api/webhook-bap-config", body: { webhookUrl: `${STUB_URL}/webhook/bap`, isActive: false } }, + { method: "POST", path: "/api/bap/send-webhook", body: { pdfBase64: "JVBERi0xLjQK", fileName: "c.pdf", recipient: "Laurie" } }, + ], + tables: ["webhook_bap_config"], + }, + + // ---------- Clés de l'API externe ---------- + { + module: "external-api", + name: "clés d'API : créer, révoquer", + steps: [ + { method: "POST", path: "/api/external-api/keys", body: { name: " Nouvel outil " } }, + { method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" }, + { method: "DELETE", path: "/api/external-api/keys/{{0.apiKey.id}}" }, + { method: "DELETE", path: "/api/external-api/keys/2" }, + { method: "DELETE", path: "/api/external-api/keys/1" }, + { method: "DELETE", path: "/api/external-api/keys/1e2" }, + { method: "GET", path: "/api/external-api/keys" }, + ], + tables: ["external_api_keys"], + ignore: ["key", "keyPrefix", "key_prefix", "key_hash"], + }, + { + module: "external-api", + name: "clés d'API : nom ou identifiant invalide", + steps: [ + { method: "POST", path: "/api/external-api/keys", body: {} }, + { method: "POST", path: "/api/external-api/keys", body: { name: " " } }, + { method: "POST", path: "/api/external-api/keys", body: { name: 5 } }, + { method: "POST", path: "/api/external-api/keys", body: { name: "x".repeat(101) } }, + { method: "POST", path: "/api/external-api/keys", body: [] }, + { method: "DELETE", path: "/api/external-api/keys/abc" }, + { method: "DELETE", path: "/api/external-api/keys/0" }, + { method: "DELETE", path: "/api/external-api/keys/1.5" }, + { method: "DELETE", path: "/api/external-api/keys/99999999999" }, + ], + tables: ["external_api_keys"], + }, +]; diff --git a/scripts/bench/cases/index.ts b/scripts/bench/cases/index.ts new file mode 100644 index 0000000..187e54d --- /dev/null +++ b/scripts/bench/cases/index.ts @@ -0,0 +1,24 @@ +// Toutes les requêtes du banc, une liste par lot (docs/PLAN-MIGRATION-RUST.md) +import type { ReadCase, WriteScenario } from "../types"; +import * as socle from "./socle"; +import * as referentiel from "./referentiel"; +import * as informations from "./informations"; +import * as quotidien from "./quotidien"; +import * as fournisseursServices from "./fournisseurs-services"; +import * as fournisseursCommandes from "./fournisseurs-commandes"; +import * as fournisseursRapprochement from "./fournisseurs-rapprochement"; +import * as administration from "./administration"; + +const modules = [ + socle, + referentiel, + informations, + quotidien, + fournisseursServices, + fournisseursCommandes, + fournisseursRapprochement, + administration, +]; + +export const READS: ReadCase[] = modules.flatMap((m) => m.reads); +export const WRITES: WriteScenario[] = modules.flatMap((m) => m.writes); diff --git a/scripts/bench/cases/informations.ts b/scripts/bench/cases/informations.ts new file mode 100644 index 0000000..eecb9b1 --- /dev/null +++ b/scripts/bench/cases/informations.ts @@ -0,0 +1,251 @@ +// Lot 2 : Informations (annonces), Publicités, Météo, Statistiques et +// Statistiques détaillées. Paramètres repris des appels de client/src +// (AnnouncementCard, Calendar, Publicities, Dashboard, StatsPanel, Groups, +// Suppliers, Analytics, WeatherWidget, WeatherSettings). +// Les appels réels à Visual Crossing et Nominatim ne sont jamais déclenchés : +// la météo du jour vient du cache (seeds/lot2.ts), et les cas de test de +// connexion et de géolocalisation s'arrêtent avant l'appel externe. +import type { ReadCase, WriteScenario } from "../types"; + +const YEAR = new Date().getUTCFullYear(); +const PERIOD = "startDate={{today-30}}T00:00:00.000Z&endDate={{today}}T23:59:59.999Z"; +const YEAR_PERIOD = "startDate={{year}}-01-01T00:00:00.000Z&endDate={{year}}-12-31T23:59:59.999Z"; + +const announcementReads: ReadCase[] = [ + { module: "announcements", path: "/api/announcements" }, + { module: "announcements", path: "/api/announcements?storeId=1" }, + { module: "announcements", path: "/api/announcements?storeId=3" }, + // Magasin illisible : erreur SQL, repli de Node sur toutes les informations + { module: "announcements", path: "/api/announcements?storeId=abc" }, +]; + +const publicityReads: ReadCase[] = [ + { module: "publicities", path: "/api/ad-campaigns" }, + { module: "publicities", path: "/api/ad-campaigns?year={{year}}" }, + { module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=1" }, + { module: "publicities", path: "/api/ad-campaigns?year={{year}}&storeId=2" }, + { module: "publicities", path: "/api/ad-campaigns?year={{year-1}}" }, + { module: "publicities", path: "/api/ad-campaigns?year={{year+1}}" }, + { module: "publicities", path: "/api/ad-campaigns?storeId=3" }, + { module: "publicities", path: "/api/ad-campaigns?year=abc" }, + { module: "publicities", path: "/api/ad-campaigns?year=99999999999" }, + { module: "publicities", path: "/api/ad-campaigns/1" }, + { module: "publicities", path: "/api/ad-campaigns/3" }, + { module: "publicities", path: "/api/ad-campaigns/200" }, + { module: "publicities", path: "/api/ad-campaigns/202" }, + { module: "publicities", path: "/api/ad-campaigns/999" }, + { module: "publicities", path: "/api/ad-campaigns/abc" }, + { module: "publicities", path: "/api/ad-campaigns/debug" }, + { module: "publicities", path: "/api/ad-campaigns/debug?year={{year}}&storeId=1" }, + { module: "publicities", path: "/api/ad-campaigns/debug?year=99999999999" }, +]; + +const weatherReads: ReadCase[] = [ + { module: "weather", path: "/api/weather/settings" }, + { module: "weather", path: "/api/weather/current" }, +]; + +const statsReads: ReadCase[] = [ + { module: "stats", path: "/api/stats/by-group" }, + { module: "stats", path: "/api/stats/by-group?storeId=1" }, + { module: "stats", path: "/api/stats/by-group?storeId=2" }, + { module: "stats", path: "/api/stats/by-group?storeId=abc" }, + { module: "stats", path: "/api/stats/by-supplier" }, + { module: "stats", path: "/api/stats/by-supplier?storeId=1" }, + { module: "stats", path: "/api/stats/by-supplier?storeId=3" }, + { module: "stats", path: "/api/stats/by-supplier?storeId=abc" }, + { module: "stats", path: "/api/stats/monthly" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=1" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=2" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month={{month}}&storeId=abc" }, + { module: "stats", path: "/api/stats/monthly?year={{year-1}}&month={{month}}" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month=12" }, + { module: "stats", path: "/api/stats/monthly?year={{year}}&month=13" }, + { module: "stats", path: "/api/stats/monthly?year=abc&month=3" }, + { module: "stats", path: "/api/stats/yearly" }, + { module: "stats", path: "/api/stats/yearly?year={{year}}" }, + { module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=1" }, + { module: "stats", path: "/api/stats/yearly?year={{year}}&storeId=3" }, + { module: "stats", path: "/api/stats/yearly?year={{year-1}}" }, + { module: "stats", path: "/api/stats/yearly?year=abc" }, +]; + +const analyticsReads: ReadCase[] = [ + { module: "analytics", path: "/api/analytics/summary" }, + { module: "analytics", path: `/api/analytics/summary?${PERIOD}` }, + { module: "analytics", path: `/api/analytics/summary?${YEAR_PERIOD}&supplierIds=1,2&groupIds=1` }, + { module: "analytics", path: `/api/analytics/summary?${PERIOD}&groupIds=2` }, + { module: "analytics", path: `/api/analytics/summary?${PERIOD}&status=delivered` }, + { module: "analytics", path: "/api/analytics/summary?startDate={{today+500}}T00:00:00.000Z" }, + { module: "analytics", path: "/api/analytics/summary?supplierIds=abc" }, + { module: "analytics", path: "/api/analytics/summary?groupIds=1.5" }, + { module: "analytics", path: "/api/analytics/summary?startDate=pas-une-date" }, + { module: "analytics", path: `/api/analytics/timeseries?${PERIOD}&granularity=day` }, + { module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=week` }, + { module: "analytics", path: `/api/analytics/timeseries?${YEAR_PERIOD}&granularity=month&groupIds=1,2` }, + { module: "analytics", path: "/api/analytics/timeseries" }, + { module: "analytics", path: "/api/analytics/timeseries?granularity=autre&supplierIds=1,4" }, + { module: "analytics", path: "/api/analytics/timeseries?supplierIds=1.5,2" }, + { module: "analytics", path: "/api/analytics/timeseries?supplierIds=abc" }, + { module: "analytics", path: "/api/analytics/timeseries?endDate=pas-une-date" }, + { module: "analytics", path: "/api/analytics/by-supplier" }, + { module: "analytics", path: `/api/analytics/by-supplier?${PERIOD}` }, + { module: "analytics", path: `/api/analytics/by-supplier?${YEAR_PERIOD}&groupIds=1,3` }, + { module: "analytics", path: "/api/analytics/by-supplier?groupIds=abc" }, + { module: "analytics", path: "/api/analytics/by-store" }, + { module: "analytics", path: `/api/analytics/by-store?${PERIOD}&supplierIds=1,2` }, + { module: "analytics", path: "/api/analytics/by-store?supplierIds=abc" }, + { module: "analytics", path: "/api/analytics/by-store?startDate=pas-une-date" }, + { module: "analytics", path: `/api/analytics/export?${PERIOD}&type=summary` }, + { module: "analytics", path: "/api/analytics/export" }, + { module: "analytics", path: `/api/analytics/export?${YEAR_PERIOD}&type=timeseries` }, + { module: "analytics", path: `/api/analytics/export?${PERIOD}&groupIds=1&type=suppliers` }, + { module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=2" }, + { module: "analytics", path: "/api/analytics/export?type=inconnu" }, + { module: "analytics", path: "/api/analytics/export?type=timeseries&startDate={{today+500}}T00:00:00.000Z" }, + { module: "analytics", path: "/api/analytics/export?type=stores&supplierIds=abc" }, + { module: "analytics", path: "/api/analytics/export?type=suppliers&groupIds=abc" }, + { module: "analytics", path: "/api/analytics/export?type=r%C3%A9sum%C3%A9" }, + { module: "analytics", path: "/api/analytics/export?type=%E2%82%AC" }, +]; + +export const reads: ReadCase[] = [ + ...announcementReads, + ...publicityReads, + ...weatherReads, + ...statsReads, + ...analyticsReads, +]; + +const announcementWrites: WriteScenario[] = [ + { + module: "announcements", + name: "créer, modifier, supprimer une information", + steps: [ + { method: "POST", path: "/api/announcements", body: { title: "Inventaire tournant", content: "Rayon frais lundi", type: "warning" } }, + { method: "POST", path: "/api/announcements", body: { title: "Livraison décalée", content: "Camion attendu à 14 h", storeId: 1 } }, + { method: "POST", path: "/api/announcements", body: { title: "Autre magasin", content: "Pour Metz", type: "", storeId: 2, createdBy: "pirate" } }, + { method: "PUT", path: "/api/announcements/{{0.id}}", body: { title: "Inventaire reporté", type: "error", storeId: null } }, + { method: "PUT", path: "/api/announcements/202", body: { content: "Réunion déplacée", storeId: 3 } }, + { method: "DELETE", path: "/api/announcements/{{1.id}}" }, + { method: "DELETE", path: "/api/announcements/3" }, + { method: "GET", path: "/api/announcements" }, + ], + tables: ["dashboard_messages"], + }, + { + module: "announcements", + name: "information invalide ou introuvable", + steps: [ + { method: "POST", path: "/api/announcements", body: {} }, + { method: "POST", path: "/api/announcements", body: { title: "x".repeat(300), content: 5 } }, + { method: "POST", path: "/api/announcements", body: { title: "Type inconnu", content: "Refusé par la base", type: "urgent" } }, + { method: "POST", path: "/api/announcements", body: { title: "Magasin inconnu", content: "Clé étrangère", storeId: 999 } }, + { method: "POST", path: "/api/announcements", body: { title: "Magasin en texte", content: "Refusé", storeId: "1" } }, + { method: "PUT", path: "/api/announcements/999", body: { title: "Introuvable" } }, + { method: "PUT", path: "/api/announcements/abc", body: { title: "Illisible" } }, + { method: "PUT", path: "/api/announcements/1", body: {} }, + { method: "PUT", path: "/api/announcements/1", body: { type: 5, storeId: "2" } }, + { method: "PUT", path: "/api/announcements/1", body: { type: "urgent" } }, + { method: "DELETE", path: "/api/announcements/999" }, + { method: "DELETE", path: "/api/announcements/abc" }, + ], + tables: ["dashboard_messages"], + }, +]; + +const publicityWrites: WriteScenario[] = [ + { + module: "publicities", + name: "créer, modifier, supprimer une publicité", + steps: [ + { + method: "POST", path: "/api/ad-campaigns", + body: { pubNumber: "PUB-50", designation: "Fête des jardins", startDate: "{{today+40}}", endDate: "{{today+50}}", year: YEAR, participatingGroups: [1, 2] }, + }, + { method: "PUT", path: "/api/ad-campaigns/{{0.id}}", body: { designation: "Fête des jardins (prolongée)", endDate: "{{today+55}}", participatingGroups: [3] } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-01", year: String(YEAR), participatingGroups: [] } }, + { method: "PUT", path: "/api/ad-campaigns/2", body: { designation: ["Spécial", "jardin"], createdBy: "bench_manager", inconnu: 1 } }, + { method: "PUT", path: "/api/ad-campaigns/202", body: { participatingGroups: 5 } }, + { method: "GET", path: "/api/ad-campaigns/{{0.id}}" }, + { method: "POST", path: "/api/ad-campaigns/{{0.id}}/delete", body: {} }, + { method: "DELETE", path: "/api/ad-campaigns/3" }, + { method: "GET", path: "/api/ad-campaigns?year={{year}}" }, + ], + tables: ["publicities", "publicity_participations"], + }, + { + module: "publicities", + name: "publicité invalide, en double ou introuvable", + steps: [ + { method: "POST", path: "/api/ad-campaigns", body: {} }, + { method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-01", designation: "Doublon", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR } }, + { method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-60", designation: "Magasin inconnu", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: [1, 999] } }, + { method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-61", designation: "Groupes en texte", startDate: "{{today}}", endDate: "{{today+7}}", year: YEAR, participatingGroups: "12" } }, + { method: "POST", path: "/api/ad-campaigns", body: { pubNumber: "PUB-62", designation: "Date illisible", startDate: "bientôt", endDate: "{{today+7}}", year: YEAR } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { startDate: "pas une date" } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { createdAt: "2020-01-01" } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { pubNumber: "PUB-02" } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { year: 2030.5 } }, + { method: "PUT", path: "/api/ad-campaigns/1", body: { designation: null } }, + { method: "PUT", path: "/api/ad-campaigns/2", body: { participatingGroups: null } }, + { method: "PUT", path: "/api/ad-campaigns/200", body: { participatingGroups: [2, 2] } }, + { method: "PUT", path: "/api/ad-campaigns/999", body: { designation: "Introuvable" } }, + { method: "PUT", path: "/api/ad-campaigns/998", body: { participatingGroups: [1] } }, + { method: "PUT", path: "/api/ad-campaigns/abc", body: { designation: "Illisible" } }, + { method: "PUT", path: "/api/ad-campaigns/201", body: {} }, + { method: "DELETE", path: "/api/ad-campaigns/999" }, + { method: "DELETE", path: "/api/ad-campaigns/abc" }, + { method: "POST", path: "/api/ad-campaigns/99999999999/delete", body: {} }, + ], + tables: ["publicities", "publicity_participations"], + }, +]; + +const weatherWrites: WriteScenario[] = [ + { + module: "weather", + name: "configurer la météo", + steps: [ + { method: "PUT", path: "/api/weather/settings/1", body: { location: "Metz, France", isActive: true, apiKey: "cle-meteo-modifiee" } }, + { method: "GET", path: "/api/weather/current" }, + { method: "POST", path: "/api/weather/settings", body: { apiKey: "seconde-cle-meteo", location: "Épinal, France" } }, + { method: "GET", path: "/api/weather/settings" }, + { method: "PUT", path: "/api/weather/settings/999", body: { location: "Introuvable" } }, + { method: "PUT", path: "/api/weather/settings/1", body: {} }, + { method: "PUT", path: "/api/weather/settings/1", body: { isActive: false } }, + { method: "GET", path: "/api/weather/current" }, + ], + tables: ["weather_settings", "weather_data"], + }, + { + module: "weather", + name: "configuration météo invalide", + steps: [ + { method: "POST", path: "/api/weather/settings", body: {} }, + { method: "POST", path: "/api/weather/settings", body: { apiKey: 5, isActive: "oui" } }, + { method: "PUT", path: "/api/weather/settings/1", body: { isActive: "oui" } }, + { method: "PUT", path: "/api/weather/settings/abc", body: { location: "Illisible" } }, + { method: "PUT", path: "/api/weather/settings/abc", body: { location: 12 } }, + ], + tables: ["weather_settings"], + }, + { + // Corps incomplets : refusés avant tout appel à Visual Crossing ou Nominatim + module: "weather", + name: "test de connexion et géolocalisation incomplets", + steps: [ + { method: "POST", path: "/api/weather/test-connection", body: {} }, + { method: "POST", path: "/api/weather/test-connection", body: { apiKey: "cle-de-test-meteo" } }, + { method: "POST", path: "/api/weather/test-connection", body: { apiKey: "", location: "Nancy, France" } }, + { method: "POST", path: "/api/weather/geolocation", body: {} }, + { method: "POST", path: "/api/weather/geolocation", body: { latitude: 0, longitude: 6.18 } }, + { method: "POST", path: "/api/weather/geolocation", body: { latitude: 48.69 } }, + { method: "POST", path: "/api/weather/geolocation", body: { latitude: "", longitude: "6.18" } }, + ], + tables: ["weather_settings", "weather_data"], + }, +]; + +export const writes: WriteScenario[] = [...announcementWrites, ...publicityWrites, ...weatherWrites]; diff --git a/scripts/bench/cases/quotidien.ts b/scripts/bench/cases/quotidien.ts new file mode 100644 index 0000000..8bf89f3 --- /dev/null +++ b/scripts/bench/cases/quotidien.ts @@ -0,0 +1,359 @@ +// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV. +// Lignes du banc par magasin (seed.ts et seeds/lot3.ts) : +// Nancy (1) : tâches 1, 2, 3, 301, 302 ; DLC 1 à 5, 301, 302, 303, 305 ; +// commandes 1, 2, 3, 301 ; SAV 1, 2, 301 +// Metz (2) : tâches 4, 5, 303 ; DLC 6, 304 ; commandes 4, 5, 302, 303 ; SAV 3, 302 +// Épinal (3) : tâche 300 ; DLC 300 ; commande 300 ; SAV 300 (aucun compte du banc sauf l'admin) +import type { ReadCase, WriteScenario } from "../types"; + +export const reads: ReadCase[] = [ + // Tâches : sans magasin choisi, un non-admin reçoit une liste vide + { module: "tasks", path: "/api/tasks" }, + { module: "tasks", path: "/api/tasks?storeId=1" }, + { module: "tasks", path: "/api/tasks?storeId=2" }, + { module: "tasks", path: "/api/tasks?storeId=3" }, + { module: "tasks", path: "/api/tasks?storeId=abc" }, + { module: "tasks", path: "/api/tasks?storeId=99999999999" }, + { module: "tasks", path: "/api/tasks?storeId=1.9" }, + + // DLC : filtres de DlcPage, de la fenêtre d'alerte et du tableau de bord + { module: "dlc", path: "/api/dlc-products" }, + { module: "dlc", path: "/api/dlc-products?storeId=1" }, + { module: "dlc", path: "/api/dlc-products?storeId=2" }, + { module: "dlc", path: "/api/dlc-products?storeId=3" }, + { module: "dlc", path: "/api/dlc-products?storeId=abc" }, + { module: "dlc", path: "/api/dlc-products?status=expires" }, + { module: "dlc", path: "/api/dlc-products?status=expires&storeId=1" }, + { module: "dlc", path: "/api/dlc-products?status=expires_soon" }, + { module: "dlc", path: "/api/dlc-products?status=expires_soon&storeId=2" }, + { module: "dlc", path: "/api/dlc-products?status=en_cours" }, + { module: "dlc", path: "/api/dlc-products?status=valides" }, + { module: "dlc", path: "/api/dlc-products?status=all" }, + { module: "dlc", path: "/api/dlc-products?status=inconnu" }, + { module: "dlc", path: "/api/dlc-products?supplierId=2" }, + { module: "dlc", path: "/api/dlc-products?supplierId=all" }, + { module: "dlc", path: "/api/dlc-products?supplierId=abc" }, + { module: "dlc", path: "/api/dlc-products?supplierId=0" }, + { module: "dlc", path: "/api/dlc-products?supplierId=99999999999" }, + { module: "dlc", path: "/api/dlc-products?storeId=99999999999" }, + { module: "dlc", path: "/api/dlc-products?search=camem" }, + { module: "dlc", path: "/api/dlc-products?search=BONDU" }, + { module: "dlc", path: "/api/dlc-products?search=%25" }, + { module: "dlc", path: "/api/dlc-products?storeId=1&status=expires_soon&supplierId=1&search=e" }, + { module: "dlc", path: "/api/dlc-products/stats" }, + { module: "dlc", path: "/api/dlc-products/stats?storeId=1" }, + { module: "dlc", path: "/api/dlc-products/stats?storeId=2" }, + { module: "dlc", path: "/api/dlc-products/stats?storeId=3" }, + { module: "dlc", path: "/api/dlc-products/stats?storeId=abc" }, + { module: "dlc", path: "/api/dlc-products/1" }, + { module: "dlc", path: "/api/dlc-products/6" }, + { module: "dlc", path: "/api/dlc-products/300" }, + { module: "dlc", path: "/api/dlc-products/303" }, + { module: "dlc", path: "/api/dlc-products/9999" }, + { module: "dlc", path: "/api/dlc-products/abc" }, + + // Commandes clients : la page n'envoie storeId que pour l'admin, les appels à passer pour tous + { module: "customer-orders", path: "/api/customer-orders" }, + { module: "customer-orders", path: "/api/customer-orders?storeId=1" }, + { module: "customer-orders", path: "/api/customer-orders?storeId=2" }, + { module: "customer-orders", path: "/api/customer-orders?storeId=3" }, + { module: "customer-orders", path: "/api/customer-orders?storeId=abc" }, + { module: "customer-orders", path: "/api/customer-orders?storeId=2.5" }, + { module: "customer-orders", path: "/api/customer-orders/pending-calls" }, + { module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=1" }, + { module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=3" }, + { module: "customer-orders", path: "/api/customer-orders/pending-calls?storeId=abc" }, + + // SAV : filtres de SavTickets (groupId pour l'admin seulement côté interface) + { module: "sav", path: "/api/sav/tickets" }, + { module: "sav", path: "/api/sav/tickets?groupId=1" }, + { module: "sav", path: "/api/sav/tickets?groupId=3" }, + { module: "sav", path: "/api/sav/tickets?groupId=abc" }, + { module: "sav", path: "/api/sav/tickets?groupId=0" }, + { module: "sav", path: "/api/sav/tickets?groupId=99999999999" }, + { module: "sav", path: "/api/sav/tickets?supplierId=99999999999" }, + { module: "sav", path: "/api/sav/tickets?endDate={{today}}T23:59:59%2B02:00" }, + { module: "sav", path: "/api/sav/tickets?status=nouveau" }, + { module: "sav", path: "/api/sav/tickets?priority=critique&groupId=3" }, + { module: "sav", path: "/api/sav/tickets?supplierId=4" }, + { module: "sav", path: "/api/sav/tickets?supplierId=abc" }, + { module: "sav", path: "/api/sav/tickets?status=attente_pieces&priority=normale&supplierId=4&groupId=1" }, + { module: "sav", path: "/api/sav/tickets?startDate={{today-10}}&endDate={{today}}" }, + { module: "sav", path: "/api/sav/tickets?startDate=pas-une-date" }, + { module: "sav", path: "/api/sav/tickets/1" }, + { module: "sav", path: "/api/sav/tickets/3" }, + { module: "sav", path: "/api/sav/tickets/300" }, + { module: "sav", path: "/api/sav/tickets/301" }, + { module: "sav", path: "/api/sav/tickets/9999" }, + { module: "sav", path: "/api/sav/tickets/abc" }, + { module: "sav", path: "/api/sav/stats" }, + { module: "sav", path: "/api/sav/stats?groupId=1" }, + { module: "sav", path: "/api/sav/stats?groupId=3" }, + { module: "sav", path: "/api/sav/stats?groupId=abc" }, +]; + +export const writes: WriteScenario[] = [ + // ---------- Tâches ---------- + { + module: "tasks", + name: "créer une tâche, la modifier, la terminer, la supprimer", + steps: [ + { method: "POST", path: "/api/tasks", body: { title: "Nouvelle tâche", description: "", priority: "high", status: "pending", assignedTo: "Sophie", startDate: "{{today+1}}", dueDate: "{{today+3}}", groupId: 1, createdBy: "manager" } }, + { method: "PUT", path: "/api/tasks/{{0.id}}", body: { title: "Tâche modifiée", priority: "low", assignedTo: "Marc", startDate: "", dueDate: "{{today+5}}" } }, + { method: "POST", path: "/api/tasks/{{0.id}}/complete" }, + { method: "GET", path: "/api/tasks?storeId=1" }, + { method: "DELETE", path: "/api/tasks/{{0.id}}" }, + ], + tables: ["tasks"], + }, + { + module: "tasks", + name: "tâches de son magasin et d'un autre", + steps: [ + { method: "PUT", path: "/api/tasks/2", body: { status: "completed", description: null, startDate: "{{today}}T14:30", dueDate: null } }, + { method: "PUT", path: "/api/tasks/4", body: { title: "Autre magasin" } }, + { method: "PUT", path: "/api/tasks/300", body: { title: "Épinal" } }, + { method: "POST", path: "/api/tasks/1/complete" }, + { method: "POST", path: "/api/tasks/5/complete" }, + { method: "POST", path: "/api/tasks/300/complete" }, + { method: "DELETE", path: "/api/tasks/3" }, + { method: "DELETE", path: "/api/tasks/303" }, + { method: "DELETE", path: "/api/tasks/300" }, + { method: "PUT", path: "/api/tasks/9999", body: { title: "Absente" } }, + { method: "POST", path: "/api/tasks/9999/complete" }, + { method: "DELETE", path: "/api/tasks/9999" }, + { method: "DELETE", path: "/api/tasks/abc" }, + { method: "POST", path: "/api/tasks/abc/complete" }, + ], + tables: ["tasks"], + }, + { + module: "tasks", + name: "créer des tâches : magasin par défaut, autre magasin, corps invalides", + steps: [ + { method: "POST", path: "/api/tasks", body: { title: "Sans magasin", assignedTo: "Léa" } }, + { method: "POST", path: "/api/tasks", body: { title: "Magasin d'Épinal", assignedTo: "Léa", groupId: 3, dueDate: "{{today+2}}T18:00:00.000Z" } }, + { method: "POST", path: "/api/tasks", body: { title: "Magasin en texte", assignedTo: "Léa", groupId: "1", startDate: 0 } }, + { method: "POST", path: "/api/tasks", body: { title: "Metz", assignedTo: "Léa", groupId: 2, status: "completed", completedBy: "x" } }, + { method: "POST", path: "/api/tasks", body: {} }, + { method: "POST", path: "/api/tasks", body: { title: "Priorité inconnue", assignedTo: "Léa", groupId: 1, priority: "urgent" } }, + { method: "POST", path: "/api/tasks", body: { title: "Échéance invalide", assignedTo: "Léa", groupId: 1, dueDate: "pas une date" } }, + { method: "POST", path: "/api/tasks", body: { title: "Départ invalide", assignedTo: "Léa", groupId: 1, startDate: "2026-13-45" } }, + { method: "POST", path: "/api/tasks", body: { title: "Date de création", assignedTo: "Léa", groupId: 1, createdAt: "{{today}}" } }, + { method: "POST", path: "/api/tasks", body: { id: 900, title: "Identifiant imposé", assignedTo: "Léa", groupId: 1, inconnu: true } }, + ], + tables: ["tasks"], + }, + { + module: "tasks", + name: "modifier une tâche avec des valeurs invalides", + steps: [ + { method: "PUT", path: "/api/tasks/1", body: { priority: "urgent" } }, + { method: "PUT", path: "/api/tasks/1", body: { startDate: "n'importe quoi" } }, + { method: "PUT", path: "/api/tasks/1", body: { dueDate: "n'importe quoi" } }, + { method: "PUT", path: "/api/tasks/1", body: { title: null } }, + { method: "PUT", path: "/api/tasks/1", body: { assignedTo: 42, groupId: 2, createdBy: "pirate" } }, + { method: "PUT", path: "/api/tasks/1", body: {} }, + { method: "PUT", path: "/api/tasks/abc", body: {} }, + { method: "PUT", path: "/api/tasks/99999999999", body: {} }, + ], + tables: ["tasks"], + }, + + // ---------- DLC ---------- + { + module: "dlc", + name: "créer un produit DLC, le modifier, le valider, le supprimer", + steps: [ + { + method: "POST", path: "/api/dlc-products", body: { + productName: "Jambon blanc", name: "Jambon blanc", gencode: "3000000000999", supplierId: 1, groupId: 1, + dlcDate: "{{today+4}}", expiryDate: "{{today+4}}T00:00:00.000Z", dateType: "dlc", quantity: 1, unit: "unité", + location: "Magasin", alertThreshold: 15, status: "en_cours", notes: "", createdBy: "pirate", + }, + }, + { method: "PUT", path: "/api/dlc-products/{{0.id}}", body: { productName: "Jambon supérieur", name: "Jambon supérieur", expiryDate: "{{today+6}}", dlcDate: "{{today+6}}", notes: "Modifié", groupId: 1 } }, + { method: "POST", path: "/api/dlc-products/{{0.id}}/validate" }, + { method: "GET", path: "/api/dlc-products/{{0.id}}" }, + { method: "DELETE", path: "/api/dlc-products/{{0.id}}" }, + { method: "GET", path: "/api/dlc-products/{{0.id}}" }, + ], + tables: ["dlc_products"], + }, + { + module: "dlc", + name: "actions sur les produits de son magasin", + steps: [ + { method: "PUT", path: "/api/dlc-products/2/stock-epuise" }, + { method: "PUT", path: "/api/dlc-products/4/restore-stock" }, + { method: "PUT", path: "/api/dlc-products/2/mark-processed" }, + { method: "PUT", path: "/api/dlc-products/5/unmark-processed" }, + { method: "POST", path: "/api/dlc-products/1/validate" }, + { method: "GET", path: "/api/dlc-products/stats?storeId=1" }, + { method: "DELETE", path: "/api/dlc-products/302" }, + ], + tables: ["dlc_products"], + }, + { + module: "dlc", + name: "actions sur les produits d'un autre magasin", + steps: [ + { method: "PUT", path: "/api/dlc-products/6/stock-epuise" }, + { method: "PUT", path: "/api/dlc-products/6/mark-processed" }, + { method: "POST", path: "/api/dlc-products/304/validate" }, + { method: "PUT", path: "/api/dlc-products/300/stock-epuise" }, + { method: "PUT", path: "/api/dlc-products/300/restore-stock" }, + { method: "PUT", path: "/api/dlc-products/300/mark-processed" }, + { method: "PUT", path: "/api/dlc-products/300/unmark-processed" }, + { method: "POST", path: "/api/dlc-products/300/validate" }, + { method: "PUT", path: "/api/dlc-products/300", body: { notes: "Épinal" } }, + { method: "DELETE", path: "/api/dlc-products/300" }, + { method: "PUT", path: "/api/dlc-products/6", body: { quantity: 2 } }, + { method: "DELETE", path: "/api/dlc-products/6" }, + { method: "PUT", path: "/api/dlc-products/9999/stock-epuise" }, + { method: "PUT", path: "/api/dlc-products/9999/restore-stock" }, + { method: "POST", path: "/api/dlc-products/9999/validate" }, + { method: "PUT", path: "/api/dlc-products/9999", body: { notes: "x" } }, + { method: "DELETE", path: "/api/dlc-products/abc" }, + { method: "PUT", path: "/api/dlc-products/abc/mark-processed" }, + { method: "PUT", path: "/api/dlc-products/abc/unmark-processed" }, + ], + tables: ["dlc_products"], + }, + { + module: "dlc", + name: "créer et modifier des produits DLC avec des corps invalides", + steps: [ + { method: "POST", path: "/api/dlc-products", body: { groupId: 1 } }, + { method: "POST", path: "/api/dlc-products", body: { name: "Texte", supplierId: 1, groupId: "1", expiryDate: "{{today+2}}" } }, + { method: "POST", path: "/api/dlc-products", body: { name: "Épinal", supplierId: 1, groupId: 3, expiryDate: "{{today+2}}" } }, + { method: "POST", path: "/api/dlc-products", body: { name: "Type inconnu", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", dateType: "xyz" } }, + { method: "POST", path: "/api/dlc-products", body: { name: "Horodatage", supplierId: 1, groupId: 1, expiryDate: "{{today+2}}", validatedAt: "{{today}}" } }, + { method: "POST", path: "/api/dlc-products", body: { name: "Minimal", supplierId: 2, groupId: 1, expiryDate: "{{today+9}}", stockEpuise: null, quantity: "7" } }, + { method: "PUT", path: "/api/dlc-products/1", body: { status: "inconnu" } }, + { method: "PUT", path: "/api/dlc-products/1", body: { expiryDate: null } }, + { method: "PUT", path: "/api/dlc-products/1", body: { processedAt: "{{today}}" } }, + { method: "PUT", path: "/api/dlc-products/1", body: { updatedAt: "{{today}}", inconnu: 1 } }, + { method: "PUT", path: "/api/dlc-products/2", body: { groupId: 3, createdBy: "pirate" } }, + ], + tables: ["dlc_products"], + }, + + // ---------- Commandes clients ---------- + { + module: "customer-orders", + name: "créer une commande client, la modifier, prévenir le client, la supprimer", + steps: [ + { + method: "POST", path: "/api/customer-orders", body: { + orderTaker: "Sophie", customerName: "M. Nouveau", customerPhone: "06 12 34 56 78", customerEmail: "nouveau@client.test", + productDesignation: "Barbecue", productReference: "BBQ-1", gencode: "3245678909999", quantity: 1, supplierId: 4, + status: "En attente de Commande", deposit: 30, isPromotionalPrice: false, groupId: 1, notes: "Livraison", + }, + }, + { method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { status: "Disponible", deposit: "45.5", quantity: 2 } }, + { method: "GET", path: "/api/customer-orders/pending-calls" }, + { method: "PATCH", path: "/api/customer-orders/{{0.id}}/mark-called", body: { comment: "Message laissé" } }, + { method: "PUT", path: "/api/customer-orders/{{0.id}}", body: { customerNotified: false, notifiedAt: null, notifiedComment: null } }, + { method: "DELETE", path: "/api/customer-orders/{{0.id}}" }, + ], + tables: ["customer_orders"], + }, + { + module: "customer-orders", + name: "commandes clients de son magasin et d'un autre", + steps: [ + { method: "PATCH", path: "/api/customer-orders/3/mark-called", body: {} }, + { method: "PATCH", path: "/api/customer-orders/301/mark-called", body: { comment: "" } }, + { method: "PATCH", path: "/api/customer-orders/303/mark-called", body: { comment: "Metz" } }, + { method: "PATCH", path: "/api/customer-orders/300/mark-called", body: { comment: "Épinal" } }, + { method: "PUT", path: "/api/customer-orders/4", body: { status: "Retiré" } }, + { method: "PUT", path: "/api/customer-orders/300", body: { status: "Retiré" } }, + { method: "DELETE", path: "/api/customer-orders/5" }, + { method: "DELETE", path: "/api/customer-orders/300" }, + { method: "DELETE", path: "/api/customer-orders/2" }, + { method: "PUT", path: "/api/customer-orders/9999", body: { status: "x" } }, + { method: "PATCH", path: "/api/customer-orders/9999/mark-called", body: {} }, + { method: "DELETE", path: "/api/customer-orders/9999" }, + { method: "PATCH", path: "/api/customer-orders/abc/mark-called", body: {} }, + { method: "DELETE", path: "/api/customer-orders/abc" }, + ], + tables: ["customer_orders"], + }, + { + module: "customer-orders", + name: "créer et modifier des commandes clients avec des corps invalides", + steps: [ + { method: "POST", path: "/api/customer-orders", body: {} }, + { method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Texte", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: "1" } }, + { method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Épinal", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 3 } }, + { method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Horodatage", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 4, groupId: 1, notifiedAt: "{{today}}" } }, + { method: "POST", path: "/api/customer-orders", body: { orderTaker: "Léa", customerName: "Fournisseur absent", customerPhone: "06", productDesignation: "Pelle", gencode: "1", supplierId: 999, groupId: 1 } }, + { method: "PUT", path: "/api/customer-orders/1", body: { quantity: "abc" } }, + { method: "PUT", path: "/api/customer-orders/1", body: { createdAt: "{{today}}" } }, + { method: "PUT", path: "/api/customer-orders/1", body: { deposit: "12,5" } }, + { method: "PUT", path: "/api/customer-orders/1", body: { updatedAt: "2020-01-01", inconnu: 1, notes: ["a", "b"] } }, + { method: "PUT", path: "/api/customer-orders/2", body: {} }, + ], + tables: ["customer_orders"], + }, + + // ---------- SAV ---------- + { + module: "sav", + name: "créer un ticket SAV, le modifier, le commenter, le supprimer", + steps: [ + { + method: "POST", path: "/api/sav/tickets", body: { + supplierId: 3, groupId: 1, productGencode: "3245678901241", productReference: "PS-18", productDesignation: "Perceuse sans fil", + problemType: "defectueux", problemDescription: "Batterie HS", priority: "haute", status: "nouveau", createdBy: "1", + clientName: "M. Laurent", clientPhone: "06 22 22 22 22", + }, + }, + { method: "PATCH", path: "/api/sav/tickets/{{0.id}}", body: { status: "en_cours", priority: "critique", resolutionDescription: "Pièce commandée" } }, + { method: "POST", path: "/api/sav/tickets/{{0.id}}/history", body: { description: "Appel au fournisseur" } }, + { method: "GET", path: "/api/sav/tickets/{{0.id}}" }, + { method: "POST", path: "/api/sav/tickets", body: { supplierId: 4, productGencode: "1", productDesignation: "Second ticket", problemDescription: "Sans magasin choisi" } }, + { method: "GET", path: "/api/sav/stats" }, + { method: "DELETE", path: "/api/sav/tickets/{{0.id}}" }, + { method: "DELETE", path: "/api/sav/tickets/{{4.id}}" }, + ], + tables: ["sav_tickets"], + }, + { + module: "sav", + name: "tickets SAV de son magasin et d'un autre", + steps: [ + { method: "PATCH", path: "/api/sav/tickets/1", body: { status: "resolu" } }, + { method: "PATCH", path: "/api/sav/tickets/3", body: { priority: "haute" } }, + { method: "PATCH", path: "/api/sav/tickets/300", body: { status: "ferme" } }, + { method: "POST", path: "/api/sav/tickets/2/history", body: { description: "Relance" } }, + { method: "POST", path: "/api/sav/tickets/3/history", body: { description: "Metz" } }, + { method: "POST", path: "/api/sav/tickets/300/history", body: {} }, + { method: "DELETE", path: "/api/sav/tickets/302" }, + { method: "DELETE", path: "/api/sav/tickets/300" }, + { method: "PATCH", path: "/api/sav/tickets/9999", body: {} }, + { method: "POST", path: "/api/sav/tickets/9999/history", body: {} }, + { method: "DELETE", path: "/api/sav/tickets/9999" }, + { method: "PATCH", path: "/api/sav/tickets/abc", body: {} }, + { method: "DELETE", path: "/api/sav/tickets/abc" }, + ], + tables: ["sav_tickets"], + }, + { + module: "sav", + name: "créer et modifier des tickets SAV avec des corps invalides", + steps: [ + { method: "POST", path: "/api/sav/tickets", body: {} }, + { method: "POST", path: "/api/sav/tickets", body: { supplierId: null, groupId: "2", productGencode: 12, productDesignation: "x".repeat(501), problemType: "casse", problemDescription: "x", priority: "urgente", status: "ouvert" } }, + { method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 3, productGencode: "1", productDesignation: "Épinal", problemDescription: "Autre magasin" } }, + { method: "POST", path: "/api/sav/tickets", body: { supplierId: 1, groupId: 99, productGencode: "1", productDesignation: "Magasin inconnu", problemDescription: "x" } }, + { method: "POST", path: "/api/sav/tickets", body: { supplierId: 999, groupId: 1, productGencode: "1", productDesignation: "Fournisseur inconnu", problemDescription: "x", ticketNumber: "SAV-PIRATE", createdAt: "2020-01-01" } }, + { method: "PATCH", path: "/api/sav/tickets/1", body: { status: "inconnu", priority: 3 } }, + { method: "PATCH", path: "/api/sav/tickets/1", body: { supplierId: 999 } }, + { method: "PATCH", path: "/api/sav/tickets/2", body: { groupId: 3, createdBy: "pirate", clientName: null, ticketNumber: "SAV-PIRATE" } }, + { method: "PATCH", path: "/api/sav/tickets/2", body: {} }, + ], + tables: ["sav_tickets"], + }, +]; diff --git a/scripts/bench/cases/referentiel.ts b/scripts/bench/cases/referentiel.ts new file mode 100644 index 0000000..024ef3c --- /dev/null +++ b/scripts/bench/cases/referentiel.ts @@ -0,0 +1,49 @@ +import type { ReadCase, WriteScenario } from "../types"; + +export const reads: ReadCase[] = [ + { module: "contacts", path: "/api/contacts" }, + { module: "contacts", path: "/api/contacts?groupId=1" }, + { module: "contacts", path: "/api/contacts?groupId=2" }, + { module: "suppliers", path: "/api/suppliers" }, + { module: "suppliers", path: "/api/suppliers?dlc=true" }, + { module: "groups", path: "/api/groups" }, + { module: "users", path: "/api/users" }, + // Express ignore la casse et une « / » finale + { module: "contacts", path: "/api/Contacts?groupId=1" }, + { module: "suppliers", path: "/api/suppliers/" }, +]; + +export const writes: WriteScenario[] = [ + { + module: "contacts", + name: "créer, modifier, supprimer un contact", + steps: [ + { method: "POST", path: "/api/contacts", body: { groupId: 1, name: "Nouveau contact", company: "Société", phone: "06 99 99 99 99", email: "nouveau@banc.test", notes: "Note" } }, + { method: "PUT", path: "/api/contacts/{{0.id}}", body: { name: "Contact modifié", phone: "06 88 88 88 88" } }, + { method: "PUT", path: "/api/contacts/3", body: { name: "Autre magasin" } }, + { method: "DELETE", path: "/api/contacts/{{0.id}}" }, + { method: "DELETE", path: "/api/contacts/3" }, + ], + tables: ["contacts"], + }, + { + module: "contacts", + name: "créer un contact invalide", + steps: [ + { method: "POST", path: "/api/contacts", body: { groupId: 1 } }, + { method: "POST", path: "/api/contacts", body: { groupId: 2, name: "Metz" } }, + ], + tables: ["contacts"], + }, + { + module: "suppliers", + name: "créer, modifier, supprimer un fournisseur", + steps: [ + { method: "POST", path: "/api/suppliers", body: { name: "Nouveau fournisseur", contact: "M. Neuf", phone: "01 23 45 67 89", hasDlc: true, email: "neuf@banc.test", codefou: "F099" } }, + { method: "PUT", path: "/api/suppliers/{{0.id}}", body: { name: "Fournisseur renommé", automaticReconciliation: true } }, + { method: "DELETE", path: "/api/suppliers/{{0.id}}" }, + { method: "POST", path: "/api/suppliers", body: { contact: "sans nom" } }, + ], + tables: ["suppliers"], + }, +]; diff --git a/scripts/bench/cases/socle.ts b/scripts/bench/cases/socle.ts new file mode 100644 index 0000000..a7e41e5 --- /dev/null +++ b/scripts/bench/cases/socle.ts @@ -0,0 +1,66 @@ +import type { ReadCase, WriteScenario } from "../types"; + +export const reads: ReadCase[] = [ + { module: "socle", path: "/", accounts: ["anonyme"] }, + { module: "socle", path: "/deliveries", accounts: ["anonyme", "manager"] }, + { module: "socle", path: "/favicon.ico", accounts: ["anonyme"] }, + { module: "socle", path: "/api/health", accounts: ["anonyme"] }, + { module: "socle", path: "/api/adresse-inconnue", accounts: ["anonyme", "admin"] }, + { module: "auth", path: "/api/user" }, + { module: "auth", path: "/api/default-credentials-check" }, +]; + +export const writes: WriteScenario[] = [ + { + module: "auth", + name: "déconnexion puis lecture de l'utilisateur", + steps: [ + { method: "POST", path: "/api/logout" }, + { method: "GET", path: "/api/user" }, + ], + tables: [], + }, + { + module: "auth", + name: "connexion refusée", + accounts: ["anonyme"], + steps: [ + { method: "POST", path: "/api/login", body: { username: "manager", password: "mauvais" } }, + { method: "POST", path: "/api/login", body: { username: "inconnu", password: "x" } }, + { method: "POST", path: "/api/login", body: {} }, + ], + tables: ["users"], + }, + // ---------- Requêtes d'écriture d'un autre site (décision R4) ---------- + { + module: "socle", + name: "écriture d'un autre site refusée", + accounts: ["manager"], + steps: [ + { + method: "POST", + path: "/api/logout", + headers: { origin: "https://site-tiers.example", "sec-fetch-site": "cross-site" }, + }, + { method: "POST", path: "/api/logout", headers: { origin: "null" } }, + // La session est toujours ouverte côté Rust + { method: "GET", path: "/api/user" }, + ], + tables: [], + deviation: { + fields: ["status", "headers", "body"], + reason: + "R4 : une requête /api qui n'est pas une lecture est refusée (403) quand le navigateur indique qu'elle vient d'un autre site (Sec-Fetch-Site, sinon Origin)", + }, + }, + { + module: "socle", + name: "écriture du même site acceptée", + accounts: ["manager"], + steps: [ + { method: "POST", path: "/api/logout", headers: { "sec-fetch-site": "same-origin" } }, + { method: "GET", path: "/api/user" }, + ], + tables: [], + }, +]; diff --git a/scripts/bench/compare.ts b/scripts/bench/compare.ts new file mode 100644 index 0000000..5936f84 --- /dev/null +++ b/scripts/bench/compare.ts @@ -0,0 +1,438 @@ +// Banc de comparaison : rejoue les mêmes requêtes sur deux serveurs et compare +// statuts, corps JSON, en-têtes utiles, état de la base et appels aux services +// externes (docs/PLAN-MIGRATION-RUST.md, « Vérification »). +// +// npx tsx scripts/bench/compare.ts Node de référence contre Rust +// npx tsx scripts/bench/compare.ts --mode node Node contre Node (le banc est-il stable ?) +// npx tsx scripts/bench/compare.ts --only dlc,tasks seulement ces modules +// +// Prérequis : PostgreSQL local, base modèle bench_seed (voir README.md), serveur Node +// de référence construit (reference-node.sh), binaire Rust construit (server-rs/target/release). +import { spawn, execFileSync, type ChildProcess } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import pg from "pg"; +import { fileURLToPath } from "url"; +import { BENCH_ACCOUNTS, BENCH_API_KEY, BENCH_ENV, DB_PREFIX, LEFT_PORT, RIGHT_PORT, STUB_URL, TEMPLATE_DB } from "./config"; +import { startStub } from "./stub"; +import { READS, WRITES } from "./cases"; +import { ALL_ACCOUNTS, LOGGED_ACCOUNTS, type AccountKey, type Deviation, type ReadCase, type Step, type WriteScenario } from "./types"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +// Fichiers des sauvegardes déclarées par les seeds (database_backups), copiés +// dans le dossier de sauvegardes de chaque serveur +const SEED_BACKUPS_DIR = path.join(ROOT, "scripts/bench/seeds/sauvegardes"); +const PG_ADMIN_URL = process.env.BENCH_PG_URL || "postgres://logiflow_admin:bench@localhost:5432"; +// Chemin relatif : depuis cette copie de travail (les serveurs sont lancés depuis BENCH_NODE_DIR) +const RUST_BIN = path.resolve(ROOT, process.env.RUST_BIN || "server-rs/target/release/logiflow"); +// Serveur Node de référence construit par reference-node.sh (dist/ et node_modules/) +const NODE_DIR = process.env.BENCH_NODE_DIR || path.join(ROOT, ".bench-reference"); +const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "logiflow-banc-")); + +const args = process.argv.slice(2); +const mode = args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "rust"; +const only = args.includes("--only") ? args[args.indexOf("--only") + 1].split(",") : null; +const keep = args.includes("--keep"); + +// ---------- Gabarits ---------- + +function isoDay(offset: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +function fill(template: string, results: unknown[]): string { + return template.replace(/\{\{([^}]+)\}\}/g, (_, expr: string) => { + const day = expr.match(/^today([+-]\d+)?$/); + if (day) return isoDay(Number(day[1] || 0)); + const year = expr.match(/^year([+-]\d+)?$/); + if (year) return String(new Date().getUTCFullYear() + Number(year[1] || 0)); + if (expr === "month") return String(new Date().getUTCMonth() + 1); + const ref = expr.match(/^(\d+)\.(.+)$/); + if (ref) { + const value = ref[2].split(".").reduce((acc, key) => acc?.[key], results[Number(ref[1])]); + return String(value); + } + throw new Error(`Gabarit inconnu : {{${expr}}}`); + }); +} + +function fillDeep(value: unknown, results: unknown[]): unknown { + if (typeof value === "string") { + const whole = value.match(/^\{\{(\d+\.[^}]+)\}\}$/); + if (whole) { + // Valeur entière ({{0.id}}) : garder son type (nombre, booléen…) + const [index, ...keys] = whole[1].split("."); + return keys.reduce((acc, key) => acc?.[key], results[Number(index)]); + } + return fill(value, results); + } + if (Array.isArray(value)) return value.map((item) => fillDeep(item, results)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillDeep(item, results)])); + } + return value; +} + +// ---------- Serveurs ---------- + +interface Target { + name: string; + baseUrl: string; + dbName: string; + processes: ChildProcess[]; +} + +function dbUrl(name: string) { + return `${PG_ADMIN_URL}/${name}`; +} + +function psql(database: string, sql: string) { + execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", dbUrl(database), "-c", sql], { stdio: ["ignore", "ignore", "inherit"] }); +} + +function startProcess(label: string, command: string, commandArgs: string[], env: Record): ChildProcess { + const log = fs.openSync(path.join(WORK_DIR, `${label}.log`), "a"); + const child = spawn(command, commandArgs, { cwd: NODE_DIR, env: { ...process.env, ...env }, stdio: ["ignore", log, log] }); + child.on("exit", (code) => { + if (code !== null && code !== 0) console.error(` ${label} s'est arrêté (code ${code}), voir ${WORK_DIR}/${label}.log`); + }); + return child; +} + +async function waitHealthy(url: string, label: string) { + for (let i = 0; i < 120; i++) { + try { + const res = await fetch(`${url}/api/health`); + if (res.ok) return; + } catch { /* pas encore prêt */ } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`${label} ne répond pas, voir ${WORK_DIR}/${label}.log`); +} + +// Dossier de sauvegardes propre à chaque serveur, avec les fichiers des données du banc +function backupDir(port: number): string { + const dir = path.join(WORK_DIR, `sauvegardes-${port}`); + fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(SEED_BACKUPS_DIR)) fs.cpSync(SEED_BACKUPS_DIR, dir, { recursive: true }); + return dir; +} + +function nodeEnv(port: number, database: string): Record { + return { ...BENCH_ENV, NODE_ENV: "production", PORT: String(port), DATABASE_URL: dbUrl(database), BACKUP_DIR: backupDir(port) }; +} + +async function startTarget(name: string, kind: "node" | "rust", port: number): Promise { + const dbName = `${DB_PREFIX}_${name}`; + psql("postgres", `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`); + psql("postgres", `CREATE DATABASE ${dbName} TEMPLATE ${TEMPLATE_DB}`); + const processes: ChildProcess[] = []; + if (kind === "node") { + processes.push(startProcess(`${name}-node`, "node", ["dist/index.js"], nodeEnv(port, dbName))); + } else { + processes.push(startProcess(`${name}-rust`, RUST_BIN, [], { + ...BENCH_ENV, + PORT: String(port), + DATABASE_URL: dbUrl(dbName), + STATIC_DIR: path.join(NODE_DIR, "dist/public"), + BACKUP_DIR: backupDir(port), + RUST_LOG: "warn", + })); + } + const target = { name, baseUrl: `http://127.0.0.1:${port}`, dbName, processes }; + await waitHealthy(target.baseUrl, name); + return target; +} + +function stopTarget(target: Target) { + for (const child of target.processes) child.kill("SIGTERM"); +} + +// Remise à zéro de la base d'une cible : vider toutes les tables, recharger les données du modèle +let seedDump: string | null = null; +function resetDatabase(database: string) { + if (!seedDump) { + seedDump = path.join(WORK_DIR, "donnees.sql"); + execFileSync("pg_dump", ["--data-only", "--disable-triggers", "-f", seedDump, dbUrl(TEMPLATE_DB)]); + } + const tables = execFileSync("psql", ["-At", dbUrl(database), "-c", + "SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'"]).toString().trim(); + psql(database, `TRUNCATE ${tables} RESTART IDENTITY CASCADE`); + execFileSync("psql", ["-q", "-v", "ON_ERROR_STOP=1", "-f", seedDump, dbUrl(database)], { stdio: ["ignore", "ignore", "inherit"] }); +} + +// ---------- Requêtes ---------- + +interface Recorded { + status: number; + headers: Record; + body: unknown; +} + +const KEPT_HEADERS = ["content-type", "cache-control", "location", "content-disposition"]; + +function describeCookie(header: string): string { + // Nom et attributs du cookie, sans sa valeur (propre à chaque session) + const [pair, ...attributes] = header.split(";").map((part) => part.trim()); + const name = pair.split("=")[0]; + const kept = attributes.filter((a) => !/^expires=/i.test(a)).map((a) => a.toLowerCase()).sort(); + return [name, ...kept].join("; "); +} + +async function login(baseUrl: string, account: AccountKey): Promise> { + if (account === "anonyme") return {}; + if (account === "api") return { "x-api-key": BENCH_API_KEY }; + const known = BENCH_ACCOUNTS.find((a) => a.key === account)!; + const res = await fetch(`${baseUrl}/api/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: known.username, password: known.password }), + }); + if (!res.ok) throw new Error(`Connexion impossible pour ${account} sur ${baseUrl} : ${res.status}`); + const cookie = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); + return { cookie }; +} + +function buildBody(step: Step, results: unknown[]): { body?: BodyInit; contentType?: string } { + if (step.body === undefined && !step.files) return {}; + const data = fillDeep(step.body ?? {}, results) as Record; + if (step.bodyType === "form") { + return { body: new URLSearchParams(data as Record).toString(), contentType: "application/x-www-form-urlencoded" }; + } + if (step.bodyType === "multipart") { + const form = new FormData(); + for (const [key, value] of Object.entries(data)) form.append(key, String(value)); + for (const file of step.files ?? []) { + const content = Buffer.alloc(file.size, 0x25); // contenu fixe : « %%%% » + form.append(file.field, new Blob([content], { type: file.contentType }), file.filename); + } + return { body: form }; + } + return { body: JSON.stringify(data), contentType: "application/json" }; +} + +async function send(baseUrl: string, step: Step, auth: Record, results: unknown[]): Promise { + const { body, contentType } = buildBody(step, results); + const headers: Record = { ...auth, ...(step.headers ?? {}) }; + if (contentType) headers["content-type"] = contentType; + const res = await fetch(`${baseUrl}${fill(step.path, results)}`, { method: step.method, headers, body, redirect: "manual" }); + const kept: Record = {}; + for (const name of KEPT_HEADERS) { + const value = res.headers.get(name); + if (value !== null) kept[name] = value.replace(/;\s*/g, "; ").toLowerCase(); + } + const cookies = res.headers.getSetCookie(); + if (cookies.length) kept["set-cookie"] = cookies.map(describeCookie).join(" | "); + const text = await res.text(); + let parsed: unknown = text; + if ((res.headers.get("content-type") || "").includes("json")) { + try { parsed = JSON.parse(text); } catch { /* texte brut */ } + } else if (text.length > 2000) { + // Gros contenu non JSON (HTML, fichiers) : taille et début suffisent + parsed = { taille: Buffer.byteLength(text), debut: text.slice(0, 200) }; + } + return { status: res.status, headers: kept, body: parsed }; +} + +async function stubCalls(reset = false): Promise { + const res = await fetch(`${STUB_URL}/__calls`, { method: reset ? "DELETE" : "GET" }); + return res.json() as Promise; +} + +async function snapshotTables(database: string, tables: string[]) { + const client = new pg.Client({ connectionString: dbUrl(database) }); + await client.connect(); + const out: Record = {}; + for (const table of tables) { + const { rows } = await client.query(`SELECT * FROM ${table} ORDER BY 1, 2`); + out[table] = rows; + } + await client.end(); + return out; +} + +// ---------- Normalisation ---------- + +const ISO_DATETIME = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g; + +// drop : champs d'un écart voulu, retirés des deux côtés +function normalize(value: unknown, window: [number, number], ignore: string[] = [], drop: string[] = []): unknown { + if (value instanceof Date) return normalize(value.toISOString(), window, ignore, drop); + if (typeof value === "string") { + return value.replace(ISO_DATETIME, (match) => { + const time = Date.parse(match); + return time >= window[0] && time <= window[1] ? "" : match; + }); + } + if (Array.isArray(value)) return value.map((item) => normalize(item, window, ignore, drop)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).filter(([key]) => !drop.includes(key)).map(([key, item]) => + [key, ignore.includes(key) ? "" : normalize(item, window, ignore, drop)])); + } + return value; +} + +// ---------- Passage ---------- + +interface Entry { + key: string; // module | cas | compte + module: string; + data: unknown; +} + +async function runTarget(target: Target): Promise { + const entries: Entry[] = []; + const startedAt = Date.now() - 60_000; + const reads = READS.filter((c) => !only || only.includes(c.module)); + const writes = WRITES.filter((c) => !only || only.includes(c.module)); + + // Lectures : une base fraîche, une connexion par compte + resetDatabase(target.dbName); + const sessions = new Map>(); + for (const account of [...LOGGED_ACCOUNTS, "anonyme", "api"] as AccountKey[]) { + sessions.set(account, await login(target.baseUrl, account)); + } + for (const readCase of reads) { + for (const account of readCase.accounts ?? ALL_ACCOUNTS) { + await stubCalls(true); + const recorded = await send(target.baseUrl, { method: "GET", path: readCase.path }, sessions.get(account)!, []); + const calls = await stubCalls(); + entries.push({ + key: `${readCase.module} | GET ${readCase.path} | ${account}`, + module: readCase.module, + data: { reponse: recorded, appels: calls, ignore: readCase.ignore, deviation: readCase.deviation }, + }); + } + } + + // Écritures : base remise à zéro pour chaque compte + for (const scenario of writes) { + for (const account of scenario.accounts ?? LOGGED_ACCOUNTS) { + resetDatabase(target.dbName); + await stubCalls(true); + const auth = await login(target.baseUrl, account); + const results: unknown[] = []; + const responses: Recorded[] = []; + for (const step of scenario.steps) { + const recorded = await send(target.baseUrl, step, auth, results); + responses.push(recorded); + results.push(recorded.body); + } + entries.push({ + key: `${scenario.module} | ${scenario.name} | ${account}`, + module: scenario.module, + data: { + reponses: responses, + tables: await snapshotTables(target.dbName, scenario.tables), + appels: await stubCalls(), + ignore: scenario.ignore, + deviation: scenario.deviation, + }, + }); + } + } + + const endedAt = Date.now() + 60_000; + return entries.map((entry) => { + const { ignore, deviation, ...data } = entry.data as { ignore?: string[]; deviation?: Deviation }; + return { ...entry, data: normalize(data, [startedAt, endedAt], ignore, deviation?.fields) }; + }); +} + +// ---------- Comparaison ---------- + +function differences(a: unknown, b: unknown, where = ""): string[] { + if (JSON.stringify(a) === JSON.stringify(b)) return []; + if (Array.isArray(a) && Array.isArray(b)) { + const out: string[] = []; + if (a.length !== b.length) out.push(`${where} : ${a.length} éléments contre ${b.length}`); + for (let i = 0; i < Math.min(a.length, b.length); i++) out.push(...differences(a[i], b[i], `${where}[${i}]`)); + return out; + } + if (a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { + const out: string[] = []; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const key of keys) { + if (!(key in (a as object))) out.push(`${where}.${key} : absent à gauche, ${JSON.stringify((b as any)[key])?.slice(0, 120)} à droite`); + else if (!(key in (b as object))) out.push(`${where}.${key} : ${JSON.stringify((a as any)[key])?.slice(0, 120)} à gauche, absent à droite`); + else out.push(...differences((a as any)[key], (b as any)[key], `${where}.${key}`)); + } + return out; + } + return [`${where} : ${JSON.stringify(a)?.slice(0, 160)} contre ${JSON.stringify(b)?.slice(0, 160)}`]; +} + +async function main() { + // Décision R5 : Rust compte « aujourd'hui » à Paris, Node en UTC. Les deux + // dates diffèrent en fin de soirée (après 22 h ou 23 h UTC) : les écarts de + // date relevés alors sont attendus. + const parisDay = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" }).format(new Date()); + if (mode !== "node" && parisDay !== isoDay(0)) { + console.log(`Attention : il est déjà le ${parisDay} à Paris (${isoDay(0)} en UTC) ; écarts de date attendus (décision R5). Relancer après minuit UTC.`); + } + console.log(`Banc : ${mode === "node" ? "Node contre Node" : "Node de référence contre Rust"}${only ? ` (modules : ${only.join(", ")})` : ""}`); + console.log(`Journaux : ${WORK_DIR}`); + const stopStub = await startStub(); + const targets: Target[] = []; + try { + const left = await startTarget("gauche", "node", LEFT_PORT); + targets.push(left); + const right = await startTarget("droite", mode === "node" ? "node" : "rust", RIGHT_PORT); + targets.push(right); + + const leftEntries = await runTarget(left); + const rightEntries = await runTarget(right); + + const report: { key: string; differences: string[] }[] = []; + const byModule = new Map(); + for (const entry of leftEntries) { + const other = rightEntries.find((e) => e.key === entry.key); + const diffs = differences(entry.data, other?.data); + const stats = byModule.get(entry.module) ?? { total: 0, ko: 0 }; + stats.total++; + if (diffs.length) { stats.ko++; report.push({ key: entry.key, differences: diffs }); } + byModule.set(entry.module, stats); + } + + fs.writeFileSync(path.join(WORK_DIR, "gauche.json"), JSON.stringify(leftEntries, null, 2)); + fs.writeFileSync(path.join(WORK_DIR, "droite.json"), JSON.stringify(rightEntries, null, 2)); + fs.writeFileSync(path.join(WORK_DIR, "ecarts.json"), JSON.stringify(report, null, 2)); + + for (const item of report.slice(0, 40)) { + console.log(`\n✗ ${item.key}`); + for (const line of item.differences.slice(0, 8)) console.log(` ${line}`); + if (item.differences.length > 8) console.log(` … ${item.differences.length - 8} autre(s)`); + } + if (report.length > 40) console.log(`\n… ${report.length - 40} autre(s) cas en écart, voir ${WORK_DIR}/ecarts.json`); + + console.log("\nModule cas écarts"); + for (const [module, stats] of [...byModule].sort()) { + console.log(`${module.padEnd(22)} ${String(stats.total).padStart(4)} ${stats.ko}`); + } + const deviations = new Set( + [...READS, ...WRITES] + .filter((c) => c.deviation && (!only || only.includes(c.module))) + .map((c) => `${c.module} : ${c.deviation!.fields.join(", ")} — ${c.deviation!.reason}`), + ); + if (deviations.size) { + console.log("\nÉcarts voulus (champs retirés de la comparaison) :"); + for (const line of deviations) console.log(` ${line}`); + } + const total = leftEntries.length; + console.log(`\n${total} cas comparés, ${report.length} en écart.`); + process.exitCode = report.length ? 1 : 0; + } finally { + targets.forEach(stopTarget); + await stopStub(); + if (!keep) { + for (const target of targets) { + try { psql("postgres", `DROP DATABASE IF EXISTS ${target.dbName} WITH (FORCE)`); } catch { /* déjà supprimée */ } + } + } + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/bench/config.ts b/scripts/bench/config.ts new file mode 100644 index 0000000..3246ab0 --- /dev/null +++ b/scripts/bench/config.ts @@ -0,0 +1,49 @@ +// Réglages communs du banc de comparaison Node / Rust. + +export interface BenchAccount { + key: string; + id: string; + username: string; + password: string; + role: "admin" | "directeur" | "manager" | "employee"; + firstName: string; + lastName: string; + groupIds: number[]; +} + +// Un compte par profil à tester (règles communes du plan du webUI) +export const BENCH_ACCOUNTS: BenchAccount[] = [ + { key: "admin", id: "admin_local", username: "admin", password: "banc-admin", role: "admin", firstName: "Alice", lastName: "Admin", groupIds: [] }, + { key: "directeur", id: "bench_directeur", username: "directeur", password: "banc-directeur", role: "directeur", firstName: "Denis", lastName: "Directeur", groupIds: [1, 2] }, + { key: "manager", id: "bench_manager", username: "manager", password: "banc-manager", role: "manager", firstName: "Manon", lastName: "Manager", groupIds: [1] }, + { key: "employe", id: "bench_employee", username: "employe", password: "banc-employe", role: "employee", firstName: "Émile", lastName: "Employé", groupIds: [1] }, + { key: "sans-magasin", id: "bench_sans_magasin", username: "sansmagasin", password: "banc-sans-magasin", role: "employee", firstName: "Sacha", lastName: "Sans", groupIds: [] }, +]; + +// Clé de l'API externe enregistrée par seed.ts (empreinte seulement) +export const BENCH_API_KEY = "lf_banc_cle_de_test_0123456789"; + +// Plusieurs bancs peuvent tourner en parallèle (un par copie de travail) : +// BENCH_ID sépare les bases, BENCH_PORT_BASE les ports. +export const BENCH_ID = process.env.BENCH_ID || ""; +const SUFFIX = BENCH_ID ? `_${BENCH_ID}` : ""; +const PORT_BASE = Number(process.env.BENCH_PORT_BASE || 3200); + +export const TEMPLATE_DB = process.env.BENCH_TEMPLATE_DB || `bench_seed${SUFFIX}`; +export const DB_PREFIX = `bench${SUFFIX}`; +export const LEFT_PORT = PORT_BASE + 1; +export const RIGHT_PORT = PORT_BASE + 3; + +// Services externes simulés (NocoDB, webhooks, SMTP) : voir stub.ts. +// Les magasins de seed.ts pointent vers ces ports. +export const STUB_PORT = PORT_BASE + 90; +export const STUB_URL = `http://127.0.0.1:${STUB_PORT}`; +export const STUB_SMTP_PORT = PORT_BASE + 91; + +// Environnement commun aux deux serveurs +export const BENCH_ENV = { + SESSION_SECRET: "banc-secret-de-session", + ENCRYPTION_KEY: "banc-cle-de-chiffrement", + EXTERNAL_API_KEYS: "", + TZ: "UTC", +}; diff --git a/scripts/bench/legacy.ts b/scripts/bench/legacy.ts new file mode 100644 index 0000000..d9910fb --- /dev/null +++ b/scripts/bench/legacy.ts @@ -0,0 +1,34 @@ +// Fonctions reprises de l'ancien serveur Node, pour écrire les données du banc +// comme il les aurait écrites (le serveur Node a été retiré du dépôt). +import crypto from "node:crypto"; + +const PREFIX = "enc:v1:"; + +/** Ancien server/crypto.ts : AES-256-GCM, clé = SHA-256(ENCRYPTION_KEY ou SESSION_SECRET) */ +export function encryptSecret(value: string): string { + const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET || "logiflow-fallback-secret-key"; + const key = crypto.createHash("sha256").update(secret).digest(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`; +} + +/** + * Ancien weatherService.getPreviousYearDate, serveur en UTC : même jour de la + * semaine, même rang dans le mois, l'année précédente + */ +export function previousYearDate(date: Date): string { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const dayOfWeek = date.getUTCDay(); + const firstDayOfMonth = new Date(Date.UTC(year - 1, month, 1)).getUTCDay(); + const daysToAdd = (dayOfWeek - firstDayOfMonth + 7) % 7; + const weekOfMonth = Math.ceil(date.getUTCDate() / 7); + const target = new Date(Date.UTC(year - 1, month, 1 + daysToAdd + (weekOfMonth - 1) * 7)); + if (target.getUTCMonth() !== month) { + target.setUTCDate(target.getUTCDate() - 7); + } + return target.toISOString().split("T")[0]; +} diff --git a/scripts/bench/reference-node.sh b/scripts/bench/reference-node.sh new file mode 100755 index 0000000..2359634 --- /dev/null +++ b/scripts/bench/reference-node.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Construit le serveur Node de référence du banc, depuis le dernier commit qui le +# contient (REFERENCE_NODE_COMMIT), dans une copie de travail à part. Le serveur +# Node ayant quitté le dépôt, c'est contre lui que Rust est comparé. +# Usage : scripts/bench/reference-node.sh [dossier] (défaut : .bench-reference) +# Puis : export BENCH_NODE_DIR= +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +commit="$(tr -d '[:space:]' < "$root/scripts/bench/REFERENCE_NODE_COMMIT")" +dir="${1:-$root/.bench-reference}" + +if [ ! -d "$dir/.git" ] && [ ! -f "$dir/.git" ]; then + git -C "$root" worktree add --detach "$dir" "$commit" > /dev/null +else + git -C "$dir" checkout --detach -q "$commit" +fi +cd "$dir" +if [ ! -f dist/index.js ] || [ "$(cat dist/.commit 2>/dev/null)" != "$commit" ]; then + npm ci --no-audit --no-fund > /dev/null + scripts/bench/build-node.sh > /dev/null + echo "$commit" > dist/.commit +fi +echo "$dir" diff --git a/scripts/bench/schema-check.ts b/scripts/bench/schema-check.ts new file mode 100644 index 0000000..60ef9f7 --- /dev/null +++ b/scripts/bench/schema-check.ts @@ -0,0 +1,47 @@ +// Compare les colonnes déclarées dans shared/schema.ts à celles d'une base réelle. +// Usage : DATABASE_URL=postgres://… npx tsx scripts/bench/schema-check.ts +import pg from "pg"; +import * as schema from "../../shared/schema"; +import { getTableConfig, PgTable } from "drizzle-orm/pg-core"; + +async function main() { + const declared = new Map>(); + for (const value of Object.values(schema)) { + if (!(value instanceof PgTable)) continue; + const config = getTableConfig(value); + declared.set(config.name, new Map(config.columns.map((c) => [c.name, `${c.getSQLType()}${c.notNull ? " not null" : ""}`]))); + } + + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + const { rows } = await client.query( + `SELECT table_name, column_name, data_type, is_nullable + FROM information_schema.columns WHERE table_schema = 'public'`, + ); + await client.end(); + + const actual = new Map>(); + for (const row of rows) { + if (!actual.has(row.table_name)) actual.set(row.table_name, new Map()); + actual.get(row.table_name)!.set(row.column_name, `${row.data_type}${row.is_nullable === "NO" ? " not null" : ""}`); + } + + let problems = 0; + for (const [table, columns] of declared) { + const real = actual.get(table); + if (!real) { console.log(`table absente : ${table}`); problems++; continue; } + for (const [column, type] of columns) { + if (!real.has(column)) { console.log(`colonne absente : ${table}.${column} (${type})`); problems++; } + } + for (const [column, type] of real) { + if (!columns.has(column)) console.log(`colonne en plus dans la base : ${table}.${column} (${type})`); + } + } + for (const table of actual.keys()) { + if (!declared.has(table)) console.log(`table en plus dans la base : ${table}`); + } + console.log(problems === 0 ? "Schéma conforme à shared/schema.ts" : `${problems} écart(s) bloquant(s)`); + process.exit(problems === 0 ? 0 : 1); +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/bench/schema.sh b/scripts/bench/schema.sh new file mode 100755 index 0000000..e8fbf90 --- /dev/null +++ b/scripts/bench/schema.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Construit, dans une base vide, le schéma que le serveur Node attend : +# init.sql, puis les migrations appliquées à la main en production, puis celles +# de server/migrations.ts (jamais appelé au démarrage). Le reste (index, colonnes +# des magasins, tables récentes) est ajouté par server/migrations.production.ts +# au premier démarrage de Node. +# Usage : scripts/bench/schema.sh postgres://utilisateur:motdepasse@hôte/base +set -euo pipefail +url="$1" +root="$(cd "$(dirname "$0")/../.." && pwd)" +run() { psql -q -v ON_ERROR_STOP=1 "$url" "$@"; } + +run -f "$root/init.sql" +run -f "$root/migrations/20260515_add_contacts_and_supplier_email.sql" +run -f "$root/migrations/002_add_dlc_stock_epuise.sql" +run <<'SQL' +ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP; +ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT; +ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR; +SQL diff --git a/scripts/bench/seed.ts b/scripts/bench/seed.ts new file mode 100644 index 0000000..819972d --- /dev/null +++ b/scripts/bench/seed.ts @@ -0,0 +1,211 @@ +// Données du banc : 3 magasins, 5 comptes (un par profil à tester) et quelques +// lignes dans chaque table, datées par rapport au jour du passage pour que les +// alertes (DLC, retards, échéances) aient toujours quelque chose à montrer. +// Usage : DATABASE_URL=… SESSION_SECRET=… npx tsx scripts/bench/seed.ts +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import pg from "pg"; +import { scryptSync, createHash } from "crypto"; +import { encryptSecret } from "./legacy"; +import { BENCH_ACCOUNTS, BENCH_API_KEY, STUB_URL, STUB_SMTP_PORT } from "./config"; + +// Même format que l'ancien server/localAuth.ts : empreinte_hex.sel_hex, le sel hexadécimal +// servant tel quel de texte. Sel fixe pour un banc reproductible. +function hashPassword(password: string, salt: string): string { + return `${scryptSync(password, salt, 64).toString("hex")}.${salt}`; +} + +// Petit PNG 1×1 : un magasin a un logo, comme en production +const LOGO = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +async function main() { + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + const q = (text: string, values: unknown[] = []) => client.query(text, values); + + await q("BEGIN"); + + await q(`INSERT INTO nocodb_config (id, name, base_url, project_id, api_token, description, is_active, created_by, created_at, updated_at) + VALUES (1, 'NocoDB banc', $1, 'pbench', $2, 'Service simulé du banc', true, 'admin_local', CURRENT_DATE - 30 + time '08:00', CURRENT_DATE - 30 + time '08:00')`, + [`${STUB_URL}/nocodb`, encryptSecret("nocodb-token-banc")]); + + await q(`INSERT INTO groups (id, name, color, nocodb_config_id, nocodb_table_name, nocodb_table_id, invoice_column_name, nocodb_bl_column_name, + nocodb_amount_column_name, nocodb_invoice_amount_ttc_column_name, nocodb_supplier_column_name, nocodb_due_date_column_name, + webhook_url, address, phone, logo, smtp_enabled, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, + smtp_sender_email, smtp_sender_name, created_at, updated_at) VALUES + (1, 'Magasin Nancy', '#1976D2', 1, 'factures', 'tnancy', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance', + $1, '12 rue des Dominicains, 54000 Nancy', '03 83 00 00 01', $2, true, '127.0.0.1', $3, false, 'nancy@banc.test', $4, + 'nancy@banc.test', 'Magasin Nancy', CURRENT_DATE - 400 + time '08:00', CURRENT_DATE - 10 + time '08:00'), + (2, 'Magasin Metz', '#388E3C', 1, 'factures', 'tmetz', 'RefFacture', 'NumBL', 'MontantHT', 'MontantTTC', 'Fournisseur', 'Echeance', + NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 300 + time '08:00', CURRENT_DATE - 300 + time '08:00'), + (3, 'Magasin Épinal', '#F57C00', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, false, NULL, NULL, false, NULL, NULL, NULL, NULL, CURRENT_DATE - 200 + time '08:00', CURRENT_DATE - 200 + time '08:00')`, + [`${STUB_URL}/webhook/avoirs`, LOGO, STUB_SMTP_PORT, encryptSecret("smtp-secret-banc")]); + + for (const account of BENCH_ACCOUNTS) { + await q(`INSERT INTO users (id, username, email, first_name, last_name, password, role, password_changed, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`, + [account.id, account.username, `${account.username}@banc.test`, account.firstName, account.lastName, + hashPassword(account.password, createHash("md5").update(account.username).digest("hex")), account.role]); + for (const groupId of account.groupIds) { + await q(`INSERT INTO user_groups (user_id, group_id, created_at) VALUES ($1, $2, CURRENT_DATE - 100 + time '08:00')`, [account.id, groupId]); + } + } + + await q(`INSERT INTO suppliers (id, name, contact, phone, has_dlc, payment_method, automatic_reconciliation, requires_control, email, codefou, created_at, updated_at) VALUES + (1, 'Lactalis', 'M. Durand', '01 00 00 00 01', true, 'virement', false, false, 'commandes@lactalis.test', 'F001', CURRENT_DATE - 500 + time '08:00', CURRENT_DATE - 500 + time '08:00'), + (2, 'Bonduelle', 'Mme Petit', '01 00 00 00 02', true, 'prelevement', true, false, 'factures@bonduelle.test', 'F002', CURRENT_DATE - 490 + time '08:00', CURRENT_DATE - 490 + time '08:00'), + (3, 'Brico Pro', NULL, NULL, false, 'cheque', false, true, NULL, NULL, CURRENT_DATE - 480 + time '08:00', CURRENT_DATE - 480 + time '08:00'), + (4, 'Jardin & Co', 'M. Martin', '01 00 00 00 04', false, NULL, false, false, 'contact@jardin.test', 'F004', CURRENT_DATE - 470 + time '08:00', CURRENT_DATE - 470 + time '08:00')`); + + await q(`INSERT INTO contacts (id, group_id, name, company, role, phone, email, notes, created_at, updated_at) VALUES + (1, 1, 'Paul Lefort', 'Lactalis', 'Commercial', '06 00 00 00 01', 'paul@lactalis.test', 'Passe le mardi', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'), + (2, 1, 'Agence intérim', NULL, NULL, '03 83 00 00 99', NULL, NULL, CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 40 + time '09:00'), + (3, 2, 'Julie Roux', 'Bonduelle', 'Livraisons', '06 00 00 00 03', NULL, NULL, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`); + + await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES + (1, 1, 1, CURRENT_DATE - 20, 10, 'palettes', 'delivered', 'Commande mensuelle', 'admin_local', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00'), + (2, 2, 1, CURRENT_DATE - 2, 5, 'colis', 'planned', NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '10:00'), + (3, 3, 1, CURRENT_DATE + 3, 2, 'palettes', 'pending', 'Urgent', 'bench_manager', CURRENT_DATE - 1 + time '10:00', CURRENT_DATE - 1 + time '10:00'), + (4, 1, 2, CURRENT_DATE, 8, 'colis', 'pending', NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'), + (5, 4, 2, CURRENT_DATE - 40, 3, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 45 + time '10:00', CURRENT_DATE - 40 + time '10:00'), + (6, 2, 3, CURRENT_DATE + 10, 1, 'palettes', 'pending', NULL, 'admin_local', CURRENT_DATE + time '07:30', CURRENT_DATE + time '07:30'), + (7, 1, 1, CURRENT_DATE - 380, 4, 'palettes', 'delivered', 'Année précédente', 'admin_local', CURRENT_DATE - 385 + time '10:00', CURRENT_DATE - 380 + time '10:00')`); + + await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount, + invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by, + control_validated_at, created_by, created_at, updated_at) VALUES + (1, 1, 1, 1, CURRENT_DATE - 20, CURRENT_DATE - 20 + time '11:00', 10, 'palettes', 'delivered', NULL, 'BL-1001', 1250.50, + 'FAC-2001', 1250.50, 1500.60, CURRENT_DATE + 10, true, CURRENT_DATE - 20 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 21 + time '10:00', CURRENT_DATE - 19 + time '10:00'), + (2, NULL, 2, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '14:00', 6, 'colis', 'delivered', 'Carton abîmé', 'BL-1002', 310.00, + NULL, NULL, NULL, NULL, false, CURRENT_DATE - 5 + time '14:10', false, NULL, NULL, 'bench_manager', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 5 + time '14:10'), + (3, 2, 2, 1, CURRENT_DATE - 2, NULL, 5, 'colis', 'planned', NULL, NULL, NULL, + NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 5 + time '10:00', CURRENT_DATE - 5 + time '10:00'), + (4, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:30', 2, 'palettes', 'delivered', NULL, 'BL-1004', 980.00, + 'FAC-2004', 975.00, 1170.00, CURRENT_DATE + 25, false, CURRENT_DATE - 1 + time '09:40', true, 'bench_manager', CURRENT_DATE - 1 + time '10:00', 'bench_manager', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 1 + time '10:00'), + (5, 4, 1, 2, CURRENT_DATE, NULL, 8, 'colis', 'planned', NULL, NULL, NULL, + NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'), + (6, 5, 4, 2, CURRENT_DATE - 40, CURRENT_DATE - 40 + time '16:00', 3, 'palettes', 'delivered', NULL, 'BL-1006', 450.25, + 'FAC-2006', 450.25, 540.30, CURRENT_DATE - 5, true, CURRENT_DATE - 40 + time '16:10', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 41 + time '10:00', CURRENT_DATE - 39 + time '10:00'), + (7, NULL, 1, 3, CURRENT_DATE + 2, NULL, 1, 'palettes', 'planned', NULL, NULL, NULL, + NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'admin_local', CURRENT_DATE + time '07:00', CURRENT_DATE + time '07:00'), + (8, 7, 1, 1, CURRENT_DATE - 380, CURRENT_DATE - 380 + time '11:00', 4, 'palettes', 'delivered', NULL, 'BL-0900', 800.00, + 'FAC-1900', 800.00, 960.00, CURRENT_DATE - 350, true, CURRENT_DATE - 380 + time '11:05', false, NULL, NULL, 'admin_local', CURRENT_DATE - 381 + time '10:00', CURRENT_DATE - 379 + time '10:00')`); + + await q(`INSERT INTO reconciliation_comments (id, delivery_id, group_id, content, type, author_id, created_at, updated_at) VALUES + (1, 2, 1, 'Facture non reçue, relancer le fournisseur', 'warning', 'bench_manager', CURRENT_DATE - 4 + time '09:00', CURRENT_DATE - 4 + time '09:00'), + (2, 4, 1, 'Écart de 5 € sur le BL', 'info', 'admin_local', CURRENT_DATE - 1 + time '15:00', CURRENT_DATE - 1 + time '15:00')`); + + await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc, due_date, + exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES + (1, '1_fac-2001', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, CURRENT_DATE + 10, true, 'invoice_reference', NULL, false, 120, true, + CURRENT_DATE + 18000, CURRENT_DATE - 20 + time '11:10', CURRENT_DATE - 20 + time '11:10'), + (2, '1_fac-2004', 1, 'FAC-2004', 'Brico Pro', 975.00, 1170.00, CURRENT_DATE + 25, true, 'invoice_reference', NULL, false, 95, false, + CURRENT_DATE + 1, CURRENT_DATE - 1 + time '10:30', CURRENT_DATE - 1 + time '10:30')`); + + await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES + (1, 'PUB-01', 'Foire aux vins', CURRENT_DATE - 10, CURRENT_DATE + 5, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 60 + time '09:00', CURRENT_DATE - 60 + time '09:00'), + (2, 'PUB-02', 'Spécial jardin', CURRENT_DATE + 20, CURRENT_DATE + 34, EXTRACT(YEAR FROM CURRENT_DATE)::int, 'admin_local', CURRENT_DATE - 50 + time '09:00', CURRENT_DATE - 50 + time '09:00'), + (3, 'PUB-99', 'Rentrée', CURRENT_DATE - 400, CURRENT_DATE - 386, EXTRACT(YEAR FROM CURRENT_DATE)::int - 1, 'admin_local', CURRENT_DATE - 420 + time '09:00', CURRENT_DATE - 420 + time '09:00')`); + await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES + (1, 1, CURRENT_DATE - 60 + time '09:00'), (1, 2, CURRENT_DATE - 60 + time '09:00'), (2, 1, CURRENT_DATE - 50 + time '09:00'), (3, 2, CURRENT_DATE - 420 + time '09:00')`); + + await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode, + quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at, + notified_at, notified_comment) VALUES + (1, 'Sophie', 'Mme Bernard', '06 11 11 11 11', 'bernard@client.test', 'Tondeuse électrique', 'TD-450', '3245678901234', 1, 4, + 'En attente de Commande', 50.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 3 + time '15:00', CURRENT_DATE - 3 + time '15:00', NULL, NULL), + (2, 'Marc', 'M. Laurent', '06 22 22 22 22', NULL, 'Perceuse sans fil', 'PS-18', '3245678901241', 2, 3, + 'Commande passée', 0.00, true, false, 'Prix promo', 1, 'bench_manager', CURRENT_DATE - 8 + time '11:00', CURRENT_DATE - 6 + time '11:00', NULL, NULL), + (3, 'Sophie', 'Mme Garcia', '06 33 33 33 33', NULL, 'Fromage affiné', NULL, '3245678901258', 1, 1, + 'Disponible', 0.00, false, false, NULL, 1, 'bench_employee', CURRENT_DATE - 10 + time '10:00', CURRENT_DATE - 1 + time '10:00', NULL, NULL), + (4, 'Léa', 'M. Thomas', '06 44 44 44 44', NULL, 'Graines de gazon 5 kg', 'GZ-5', '3245678901265', 3, 4, + 'Disponible', 10.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 12 + time '10:00', CURRENT_DATE - 2 + time '10:00', CURRENT_DATE - 2 + time '11:00', 'Message laissé'), + (5, 'Léa', 'Mme Petit', '06 55 55 55 55', NULL, 'Arrosoir', NULL, '3245678901272', 1, 4, + 'Retiré', 0.00, false, true, NULL, 2, 'bench_directeur', CURRENT_DATE - 30 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 25 + time '10:00', NULL)`); + + await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold, + status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by, + validated_by, validated_at, created_at, updated_at) VALUES + (1, 'Yaourt nature', 'Yaourt nature x4', '3000000000011', 1, 1, CURRENT_DATE - 2, 'dlc', 12, 'unité', 'Rayon frais', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 9 + time '08:30', CURRENT_DATE - 9 + time '08:30'), + (2, 'Camembert', 'Camembert 250 g', '3000000000028', 1, 1, CURRENT_DATE + 3, 'dlc', 6, 'unité', 'Rayon frais', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, 'Promo possible', 'bench_manager', NULL, NULL, CURRENT_DATE - 7 + time '08:30', CURRENT_DATE - 7 + time '08:30'), + (3, 'Haricots verts', 'Haricots verts 400 g', '3000000000035', 2, 1, CURRENT_DATE + 60, 'ddm', 24, 'unité', 'Réserve', 15, + 'valides', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 6 + time '08:30', CURRENT_DATE - 6 + time '09:00'), + (4, 'Crème fraîche', 'Crème fraîche 20 cl', '3000000000042', 1, 1, CURRENT_DATE + 1, 'dlc', 4, 'unité', 'Rayon frais', 15, + 'en_cours', true, 'bench_employee', CURRENT_DATE + time '08:00', NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 5 + time '08:30', CURRENT_DATE + time '08:00'), + (5, 'Beurre doux', 'Beurre doux 250 g', '3000000000059', 1, 1, CURRENT_DATE + 2, 'dlc', 10, 'unité', 'Rayon frais', 15, + 'en_cours', false, NULL, NULL, CURRENT_DATE - 1 + time '17:00', 'bench_manager', true, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 4 + time '08:30', CURRENT_DATE - 1 + time '17:00'), + (6, 'Petits pois', 'Petits pois 800 g', '3000000000066', 2, 2, CURRENT_DATE + 10, 'dlc', 30, 'unité', 'Magasin', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 3 + time '08:30')`); + + await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES + (1, 'Inventaire rayon frais', 'Compter les yaourts', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1, 'high', 'pending', 'Équipe frais', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 3 + time '08:00'), + (2, 'Mise en rayon promo', NULL, NULL, CURRENT_DATE + 2, 'medium', 'pending', 'Sophie', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '08:00'), + (3, 'Nettoyage réserve', NULL, CURRENT_DATE + 5 + time '08:00', CURRENT_DATE + 7, 'low', 'pending', 'Marc', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'), + (4, 'Relevé températures', NULL, NULL, CURRENT_DATE - 3, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 3 + time '18:00', 'bench_directeur', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 3 + time '18:00'), + (5, 'Affichage prix', NULL, NULL, CURRENT_DATE, 'high', 'pending', 'Léa', 'bench_directeur', 2, NULL, NULL, CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 2 + time '08:00')`); + + await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES + (1, 'Inventaire annuel', 'Inventaire le 30 du mois', 'info', NULL, 'admin_local', CURRENT_DATE - 1 + time '08:00'), + (2, 'Panne caisse 3', 'Caisse 3 hors service', 'warning', 1, 'bench_manager', CURRENT_DATE + time '07:45'), + (3, 'Ancienne information', 'Texte ancien', 'info', 2, 'admin_local', CURRENT_DATE - 20 + time '08:00')`); + + await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified, + nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES + (1, 1, 1, 'AV-3001', 45.90, 'Produits périmés à la livraison', false, 'En attente de demande', false, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 4 + time '10:00'), + (2, 3, 1, 'AV-3002', 120.00, NULL, true, 'Demandé', true, false, NULL, CURRENT_DATE - 2 + time '10:00', 'admin_local', CURRENT_DATE - 6 + time '10:00', CURRENT_DATE - 2 + time '10:00'), + (3, 4, 2, NULL, 15.00, 'Casse', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00', 'bench_directeur', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 1 + time '12:00')`); + + const year = new Date().getFullYear(); + await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type, + problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES + (1, $1, 3, 1, '3245678901241', 'PS-18', 'Perceuse sans fil', 'defectueux', 'Ne démarre pas', NULL, 'nouveau', 'haute', 'M. Laurent', '06 22 22 22 22', 'bench_employee', CURRENT_DATE - 2 + time '16:00', CURRENT_DATE - 2 + time '16:00', NULL, NULL), + (2, $2, 4, 1, '3245678901234', 'TD-450', 'Tondeuse électrique', 'pieces_manquantes', 'Manque le bac', NULL, 'attente_pieces', 'normale', NULL, NULL, 'bench_manager', CURRENT_DATE - 15 + time '10:00', CURRENT_DATE - 5 + time '10:00', NULL, NULL), + (3, $3, 4, 2, '3245678901265', 'GZ-5', 'Graines de gazon 5 kg', 'non_conforme', 'Sac déchiré', 'Échangé', 'resolu', 'faible', 'M. Thomas', NULL, 'bench_directeur', CURRENT_DATE - 25 + time '10:00', CURRENT_DATE - 20 + time '10:00', CURRENT_DATE - 20 + time '10:00', NULL)`, + [`SAV-${year}-0001`, `SAV-${year}-0002`, `SAV-${year}-0003`]); + + await q(`INSERT INTO supplier_mail_logs (id, delivery_id, group_id, supplier_id, supplier_name, sent_to, subject, status, error_message, message_id, sent_by, sent_by_name, created_at) VALUES + (1, 2, 1, 2, 'Bonduelle', 'factures@bonduelle.test', 'Demande de facture BL-1002', 'sent', NULL, '', 'bench_manager', 'Manon Manager', CURRENT_DATE - 3 + time '09:00')`); + + await q(`INSERT INTO weather_settings (id, api_key, location, is_active, created_at, updated_at) VALUES + (1, 'cle-meteo-banc', 'Nancy, France', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`); + + await q(`INSERT INTO utilities (id, sales_analysis_url, automatic_backups_enabled, created_at, updated_at) VALUES + (1, 'https://analyse.banc.test/rapport', false, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`); + + await q(`INSERT INTO webhook_bap_config (id, name, webhook_url, description, is_active, created_at, updated_at) VALUES + (1, 'Configuration BAP', $1, 'Webhook simulé du banc', true, CURRENT_DATE - 100 + time '08:00', CURRENT_DATE - 100 + time '08:00')`, + [`${STUB_URL}/webhook/bap`]); + + await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES + ('banc-sauvegarde-1', 'backup_banc_1.sql', 'Sauvegarde du banc', 2048, CURRENT_DATE - 1 + time '02:00', 'system', 26, 'completed', 'automatic')`); + + await q(`INSERT INTO external_api_keys (id, name, key_prefix, key_hash, created_by, created_at, last_used_at, revoked_at) VALUES + (1, 'Outil de rapprochement', $1, $2, 'admin_local', CURRENT_DATE - 30 + time '08:00', NULL, NULL), + (2, 'Ancienne clé', 'lf_oldkey', $3, 'admin_local', CURRENT_DATE - 90 + time '08:00', NULL, CURRENT_DATE - 60 + time '08:00')`, + [BENCH_API_KEY.slice(0, 10), createHash("sha256").update(BENCH_API_KEY).digest("hex"), createHash("sha256").update("lf_cle_revoquee").digest("hex")]); + + // Données complémentaires de chaque lot (seeds/*.ts, voir seeds/README.md) + const seedsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "seeds"); + for (const file of fs.readdirSync(seedsDir).filter((f) => f.endsWith(".ts")).sort()) { + const extra = await import(path.join(seedsDir, file)); + await extra.seed(q); + } + + // Les séquences reprennent après les identifiants fixés ci-dessus + const { rows } = await q(`SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'public' AND column_default LIKE 'nextval%'`); + for (const row of rows) { + await q(`SELECT setval(pg_get_serial_sequence($1, $2), COALESCE((SELECT MAX(${row.column_name}) FROM ${row.table_name}), 0) + 1, false)`, + [row.table_name, row.column_name]); + } + + await q("COMMIT"); + await client.end(); + console.log("Données du banc insérées"); +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/bench/seeds/README.md b/scripts/bench/seeds/README.md new file mode 100644 index 0000000..3c2ba05 --- /dev/null +++ b/scripts/bench/seeds/README.md @@ -0,0 +1,8 @@ +Données complémentaires du banc, une par lot : chaque fichier `*.ts` exporte +`seed(q)` (voir `seed.ts`), appelée après les données de base, par ordre +alphabétique de nom de fichier. Identifiants à partir de 100 pour ne pas +heurter ceux de `seed.ts` ni ceux d'un autre fichier : choisir une centaine +libre (lot 2 : 200…, lot 3 : 300…, lot 4 : 400…, lot 5 : 500…). + +Les fichiers de `sauvegardes/` (sauvegardes déclarées dans `database_backups`) +sont copiés par `compare.ts` dans le dossier de sauvegardes de chaque serveur. diff --git a/scripts/bench/seeds/lot2.ts b/scripts/bench/seeds/lot2.ts new file mode 100644 index 0000000..ee3b289 --- /dev/null +++ b/scripts/bench/seeds/lot2.ts @@ -0,0 +1,84 @@ +// Données du lot 2 (Informations et planning), identifiants à partir de 200 : +// - informations au-delà de la limite de 5, par magasin et globales, avec des +// auteurs de chaque forme (prénom et nom, nom d'affichage seul, prénom seul, +// compte disparu) ; +// - publicités dont les N° testent le tri numérique (« 12 », « 3 », « 3-bis ») +// et des participations du magasin d'Épinal ; +// - une commande livrée en retard par magasin hors Nancy, pour des délais moyens +// non nuls dans les statistiques ; +// - la météo en cache pour aujourd'hui et pour la date de l'an dernier calculée +// par le serveur Node : /api/weather/current ne sollicite jamais Visual +// Crossing pendant le banc (fenêtre de 9 jours, au cas où le banc tourne +// quelques jours après setup.sh). +import { previousYearDate } from "../legacy"; + +type Query = (text: string, values?: unknown[]) => Promise; + +function isoDay(offset: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +export async function seed(q: Query) { + // Auteurs d'informations sans magasin (connexion impossible : empreinte factice) + await q(`INSERT INTO users (id, username, email, name, first_name, last_name, password, role, password_changed, created_at, updated_at) VALUES + ('bench_lot2_redaction', 'redaction', 'redaction@banc.test', 'Équipe Rédaction Nancy', NULL, NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00'), + ('bench_lot2_prenom', 'prenom-seul', NULL, NULL, 'Paulette', NULL, 'x', 'employee', true, CURRENT_DATE - 90 + time '08:00', CURRENT_DATE - 90 + time '08:00')`); + + await q(`INSERT INTO dashboard_messages (id, title, content, type, store_id, created_by, created_at) VALUES + (200, 'Fermeture exceptionnelle', 'Fermeture à 18 h samedi', 'error', 1, 'manager', CURRENT_DATE - 2 + time '09:00'), + (201, 'Nouvelle procédure', 'Procédure de réception mise à jour', 'success', NULL, 'admin', CURRENT_DATE - 3 + time '10:00'), + (202, 'Réunion Metz', 'Réunion d''équipe jeudi', 'info', 2, 'directeur', CURRENT_DATE - 1 + time '14:00'), + (203, 'Message de la rédaction', 'Nouvelle charte affichage', 'warning', 3, 'redaction', CURRENT_DATE - 5 + time '11:00'), + (204, 'Prénom seul', 'Auteur sans nom de famille', 'info', NULL, 'prenom-seul', CURRENT_DATE - 4 + time '16:30'), + (205, 'Auteur supprimé', 'Compte disparu depuis', 'info', 1, 'ancien-compte', CURRENT_DATE - 6 + time '08:15')`); + + const year = new Date().getUTCFullYear(); + await q(`INSERT INTO publicities (id, pub_number, designation, start_date, end_date, year, created_by, created_at, updated_at) VALUES + (200, '12', 'Promo hiver', CURRENT_DATE + 40, CURRENT_DATE + 50, $1, 'admin_local', CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'), + (201, '3', 'Semaine du blanc', CURRENT_DATE - 3, CURRENT_DATE + 4, $1, 'admin_local', CURRENT_DATE - 40 + time '09:00', CURRENT_DATE - 35 + time '09:00'), + (202, '3-bis', 'Semaine du blanc (suite)', CURRENT_DATE + 5, CURRENT_DATE + 9, $1, 'admin_local', CURRENT_DATE - 40 + time '09:30', CURRENT_DATE - 40 + time '09:30'), + (203, 'PUB-03', 'Catalogue de l''an prochain', CURRENT_DATE + 200, CURRENT_DATE + 214, $2, 'admin_local', CURRENT_DATE - 10 + time '09:00', CURRENT_DATE - 10 + time '09:00')`, + [year, year + 1]); + await q(`INSERT INTO publicity_participations (publicity_id, group_id, created_at) VALUES + (200, 3, CURRENT_DATE - 30 + time '09:00'), (201, 1, CURRENT_DATE - 40 + time '09:00'), (201, 3, CURRENT_DATE - 40 + time '09:00'), + (203, 2, CURRENT_DATE - 10 + time '09:00')`); + + await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES + (200, 2, 2, CURRENT_DATE - 12, 7, 'palettes', 'delivered', NULL, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'), + (201, 4, 3, CURRENT_DATE - 15, 2, 'colis', 'delivered', NULL, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`); + await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount, + invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES + (200, 200, 2, 2, CURRENT_DATE - 12, CURRENT_DATE - 9 + time '15:00', 7, 'palettes', 'delivered', NULL, 'BL-2200', 640.40, + 'FAC-2200', 640.40, 768.48, CURRENT_DATE + 20, false, CURRENT_DATE - 9 + time '15:10', false, 'bench_directeur', CURRENT_DATE - 14 + time '10:00', CURRENT_DATE - 9 + time '15:10'), + (201, 201, 4, 3, CURRENT_DATE - 15, CURRENT_DATE - 13 + time '10:00', 2, 'colis', 'delivered', NULL, 'BL-2201', NULL, + NULL, NULL, NULL, NULL, false, CURRENT_DATE - 13 + time '10:10', false, 'admin_local', CURRENT_DATE - 16 + time '10:00', CURRENT_DATE - 13 + time '10:10')`); + + // Météo en cache : même date de l'an dernier que GET /api/weather/current + const conditions = [ + ["partly-cloudy-day", "Partiellement nuageux"], + ["rain", "Pluie légère"], + ["clear-day", "Dégagé"], + ["fog", "Brouillard"], + ["icone-inconnue", "Variable"], + ]; + const rows: unknown[][] = []; + const previousDates = new Set(); + for (let offset = -1; offset <= 7; offset++) { + const day = isoDay(offset); + const [icon, label] = conditions[(offset + 1) % conditions.length]; + rows.push([day, "Nancy, France", (14.3 + offset).toFixed(1), (6 + offset / 2).toString(), icon, label, true]); + const previous = previousYearDate(new Date(`${day}T12:00:00Z`)); + if (!previousDates.has(previous)) { + previousDates.add(previous); + const [oldIcon, oldLabel] = conditions[(offset + 3) % conditions.length]; + rows.push([previous, "Nancy, France", (12 + offset).toString(), (3.5 - offset).toFixed(2), oldIcon, oldLabel, false]); + } + } + let id = 200; + for (const row of rows) { + await q(`INSERT INTO weather_data (id, date, location, temp_max, temp_min, icon, conditions, is_current_year, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00')`, [id++, ...row]); + } +} diff --git a/scripts/bench/seeds/lot3.ts b/scripts/bench/seeds/lot3.ts new file mode 100644 index 0000000..9f9f2cd --- /dev/null +++ b/scripts/bench/seeds/lot3.ts @@ -0,0 +1,52 @@ +// Lot 3 « Au quotidien » : tâches, DLC, commandes clients et SAV (identifiants 300…). +// Complète seed.ts pour exercer ce que ses lignes ne couvrent pas : une ligne du +// magasin d'Épinal (aucun compte du banc n'y est affecté, sauf l'administrateur), +// les bornes de date des filtres (aujourd'hui, J+15, J+16) et des tâches +// programmées plus tard dans la journée. +type Query = (text: string, values?: unknown[]) => Promise; + +export async function seed(q: Query) { + await q(`INSERT INTO tasks (id, title, description, start_date, due_date, priority, status, assigned_to, created_by, group_id, completed_at, completed_by, created_at, updated_at) VALUES + (300, 'Inventaire Épinal', 'Magasin sans compte du banc', NULL, CURRENT_DATE + 4, 'medium', 'pending', 'Équipe Épinal', 'admin_local', 3, NULL, NULL, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'), + (301, 'Réception livraison', NULL, CURRENT_DATE + time '00:00', CURRENT_DATE, 'high', 'pending', 'Marc', 'bench_manager', 1, NULL, NULL, CURRENT_DATE - 1 + time '07:00', CURRENT_DATE - 1 + time '07:00'), + (302, 'Préparer la vitrine', NULL, CURRENT_DATE + time '10:00', CURRENT_DATE + 1, 'low', 'pending', 'Sophie', 'bench_directeur', 1, NULL, NULL, CURRENT_DATE - 1 + time '06:00', CURRENT_DATE - 1 + time '06:00'), + (303, 'Commande ancienne', NULL, CURRENT_DATE - 10 + time '08:00', CURRENT_DATE - 9, 'medium', 'completed', 'Léa', 'bench_directeur', 2, CURRENT_DATE - 9 + time '17:00', 'bench_directeur', CURRENT_DATE - 12 + time '08:00', CURRENT_DATE - 9 + time '17:00')`); + + await q(`INSERT INTO dlc_products (id, name, product_name, gencode, supplier_id, group_id, expiry_date, date_type, quantity, unit, location, alert_threshold, + status, stock_epuise, stock_epuise_by, stock_epuise_at, processed_at, processed_by, processed_until_expiry, notes, created_by, + validated_by, validated_at, created_at, updated_at) VALUES + (300, 'Saucisson', 'Saucisson sec', '3000000000301', 1, 3, CURRENT_DATE + 5, 'dlc', 8, 'unité', 'Magasin', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'admin_local', NULL, NULL, CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 2 + time '08:30'), + (301, 'Lait demi-écrémé', 'Lait demi-écrémé 1 L', '3000000000318', 1, 1, CURRENT_DATE + 30, 'dlc', 20, 'unité', 'Réserve', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_employee', NULL, NULL, CURRENT_DATE - 1 + time '08:30', CURRENT_DATE - 1 + time '08:30'), + (302, 'Compote', 'Compote pomme x4', '3000000000325', 2, 1, CURRENT_DATE, 'ddm', 5, 'unité', 'Magasin', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 8 + time '08:30', CURRENT_DATE - 8 + time '08:30'), + (303, 'Jus d''orange', NULL, NULL, 2, 1, CURRENT_DATE + 15, 'dluo', 3, 'unité', 'Magasin', 15, + 'en_cours', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'bench_manager', NULL, NULL, CURRENT_DATE - 6 + time '09:30', CURRENT_DATE - 6 + time '09:30'), + (304, 'Fromage blanc', 'Fromage blanc 500 g', '3000000000349', 1, 2, CURRENT_DATE + 16, 'dlc', 7, 'unité', 'Rayon frais', 15, + 'en_cours', false, NULL, NULL, NULL, NULL, false, NULL, 'bench_directeur', NULL, NULL, CURRENT_DATE - 4 + time '10:30', CURRENT_DATE - 4 + time '10:30'), + (305, 'Pâté de campagne', 'Pâté de campagne 180 g', '3000000000356', 1, 1, CURRENT_DATE - 5, 'dlc', 2, 'unité', 'Rayon frais', 15, + 'valides', false, NULL, NULL, NULL, NULL, false, 'Retiré du rayon', 'bench_employee', 'bench_manager', CURRENT_DATE - 5 + time '08:00', CURRENT_DATE - 12 + time '08:30', CURRENT_DATE - 5 + time '08:00')`); + + await q(`INSERT INTO customer_orders (id, order_taker, customer_name, customer_phone, customer_email, product_designation, product_reference, gencode, + quantity, supplier_id, status, deposit, is_promotional_price, customer_notified, notes, group_id, created_by, created_at, updated_at, + notified_at, notified_comment) VALUES + (300, 'Hugo', 'M. Colin', '06 66 66 66 66', NULL, 'Tronçonneuse', 'TR-35', '3245678901300', 1, 3, + 'Arrivé', 20.00, false, false, NULL, 3, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 1 + time '12:00', NULL, NULL), + (301, 'Sophie', 'Mme Robert', '06 77 77 77 77', 'robert@client.test', 'Plateau de fromages', NULL, '3245678901317', 1, 1, + 'prêt', 0.00, false, false, 'Pour samedi', 1, 'bench_employee', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE + time '06:00', NULL, NULL), + (302, 'Léa', 'M. Simon', '06 88 88 88 88', NULL, 'Sécateur', 'SC-20', '3245678901324', 2, 4, + 'Disponible', 0.00, false, NULL, NULL, 2, 'bench_directeur', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 3 + time '09:00', NULL, NULL), + (303, 'Léa', 'Mme Michel', '06 99 99 99 99', NULL, 'Terreau 40 L', NULL, '3245678901331', 4, 4, + 'Prêt', 5.50, true, false, NULL, 2, 'bench_directeur', CURRENT_DATE - 9 + time '09:00', CURRENT_DATE - 2 + time '09:00', NULL, NULL)`); + + // 301 : créé l'an dernier (hors du compte qui numérote les nouveaux tickets), + // par un compte supprimé depuis (créateur absent de la jointure) + const year = new Date().getFullYear(); + await q(`INSERT INTO sav_tickets (id, ticket_number, supplier_id, group_id, product_gencode, product_reference, product_designation, problem_type, + problem_description, resolution_description, status, priority, client_name, client_phone, created_by, created_at, updated_at, resolved_at, closed_at) VALUES + (300, $1, 2, 3, '3245678901300', 'TR-35', 'Tronçonneuse', 'autre', 'Chaîne bloquée', NULL, 'en_cours', 'critique', 'M. Colin', '06 66 66 66 66', 'admin_local', CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 1 + time '11:00', NULL, NULL), + (301, $2, 1, 1, '3000000000011', NULL, 'Yaourt nature', 'non_conforme', 'Emballage gonflé', 'Remboursé', 'ferme', 'normale', NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 400 + time '10:00', CURRENT_DATE - 390 + time '10:00', CURRENT_DATE - 395 + time '10:00', CURRENT_DATE - 390 + time '10:00'), + (302, $3, 4, 2, '3245678901324', 'SC-20', 'Sécateur', 'defectueux', 'Ressort cassé', NULL, 'attente_echange', 'faible', 'M. Simon', NULL, 'bench_directeur', CURRENT_DATE - 6 + time '14:00', CURRENT_DATE - 6 + time '14:00', NULL, NULL)`, + [`SAV-${year}-0004`, `SAV-${year - 1}-0042`, `SAV-${year}-0005`]); +} diff --git a/scripts/bench/seeds/lot4-commandes.ts b/scripts/bench/seeds/lot4-commandes.ts new file mode 100644 index 0000000..1b7baf8 --- /dev/null +++ b/scripts/bench/seeds/lot4-commandes.ts @@ -0,0 +1,25 @@ +// Lot 4, commandes et livraisons (cases/fournisseurs-commandes.ts) : cas que les +// données de base n'exercent pas. +type Query = (text: string, values?: unknown[]) => Promise; + +export async function seed(q: Query) { + // Commande de Metz dont le créateur n'existe plus (creator: null), en attente + // alors que sa livraison est livrée (synchronisation des statuts) + await q(`INSERT INTO orders (id, supplier_id, group_id, planned_date, quantity, unit, status, notes, created_by, created_at, updated_at) VALUES + (600, 2, 2, CURRENT_DATE + 5, 6, 'colis', 'pending', 'Créateur supprimé', 'utilisateur_supprime', + CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00')`); + + await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount, + invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, control_validated_by, + control_validated_at, created_by, created_at, updated_at) VALUES + -- Livraison de Nancy liée à la commande 600 de Metz : la commande n'est jamais jointe + (600, 600, 2, 1, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '15:00', 6, 'colis', 'delivered', 'Commande d''un autre magasin', 'BL-6000', 120.00, + NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'bench_manager', CURRENT_DATE - 4 + time '10:00', CURRENT_DATE - 3 + time '15:00'), + -- Bonduelle (rapprochement automatique), prévue, créateur supprimé ; même date + -- prévue que la livraison 7 (ordre à égalité de tri) + (601, NULL, 2, 1, CURRENT_DATE + 2, NULL, 3, 'colis', 'planned', NULL, NULL, NULL, + NULL, NULL, NULL, NULL, false, NULL, false, NULL, NULL, 'utilisateur_supprime', CURRENT_DATE - 1 + time '12:00', CURRENT_DATE - 1 + time '12:00'), + -- Bonduelle à Metz, rapprochée d'office, sans facture (API externe) + (602, NULL, 2, 2, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '08:15', 4, 'colis', 'delivered', NULL, 'BL-6002', 88.40, + NULL, NULL, NULL, NULL, true, CURRENT_DATE - 7 + time '08:20', false, NULL, NULL, 'bench_directeur', CURRENT_DATE - 8 + time '10:00', CURRENT_DATE - 7 + time '08:20')`); +} diff --git a/scripts/bench/seeds/lot4-rapprochement.ts b/scripts/bench/seeds/lot4-rapprochement.ts new file mode 100644 index 0000000..5c74660 --- /dev/null +++ b/scripts/bench/seeds/lot4-rapprochement.ts @@ -0,0 +1,63 @@ +// Lot 4, rapprochement (cases/fournisseurs-rapprochement.ts) : avoirs et échéancier +// (identifiants 700…). +// - avoirs hors de Nancy : un de Metz à vérifier dans NocoDB, un d'Épinal (aucun +// compte du banc n'y est affecté, sauf l'administrateur) créé par un compte +// supprimé depuis, et un avoir validé à la référence vide ; +// - livraisons que GET /api/payment-schedule complète avec NocoDB : échéance +// absente (trouvée, introuvable, ou sans échéance dans NocoDB), montant TTC nul +// (repli « TTC »), magasin d'Épinal sans configuration NocoDB ; +// - échéances aux bornes du mois courant (premier jour à minuit, dernière +// microseconde, premier jour du mois suivant) pour l'export CSV. +type Query = (text: string, values?: unknown[]) => Promise; + +export async function seed(q: Query) { + await q(`INSERT INTO avoirs (id, supplier_id, group_id, invoice_reference, amount, comment, commercial_processed, status, webhook_sent, nocodb_verified, + nocodb_verified_at, processed_at, created_by, created_at, updated_at) VALUES + (700, 2, 2, 'FAC-2006', 10.50, 'Remise oubliée', false, 'Demandé', false, false, NULL, NULL, 'bench_directeur', + CURRENT_DATE - 3 + time '11:00', CURRENT_DATE - 3 + time '11:00'), + (701, 1, 3, NULL, NULL, NULL, NULL, 'En attente de demande', NULL, NULL, NULL, NULL, 'compte_disparu', + CURRENT_DATE - 2 + time '11:00', CURRENT_DATE - 2 + time '11:00'), + (702, 4, 1, ' ', 0.00, '', true, 'Reçu', true, true, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00', 'bench_employee', + CURRENT_DATE - 1 + time '08:00', CURRENT_DATE - 1 + time '09:00')`); + + const month = `date_trunc('month', CURRENT_DATE::timestamp)`; + await q(`INSERT INTO deliveries (id, order_id, supplier_id, group_id, scheduled_date, delivered_date, quantity, unit, status, notes, bl_number, bl_amount, + invoice_reference, invoice_amount, invoice_amount_ttc, due_date, reconciled, validated_at, control_validated, created_by, created_at, updated_at) VALUES + -- Échéance et montant absents : retrouvés dans NocoDB (FAC-2002) + (700, NULL, 2, 1, CURRENT_DATE - 8, CURRENT_DATE - 8 + time '10:00', 3, 'colis', 'delivered', NULL, 'BL-7000', 300.00, + 'FAC-2002', NULL, NULL, NULL, false, CURRENT_DATE - 8 + time '10:10', false, 'bench_manager', CURRENT_DATE - 9 + time '10:00', CURRENT_DATE - 8 + time '10:10'), + -- Référence introuvable dans NocoDB + (701, NULL, 1, 1, CURRENT_DATE - 7, CURRENT_DATE - 7 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7001', 50.00, + 'FAC-INCONNUE', NULL, NULL, NULL, false, CURRENT_DATE - 7 + time '10:10', false, 'bench_manager', CURRENT_DATE - 8 + time '09:00', CURRENT_DATE - 7 + time '10:10'), + -- Trouvée sans échéance (avoir AV-3001) : seul le montant est repris + (702, NULL, 3, 1, CURRENT_DATE - 6, CURRENT_DATE - 6 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL, + 'AV-3001', NULL, NULL, NULL, false, CURRENT_DATE - 6 + time '10:10', false, 'bench_manager', CURRENT_DATE - 7 + time '09:00', CURRENT_DATE - 6 + time '10:10'), + -- Montant TTC nul : repli « TTC », qui réécrit le HT et l'échéance de NocoDB (FAC-2006) + (703, NULL, 4, 1, CURRENT_DATE - 5, CURRENT_DATE - 5 + time '10:00', 2, 'palettes', 'delivered', NULL, 'BL-7003', 450.25, + 'FAC-2006', 450.25, 0.00, CURRENT_DATE + 3 + time '00:00', false, CURRENT_DATE - 5 + time '10:10', false, 'bench_manager', CURRENT_DATE - 6 + time '09:00', CURRENT_DATE - 5 + time '10:10'), + -- Référence vide : hors échéancier + (704, NULL, 2, 1, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '10:00', 1, 'colis', 'delivered', NULL, NULL, NULL, + '', 12.00, 14.40, CURRENT_DATE + 4, false, CURRENT_DATE - 4 + time '10:10', false, 'bench_manager', CURRENT_DATE - 5 + time '09:00', CURRENT_DATE - 4 + time '10:10'), + -- Metz : échéance absente, retrouvée dans NocoDB (FAC-2001) + (705, NULL, 1, 2, CURRENT_DATE - 4, CURRENT_DATE - 4 + time '11:00', 4, 'colis', 'delivered', NULL, 'BL-7005', 1200.00, + 'FAC-2001', NULL, NULL, NULL, false, CURRENT_DATE - 4 + time '11:10', false, 'bench_directeur', CURRENT_DATE - 5 + time '11:00', CURRENT_DATE - 4 + time '11:10'), + -- Épinal : magasin sans configuration NocoDB + (706, NULL, 3, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '11:00', 1, 'palettes', 'delivered', NULL, 'BL-7006', 99.00, + 'FAC-2004', NULL, NULL, NULL, false, CURRENT_DATE - 3 + time '11:10', false, 'admin_local', CURRENT_DATE - 4 + time '11:00', CURRENT_DATE - 3 + time '11:10'), + (707, NULL, 4, 3, CURRENT_DATE - 3, CURRENT_DATE - 3 + time '12:00', 1, 'palettes', 'delivered', NULL, 'BL-7007', 80.00, + 'FAC-7007', 80.00, 96.00, ${month} + interval '12 days', false, CURRENT_DATE - 3 + time '12:10', false, 'admin_local', CURRENT_DATE - 4 + time '12:00', CURRENT_DATE - 3 + time '12:10'), + -- Bornes du mois courant (export CSV) + (708, NULL, 1, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7008', 100.00, + 'FAC-7008', 100.00, 120.00, ${month}, false, CURRENT_DATE - 2 + time '09:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:00', CURRENT_DATE - 2 + time '09:10'), + (709, NULL, 2, 1, CURRENT_DATE - 2, CURRENT_DATE - 2 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7009', 20.10, + 'FAC-7009', 20.10, 24.12, ${month} + interval '1 month' - interval '1 microsecond', false, CURRENT_DATE - 2 + time '10:10', false, 'bench_manager', CURRENT_DATE - 3 + time '08:30', CURRENT_DATE - 2 + time '10:10'), + (710, NULL, 4, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '09:00', 1, 'colis', 'delivered', NULL, 'BL-7010', 55.55, + 'FAC-7010', 55.55, 66.66, ${month} + interval '1 month', false, CURRENT_DATE - 1 + time '09:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:00', CURRENT_DATE - 1 + time '09:10'), + (711, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '10:00', 1, 'colis', 'delivered', NULL, 'BL-7011', 0.10, + 'FAC-7011', 0.10, 0.20, ${month} + interval '14 days 10:30', false, CURRENT_DATE - 1 + time '10:10', false, 'bench_manager', CURRENT_DATE - 2 + time '08:30', CURRENT_DATE - 1 + time '10:10'), + -- Échéances de NocoDB au format JJ/MM/AAAA (illisible par new Date) et MM/JJ/AAAA + (712, NULL, 2, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '11:00', 1, 'colis', 'delivered', NULL, 'BL-1007', 99.90, + 'FAC-2007', NULL, NULL, NULL, false, CURRENT_DATE - 1 + time '11:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 1 + time '11:10'), + (713, NULL, 3, 1, CURRENT_DATE - 1, CURRENT_DATE - 1 + time '12:00', 1, 'colis', 'delivered', NULL, 'BL-1008', 12.34, + 'FAC-2008', NULL, NULL, NULL, true, CURRENT_DATE - 1 + time '12:10', false, 'bench_manager', CURRENT_DATE - 2 + time '09:30', CURRENT_DATE - 1 + time '12:10')`); +} diff --git a/scripts/bench/seeds/lot4-services.ts b/scripts/bench/seeds/lot4-services.ts new file mode 100644 index 0000000..9a47b13 --- /dev/null +++ b/scripts/bench/seeds/lot4-services.ts @@ -0,0 +1,23 @@ +// Lot 4, services partagés (cases/fournisseurs-services.ts) : entrées du cache des +// factures pour exercer le marquage « rapproché » et le diagnostic du cache. +type Query = (text: string, values?: unknown[]) => Promise; + +export async function seed(q: Query) { + await q(`INSERT INTO invoice_verification_cache (id, cache_key, group_id, invoice_reference, supplier_name, invoice_amount, invoice_amount_ttc, + due_date, exists, match_type, error_message, cache_hit, api_call_time, is_reconciled, expires_at, created_at, updated_at) VALUES + -- Livraison 6 validée (Metz), entrée encore valable : marquée par la mise à jour en lot + (400, '2_fac-2006', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, CURRENT_DATE - 5, true, 'invoice_reference', NULL, false, 80, false, + CURRENT_DATE + 2, CURRENT_DATE - 3 + time '10:00', CURRENT_DATE - 3 + time '10:00'), + -- Numéro de BL de la livraison 8, validée : recherche infructueuse en cache + (401, '1_bl-0900', 1, 'BL-0900', NULL, NULL, NULL, NULL, false, 'none', 'Aucune correspondance trouvée dans NocoDB', false, NULL, false, + CURRENT_DATE + 1, CURRENT_DATE - 1 + time '09:00', CURRENT_DATE - 1 + time '09:00'), + -- Facture de la livraison 8, expirée : ni marquée, et supprimée à sa lecture + (402, '1_fac-1900', 1, 'FAC-1900', 'Lactalis', 800.00, 960.00, CURRENT_DATE - 350, true, 'invoice_reference', NULL, false, 70, false, + CURRENT_DATE - 1, CURRENT_DATE - 2 + time '09:00', CURRENT_DATE - 2 + time '09:00'), + -- Clé au format du diagnostic (référence_magasin), expirée : supprimée par GET /api/cache/diagnosis + (403, 'fac-2001_1', 1, 'FAC-2001', 'Lactalis', 1250.50, 1500.60, NULL, true, 'invoice_reference', NULL, false, NULL, false, + CURRENT_DATE - 2, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00'), + -- Même format, valable : trouvée par le diagnostic + (404, 'fac-2006_2', 2, 'FAC-2006', 'Jardin & Co', 450.25, 540.30, NULL, true, 'invoice_reference', NULL, false, NULL, true, + CURRENT_DATE + 3, CURRENT_DATE - 30 + time '09:00', CURRENT_DATE - 30 + time '09:00')`); +} diff --git a/scripts/bench/seeds/lot5.ts b/scripts/bench/seeds/lot5.ts new file mode 100644 index 0000000..d911c35 --- /dev/null +++ b/scripts/bench/seeds/lot5.ts @@ -0,0 +1,18 @@ +// Données du lot 5 (administration) : des sauvegardes en plus de celle de +// seed.ts, pour que la liste soit tronquée à dix et que la création d'une +// sauvegarde fasse le ménage des plus anciennes (backupService.cleanOldBackups). +// banc-sauvegarde-500 a un fichier (sauvegardes/backup_banc_500.sql), copié +// par compare.ts dans le dossier de sauvegardes de chaque serveur, comme celui +// de banc-sauvegarde-1. +type Query = (text: string, values?: unknown[]) => Promise; + +export async function seed(q: Query) { + await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) VALUES + ('banc-sauvegarde-500', 'backup_banc_500.sql', 'Manuel backup du banc', 64, CURRENT_DATE - 2 + time '14:00', 'admin_local', 26, 'completed', 'manual')`); + // Neuf anciennes sauvegardes automatiques, sans fichier, un jour d'écart chacune + for (let n = 1; n <= 9; n++) { + await q(`INSERT INTO database_backups (id, filename, description, size, created_at, created_by, tables_count, status, backup_type) + VALUES ($1, $2, 'Automatique backup du banc', $3, CURRENT_DATE - $4::int + time '02:00', 'system', 26, 'completed', 'automatic')`, + [`banc-sauvegarde-${500 + n}`, `backup_banc_${500 + n}.sql`, 1024 * n, 2 + n]); + } +} diff --git a/scripts/bench/seeds/sauvegardes/backup_banc_1.sql b/scripts/bench/seeds/sauvegardes/backup_banc_1.sql new file mode 100644 index 0000000..1c34ddd --- /dev/null +++ b/scripts/bench/seeds/sauvegardes/backup_banc_1.sql @@ -0,0 +1,2 @@ +-- Sauvegarde du banc (banc-sauvegarde-1) +SELECT 1; diff --git a/scripts/bench/seeds/sauvegardes/backup_banc_500.sql b/scripts/bench/seeds/sauvegardes/backup_banc_500.sql new file mode 100644 index 0000000..9eafbb8 --- /dev/null +++ b/scripts/bench/seeds/sauvegardes/backup_banc_500.sql @@ -0,0 +1,2 @@ +-- Sauvegarde manuelle du banc (banc-sauvegarde-500) +SELECT 500; diff --git a/scripts/bench/setup.sh b/scripts/bench/setup.sh new file mode 100755 index 0000000..12e1eaf --- /dev/null +++ b/scripts/bench/setup.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Prépare la base modèle du banc (bench_seed) : schéma de production, index créés +# par le premier démarrage du Node de référence, puis données du banc (seed.ts). +# Prérequis : PostgreSQL local, serveur Node de référence construit (reference-node.sh). +# Usage : scripts/bench/setup.sh [postgres://utilisateur:motdepasse@hôte:port] +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +pg="${1:-${BENCH_PG_URL:-postgres://logiflow_admin:bench@localhost:5432}}" +# Mêmes noms que config.ts : BENCH_ID sépare les bases, BENCH_PORT_BASE les ports +suffix="${BENCH_ID:+_$BENCH_ID}" +template="${BENCH_TEMPLATE_DB:-bench_seed$suffix}" +schema_db="logiflow_schema$suffix" +port=$(( ${BENCH_PORT_BASE:-3200} + 92 )) +node_dir="${BENCH_NODE_DIR:-$root/.bench-reference}" + +psql -q "$pg/postgres" -c "DROP DATABASE IF EXISTS $schema_db WITH (FORCE)" -c "CREATE DATABASE $schema_db" +"$root/scripts/bench/schema.sh" "$pg/$schema_db" 2>&1 | grep -v NOTICE || true + +# Premier démarrage du Node de référence : migrations de son server/migrations.production.ts +# exec : $! est bien le processus Node, pas un sous-shell +(cd "$node_dir" && exec env NODE_ENV=production PORT=$port DATABASE_URL="$pg/$schema_db" SESSION_SECRET=setup \ + node dist/index.js > /dev/null 2>&1) & +node_pid=$! +trap 'kill $node_pid 2>/dev/null || true' EXIT +for _ in $(seq 1 120); do + curl -sf "http://127.0.0.1:$port/api/health" > /dev/null && break + sleep 0.5 +done +# Les index sont créés en arrière-plan : attendre la fin des CREATE INDEX +sleep 2 +while [ "$(psql -At "$pg/$schema_db" -c "SELECT count(*) FROM pg_stat_activity WHERE query ILIKE 'CREATE INDEX%' OR query ILIKE 'ANALYZE%'")" != "0" ]; do + sleep 1 +done +kill $node_pid 2>/dev/null || true; wait $node_pid 2>/dev/null || true +trap - EXIT + +# Le modèle ne garde que le schéma : Node a pu créer l'administrateur par défaut +tables=$(psql -At "$pg/$schema_db" -c "SELECT string_agg(format('%I', tablename), ', ') FROM pg_tables WHERE schemaname = 'public'") +psql -q "$pg/$schema_db" -c "TRUNCATE $tables RESTART IDENTITY CASCADE" + +psql -q "$pg/postgres" -c "DROP DATABASE IF EXISTS $template WITH (FORCE)" -c "CREATE DATABASE $template TEMPLATE $schema_db" +(cd "$root" && DATABASE_URL="$pg/$template" SESSION_SECRET=banc-secret-de-session ENCRYPTION_KEY=banc-cle-de-chiffrement \ + npx tsx scripts/bench/seed.ts) +echo "Base modèle $template prête ($(psql -At "$pg/$template" -c "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public'") index)." diff --git a/scripts/bench/stub.ts b/scripts/bench/stub.ts new file mode 100644 index 0000000..de22404 --- /dev/null +++ b/scripts/bench/stub.ts @@ -0,0 +1,287 @@ +// Services externes simulés pour le banc : NocoDB, webhooks (n8n) et SMTP. +// Chaque appel reçu est enregistré ; le banc le lit (GET /__calls) pour vérifier +// que Node et Rust appellent les mêmes services avec les mêmes données. +import http from "http"; +import net from "net"; +import { STUB_PORT, STUB_SMTP_PORT } from "./config"; + +export interface StubCall { + service: "nocodb" | "webhook" | "smtp" | "autre"; + method?: string; + path?: string; + contentType?: string; + body?: unknown; +} + +const calls: StubCall[] = []; + +function isoDate(offsetDays: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + offsetDays); + return date.toISOString().slice(0, 10); +} + +// Lignes de la table de factures NocoDB simulée (colonnes des magasins du banc) +function invoiceRows() { + return [ + { Id: 1, RefFacture: "FAC-2001", NumBL: "BL-1001", MontantHT: 1250.5, MontantTTC: 1500.6, Fournisseur: "Lactalis", Echeance: isoDate(10) }, + { Id: 2, RefFacture: "FAC-2004", NumBL: "BL-1004", MontantHT: 975, MontantTTC: 1170, Fournisseur: "Brico Pro", Echeance: isoDate(25) }, + { Id: 3, RefFacture: "FAC-2002", NumBL: "BL-1002", MontantHT: 310, MontantTTC: 372, Fournisseur: "Bonduelle", Echeance: isoDate(30) }, + { Id: 4, RefFacture: "FAC-2006", NumBL: "BL-1006", MontantHT: 450.25, MontantTTC: 540.3, Fournisseur: "Jardin & Co", Echeance: isoDate(-5) }, + { Id: 5, RefFacture: "AV-3001", NumBL: null, MontantHT: -45.9, MontantTTC: -55.08, Fournisseur: "Lactalis", Echeance: null }, + // Échéances saisies à la française (JJ/MM/AAAA) ou à l'américaine (MM/JJ/AAAA) + { Id: 6, RefFacture: "FAC-2007", NumBL: "BL-1007", MontantHT: 99.9, MontantTTC: 119.88, Fournisseur: "Bonduelle", Echeance: "25/12/2030" }, + { Id: 7, RefFacture: "FAC-2008", NumBL: "BL-1008", MontantHT: 12.34, MontantTTC: 14.81, Fournisseur: "Brico Pro", Echeance: "12/24/2030" }, + ]; +} + +// where=(colonne,eq,valeur) : seul opérateur utilisé par LogiFlow +function filterRows(where: string | null) { + const rows = invoiceRows(); + if (!where) return rows; + const match = where.match(/^\(([^,]+),eq,(.*)\)$/); + if (!match) return rows; + const [, column, value] = match; + return rows.filter((row) => String((row as Record)[column] ?? "") === value); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +// Corps enregistré sans ce qui change d'un passage à l'autre (séparateur multipart) +function describeBody(contentType: string | undefined, raw: Buffer): unknown { + if (!raw.length) return null; + if (contentType?.includes("application/json")) { + try { return JSON.parse(raw.toString("utf8")); } catch { return raw.toString("utf8"); } + } + if (contentType?.includes("multipart/form-data")) { + const boundary = contentType.split("boundary=")[1]?.replace(/"/g, ""); + if (!boundary) return { taille: raw.length }; + const parts = raw.toString("latin1").split(`--${boundary}`).slice(1, -1); + return parts.map((part) => { + const [head, ...rest] = part.split("\r\n\r\n"); + const name = head.match(/name="([^"]*)"/)?.[1]; + const filename = head.match(/filename="([^"]*)"/)?.[1]; + const content = rest.join("\r\n\r\n").replace(/\r\n$/, ""); + return filename !== undefined ? { name, filename, taille: Buffer.from(content, "latin1").length } : { name, valeur: content }; + }); + } + return raw.toString("utf8"); +} + +// ---------- Courriels reçus : décodés pour comparer le contenu, pas l'encodage ---------- +// nodemailer et lettre n'encodent pas de la même façon (quoted-printable ou base64, +// mots encodés RFC 2047, guillemets des noms) : on compare le texte décodé. + +// Octets d'un texte où =XX désigne un octet (quoted-printable, mots encodés « Q ») +function decodeEscapes(text: string): Buffer { + const bytes: number[] = []; + for (let i = 0; i < text.length; i++) { + if (text[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(text.slice(i + 1, i + 3))) { + bytes.push(parseInt(text.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(...Buffer.from(text[i], "utf8")); + } + } + return Buffer.from(bytes); +} + +// =?utf-8?Q?…?= et =?utf-8?B?…?= ; blancs entre deux mots encodés ignorés +function decodeWords(value: string): string { + return value + .replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?[^?]+\?[bBqQ]\?[^?]*\?=)/g, "$1") + .replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (_, _charset: string, encoding: string, text: string) => + encoding.toUpperCase() === "B" + ? Buffer.from(text, "base64").toString("utf8") + : decodeEscapes(text.replace(/_/g, " ")).toString("utf8")); +} + +// En-têtes dépliés, noms en minuscules (premier exemplaire de chaque) +function parseHeaders(head: string): Record { + const headers: Record = {}; + const lines = head.split("\n"); + const unfolded: string[] = []; + for (const line of lines) { + if (/^[ \t]/.test(line) && unfolded.length) unfolded[unfolded.length - 1] += line; + else unfolded.push(line); + } + for (const line of unfolded) { + const index = line.indexOf(":"); + if (index <= 0) continue; + const name = line.slice(0, index).trim().toLowerCase(); + if (!(name in headers)) headers[name] = line.slice(index + 1).trim(); + } + return headers; +} + +// « Nom » sans guillemets ni encodage, séparés par « , » +function normalizeAddresses(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return decodeWords(value) + .split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) + .map((part) => { + const match = part.trim().match(/^(.*)<([^>]*)>$/); + if (!match) return part.trim(); + const name = match[1].trim().replace(/^"(.*)"$/, "$1"); + return name ? `${name} <${match[2].trim()}>` : match[2].trim(); + }) + .join(", "); +} + +// Type et paramètres d'un en-tête Content-Type ou Content-Disposition +function parseParams(value: string | undefined): { value: string; params: Record } { + const [main, ...rest] = (value ?? "").split(";"); + const params: Record = {}; + for (const part of rest) { + const index = part.indexOf("="); + if (index < 0) continue; + params[part.slice(0, index).trim().toLowerCase()] = part.slice(index + 1).trim().replace(/^"(.*)"$/, "$1"); + } + return { value: main.trim().toLowerCase(), params }; +} + +function decodeTransfer(body: string, encoding: string | undefined): Buffer { + switch ((encoding ?? "7bit").toLowerCase()) { + case "base64": + return Buffer.from(body.replace(/\s/g, ""), "base64"); + case "quoted-printable": + return decodeEscapes(body.replace(/=\n/g, "")); + default: + return Buffer.from(body, "utf8"); + } +} + +// Parties terminales du message, dans l'ordre, avec le chemin de leurs conteneurs +function describeParts(head: Record, body: string, path: string[]): unknown[] { + const type = parseParams(head["content-type"] ?? "text/plain"); + if (type.value.startsWith("multipart/")) { + const boundary = type.params.boundary; + const sections = body.split(`--${boundary}`); + const parts: unknown[] = []; + // sections[0] : préambule ; la dernière commence par « -- » (fin) + for (const section of sections.slice(1)) { + if (section.startsWith("--")) break; + const content = section.replace(/^[^\n]*\n/, "").replace(/\n$/, ""); + const index = content.indexOf("\n\n"); + const partHead = parseHeaders(index < 0 ? content : content.slice(0, index)); + const partBody = index < 0 ? "" : content.slice(index + 2); + parts.push(...describeParts(partHead, partBody, [...path, type.value])); + } + return parts; + } + const decoded = decodeTransfer(body, head["content-transfer-encoding"]); + const disposition = parseParams(head["content-disposition"]); + const part: Record = { chemin: path.join(" > "), type: type.value }; + if (type.params.charset) part.charset = type.params.charset.toLowerCase(); + if (head["content-id"]) part.cid = head["content-id"]; + if (disposition.value) part.disposition = disposition.value; + const filename = disposition.params.filename ?? type.params.name; + if (filename) part.fichier = decodeWords(filename); + if (type.value.startsWith("text/")) part.contenu = decoded.toString("utf8").replace(/\r\n/g, "\n"); + else part.taille = decoded.length; + return [part]; +} + +function describeMail(message: string): Record { + const index = message.indexOf("\n\n"); + const headers = parseHeaders(index < 0 ? message : message.slice(0, index)); + const body = index < 0 ? "" : message.slice(index + 2).replace(/\n$/, ""); + return { + sujet: headers.subject === undefined ? undefined : decodeWords(headers.subject), + de: normalizeAddresses(headers.from), + a: normalizeAddresses(headers.to), + repondreA: normalizeAddresses(headers["reply-to"]), + parties: describeParts(headers, body, []), + }; +} + +export function startStub(): Promise<() => Promise> { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1:${STUB_PORT}`); + const raw = await readBody(req); + + if (url.pathname === "/__calls") { + if (req.method === "DELETE") calls.length = 0; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(calls)); + return; + } + + const contentType = req.headers["content-type"]; + const body = describeBody(contentType, raw); + + if (url.pathname.startsWith("/nocodb/")) { + calls.push({ service: "nocodb", method: req.method, path: `${url.pathname}${url.search}` }); + const list = filterRows(url.searchParams.get("where")); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ list, pageInfo: { totalRows: list.length, page: 1, pageSize: 25, isFirstPage: true, isLastPage: true } })); + return; + } + + if (url.pathname.startsWith("/webhook/")) { + calls.push({ service: "webhook", method: req.method, path: url.pathname, contentType: contentType?.split(";")[0], body }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, message: "Reçu par le service simulé" })); + return; + } + + calls.push({ service: "autre", method: req.method, path: url.pathname }); + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "inconnu du service simulé" })); + }); + + // SMTP minimal : accepte tout, sans STARTTLS ni authentification obligatoire + const smtp = net.createServer((socket) => { + let inData = false; + let message = ""; + const recipients: string[] = []; + socket.write("220 banc.test ESMTP simulé\r\n"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let index: number; + while ((index = buffer.indexOf("\r\n")) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + if (inData) { + if (line === ".") { + inData = false; + // Sujet, adresses et parties décodés (voir describeMail) + calls.push({ service: "smtp", body: { destinataires: recipients.slice(), ...describeMail(message) } }); + socket.write("250 2.0.0 message accepté\r\n"); + } else { + // Point doublé en début de ligne (RFC 5321, 4.5.2) + message += `${line.startsWith("..") ? line.slice(1) : line}\n`; + } + continue; + } + const command = line.slice(0, 4).toUpperCase(); + if (command === "EHLO") socket.write("250-banc.test\r\n250-AUTH PLAIN LOGIN\r\n250 8BITMIME\r\n"); + else if (command === "HELO") socket.write("250 banc.test\r\n"); + else if (command === "AUTH") socket.write("235 2.7.0 authentifié\r\n"); + else if (command === "MAIL") socket.write("250 2.1.0 ok\r\n"); + else if (command === "RCPT") { recipients.push(line.replace(/^RCPT TO:\s*/i, "")); socket.write("250 2.1.5 ok\r\n"); } + else if (command === "DATA") { inData = true; message = ""; socket.write("354 fin par .\r\n"); } + else if (command === "QUIT") { socket.write("221 2.0.0 au revoir\r\n"); socket.end(); } + else if (command === "RSET" || command === "NOOP") socket.write("250 2.0.0 ok\r\n"); + else socket.write("502 5.5.2 commande inconnue\r\n"); + } + }); + socket.on("error", () => undefined); + }); + + return new Promise((resolve) => { + server.listen(STUB_PORT, "127.0.0.1", () => { + smtp.listen(STUB_SMTP_PORT, "127.0.0.1", () => { + resolve(() => new Promise((done) => server.close(() => smtp.close(() => done())))); + }); + }); + }); +} diff --git a/scripts/bench/types.ts b/scripts/bench/types.ts new file mode 100644 index 0000000..9845b64 --- /dev/null +++ b/scripts/bench/types.ts @@ -0,0 +1,49 @@ +// Description des requêtes rejouées par le banc. + +// "anonyme" : sans session ; "api" : clé de l'API externe (en-tête X-API-Key) +export type AccountKey = "admin" | "directeur" | "manager" | "employe" | "sans-magasin" | "anonyme" | "api"; + +export const ALL_ACCOUNTS: AccountKey[] = ["admin", "directeur", "manager", "employe", "sans-magasin", "anonyme"]; +export const LOGGED_ACCOUNTS: AccountKey[] = ["admin", "directeur", "manager", "employe", "sans-magasin"]; + +// Écart voulu entre Node et Rust (faille fermée…), décidé et inscrit au banc : +// ces champs sont retirés des deux côtés avant la comparaison, et le banc +// rappelle la raison à la fin de son rapport +export interface Deviation { + fields: string[]; + reason: string; +} + +// Lecture : rejouée sur la même base pour chaque compte +export interface ReadCase { + module: string; + path: string; + accounts?: AccountKey[]; // par défaut ALL_ACCOUNTS + // Champs ignorés dans la comparaison (valeurs propres à chaque passage) + ignore?: string[]; + deviation?: Deviation; +} + +// Les chemins et corps acceptent des gabarits : +// {{today}} {{today+3}} {{today-30}} → AAAA-MM-JJ ; {{year}} {{year-1}} {{month}} +// {{0.id}} → champ id de la réponse JSON de l'étape 0 du même scénario +export interface Step { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + body?: unknown; + // json (par défaut), form (urlencoded) ou multipart (champs + fichiers) + bodyType?: "json" | "form" | "multipart"; + files?: { field: string; filename: string; contentType: string; size: number }[]; + headers?: Record; +} + +// Écriture : base remise à zéro avant chaque compte, puis état des tables comparé +export interface WriteScenario { + module: string; + name: string; + accounts?: AccountKey[]; // par défaut LOGGED_ACCOUNTS + steps: Step[]; + tables: string[]; + ignore?: string[]; + deviation?: Deviation; +} diff --git a/scripts/create-migration.js b/scripts/create-migration.js deleted file mode 100755 index 4c7382b..0000000 --- a/scripts/create-migration.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node - -// Script utilitaire pour créer une nouvelle migration -// Usage: node scripts/create-migration.js "nom de la migration" - -import { createMigration } from '../server/migrations.js'; - -const migrationName = process.argv[2]; - -if (!migrationName) { - console.error('❌ Usage: node scripts/create-migration.js "nom de la migration"'); - console.error(' Exemple: node scripts/create-migration.js "add user roles table"'); - process.exit(1); -} - -try { - const filePath = createMigration(migrationName); - console.log(`✅ Migration créée avec succès: ${filePath}`); - console.log(' Éditez ce fichier pour ajouter votre SQL'); -} catch (error) { - console.error('❌ Erreur lors de la création de la migration:', error); - process.exit(1); -} \ No newline at end of file diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh deleted file mode 100644 index 18d9e1c..0000000 --- a/scripts/docker-entrypoint.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/bin/bash - -# Script d'entrée Docker - Applique automatiquement les migrations au démarrage -# Auteur: Système de gestion automatisé -# Date: 2025-08-18 - -set -e - -echo "🚀 [ENTRYPOINT] Démarrage du conteneur de production" -echo "==================================================" - -# Fonction de logging avec timestamp -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" -} - -log "🔧 Initialisation des variables d'environnement..." - -# Construire DATABASE_URL si nécessaire -if [ -z "$DATABASE_URL" ]; then - if [ -n "$POSTGRES_USER" ] && [ -n "$POSTGRES_PASSWORD" ] && [ -n "$POSTGRES_DB" ]; then - export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@logiflow-db:5432/${POSTGRES_DB}" - log "✅ URL de base de données construite automatiquement" - else - log "⚠️ Variables PostgreSQL manquantes, utilisation de DATABASE_URL existant" - fi -fi - -# Attendre que PostgreSQL soit prêt -log "⏳ Attente de PostgreSQL..." -max_attempts=30 -attempt=1 - -while [ $attempt -le $max_attempts ]; do - if psql "$DATABASE_URL" -c "SELECT 1;" > /dev/null 2>&1; then - log "✅ PostgreSQL est prêt" - break - fi - - if [ $attempt -eq $max_attempts ]; then - log "❌ PostgreSQL non accessible après $max_attempts tentatives" - log "🔍 Variables DB: USER=${POSTGRES_USER:-'non défini'} DB=${POSTGRES_DB:-'non défini'}" - exit 1 - fi - - log "⏳ Tentative $attempt/$max_attempts - attente 2 secondes..." - sleep 2 - ((attempt++)) -done - -# Exécuter les migrations automatiques -log "🔄 Exécution des migrations automatiques..." -if [ -f "/app/scripts/auto-migrate-production.sh" ]; then - log "📂 Script de migration trouvé, exécution..." - chmod +x /app/scripts/auto-migrate-production.sh - - # Forcer l'affichage des logs du script de migration - log "🔄 Démarrage des migrations avec logs détaillés..." - if /app/scripts/auto-migrate-production.sh 2>&1; then - log "✅ Migrations exécutées avec succès" - else - log "❌ Erreur lors des migrations, mais continuation..." - # Ne pas exit pour éviter de bloquer le démarrage - fi -else - log "⚠️ Script de migration non trouvé à /app/scripts/auto-migrate-production.sh" - log "🔍 Contenu du répertoire scripts:" - ls -la /app/scripts/ || echo "Répertoire scripts non trouvé" - - # Créer la table webhook_bap_config directement si le script manque - log "🛠️ Tentative de création directe de la table webhook_bap_config..." - if [ -n "$DATABASE_URL" ]; then - psql "$DATABASE_URL" << 'EOF' -CREATE TABLE IF NOT EXISTS webhook_bap_config ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP', - webhook_url TEXT NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) -SELECT - 'Configuration BAP', - 'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d', - 'Configuration par défaut pour envoi des fichiers BAP vers n8n', - true -WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config); - -COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n'; -EOF - log "✅ Table webhook_bap_config créée directement" - else - log "❌ DATABASE_URL manquante pour création directe" - fi -fi - -# Vérification post-migration -log "🔍 Vérification des colonnes DLC..." -if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name='dlc_products' AND column_name='stock_epuise');" | grep -q "t"; then - log "✅ Colonnes DLC stock épuisé présentes" -else - log "❌ Colonnes DLC stock épuisé manquantes" - log "🛠️ Tentative de migration directe..." - - psql "$DATABASE_URL" << 'EOF' -ALTER TABLE dlc_products -ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, -ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), -ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp; - -CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise); -EOF - - log "✅ Migration directe DLC terminée" -fi - -# Vérification de la table webhook_bap_config -log "🔍 Vérification de la table webhook_bap_config..." -if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema='public' AND table_name='webhook_bap_config');" | grep -q "t"; then - log "✅ Table webhook_bap_config présente" -else - log "❌ Table webhook_bap_config manquante" - log "🛠️ Création directe de la table webhook_bap_config..." - - psql "$DATABASE_URL" << 'EOF' -CREATE TABLE webhook_bap_config ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP', - webhook_url TEXT NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) -VALUES ( - 'Configuration BAP', - 'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d', - 'Configuration par défaut pour envoi des fichiers BAP vers n8n', - true -); - -COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n'; -EOF - - if [ $? -eq 0 ]; then - log "✅ Migration directe réussie" - else - log "❌ Migration directe échouée" - exit 1 - fi -fi - -# Démarrage de l'application -log "🚀 Démarrage de l'application..." -log "==================================================" - -# Exécuter la commande passée en paramètre (normalement "node dist/index.js") -exec "$@" \ No newline at end of file diff --git a/scripts/gen-rust-schema.ts b/scripts/gen-rust-schema.ts new file mode 100644 index 0000000..a68b13a --- /dev/null +++ b/scripts/gen-rust-schema.ts @@ -0,0 +1,224 @@ +// Génère server-rs/src/schema.rs à partir de shared/schema.ts : +// - une structure par table, avec les mêmes clés JSON, dans le même ordre et +// aux mêmes formats que ce que Drizzle renvoie à l'interface ; +// - le schéma de validation que drizzle-zod (createInsertSchema) en tire. +// Les colonnes nulles dans la base réelle le sont aussi en Rust (DATABASE_URL). +// +// Usage : DATABASE_URL=postgres://… npx tsx scripts/gen-rust-schema.ts +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import pg from "pg"; +import { getTableColumns } from "drizzle-orm"; +import { getTableConfig, PgTable } from "drizzle-orm/pg-core"; +import * as schema from "../shared/schema"; + +const OUTPUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../server-rs/src/schema.rs"); + +const RUST_KEYWORDS = new Set(["type", "match", "ref", "move", "use", "loop", "where", "self", "struct", "enum"]); + +function pascal(name: string): string { + return name.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(""); +} + +interface ColumnInfo { + jsKey: string; + name: string; + columnType: string; + notNull: boolean; + hasDefault: boolean; + length?: number; + scale?: number; +} + +// Type SQL vers lequel le texte d'un paramètre est converti (comme node-pg, qui +// envoie tous les paramètres en texte) +function sqlType(column: ColumnInfo): string { + switch (column.columnType) { + case "PgSerial": + case "PgInteger": + return "int4"; + case "PgBigInt53": + return "int8"; + case "PgVarchar": + case "PgText": + return "text"; + case "PgBoolean": + return "bool"; + case "PgTimestamp": + return "timestamp"; + case "PgDateString": + return "date"; + case "PgNumeric": + return "numeric"; + case "PgJsonb": + return "jsonb"; + default: + throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`); + } +} + +function rustType(column: ColumnInfo): string { + switch (column.columnType) { + case "PgSerial": + case "PgInteger": + return "i32"; + case "PgBigInt53": + return "i64"; + case "PgVarchar": + case "PgText": + return "String"; + case "PgBoolean": + return "bool"; + case "PgTimestamp": + return "NaiveDateTime"; + case "PgDateString": + return "NaiveDate"; + case "PgNumeric": + return "Decimal"; + case "PgJsonb": + return "serde_json::Value"; + default: + throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`); + } +} + +// Même correspondance que drizzle-zod 0.7 (columnToSchema) +function zodField(column: ColumnInfo): string { + let field: string; + switch (column.columnType) { + case "PgSerial": + case "PgInteger": + field = `Field::int32("${column.jsKey}")`; + break; + case "PgBigInt53": + field = `Field::safe_integer("${column.jsKey}")`; + break; + case "PgVarchar": + field = column.length ? `Field::string("${column.jsKey}").max_len(${column.length})` : `Field::string("${column.jsKey}")`; + break; + case "PgText": + case "PgDateString": + case "PgNumeric": + field = `Field::string("${column.jsKey}")`; + break; + case "PgBoolean": + field = `Field::boolean("${column.jsKey}")`; + break; + case "PgTimestamp": + field = `Field::date("${column.jsKey}")`; + break; + case "PgJsonb": + field = `Field::json("${column.jsKey}")`; + break; + default: + throw new Error(`Type de colonne non géré : ${column.columnType}`); + } + if (!column.notNull) field += ".nullable()"; + if (!column.notNull || column.hasDefault) field += ".optional()"; + return field; +} + +async function main() { + const nullableInDb = new Set(); + if (process.env.DATABASE_URL) { + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + const { rows } = await client.query( + `SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND is_nullable = 'YES'`, + ); + for (const row of rows) nullableInDb.add(`${row.table_name}.${row.column_name}`); + await client.end(); + } else { + console.warn("DATABASE_URL absent : nullabilité tirée de shared/schema.ts seulement"); + } + + const tables = new Map(); + for (const value of Object.values(schema)) { + if (!(value instanceof PgTable)) continue; + const config = getTableConfig(value); + if (tables.has(config.name)) continue; + const columns = Object.entries(getTableColumns(value)).map(([jsKey, column]: [string, any]) => ({ + jsKey, + name: column.name, + columnType: column.columnType, + notNull: column.notNull, + hasDefault: column.hasDefault, + length: column.length, + scale: column.scale, + })); + tables.set(config.name, columns); + } + + const out: string[] = []; + out.push("// Généré par scripts/gen-rust-schema.ts à partir de shared/schema.ts : ne pas modifier à la main."); + out.push("// Une structure par table (clés, ordre et formats JSON de Drizzle) et son schéma"); + out.push("// d'insertion drizzle-zod (createInsertSchema)."); + out.push("#![allow(dead_code)]"); + out.push(""); + out.push("use chrono::{NaiveDate, NaiveDateTime};"); + out.push("use rust_decimal::Decimal;"); + out.push("use serde::Serialize;"); + out.push("use sqlx::FromRow;"); + out.push(""); + out.push("use crate::db::{Col, Table};"); + out.push("use crate::zod::{Field, Object};"); + out.push(""); + + for (const [table, columns] of [...tables].sort(([a], [b]) => a.localeCompare(b))) { + const name = `${pascal(table)}Row`; + out.push(`/// Table ${table}`); + out.push("#[derive(Debug, Clone, Serialize, FromRow)]"); + out.push(`pub struct ${name} {`); + for (const column of columns) { + const nullable = !column.notNull || nullableInDb.has(`${table}.${column.name}`); + const base = rustType(column); + const type = nullable ? `Option<${base}>` : base; + const attributes: string[] = []; + const field = RUST_KEYWORDS.has(column.name) ? `${column.name}_` : column.name; + if (field !== column.name) attributes.push(`#[sqlx(rename = "${column.name}")]`); + const serde = [`rename = "${column.jsKey}"`]; + if (base === "NaiveDateTime") serde.push(`serialize_with = "${nullable ? "crate::json::ts_opt" : "crate::json::ts"}"`); + if (base === "Decimal") { + // Échelle de la colonne : PostgreSQL rend "0.00", sqlx lit 0 + if (typeof column.scale !== "number") throw new Error(`Échelle inconnue : ${table}.${column.name}`); + serde.push(`serialize_with = "${nullable ? "crate::json::numeric_opt" : "crate::json::numeric"}::<${column.scale}, _>"`); + } + attributes.push(`#[serde(${serde.join(", ")})]`); + for (const attribute of attributes) out.push(` ${attribute}`); + out.push(` pub ${field}: ${type},`); + } + out.push("}"); + out.push(""); + out.push(`impl Table for ${name} {`); + out.push(` const TABLE: &'static str = "${table}";`); + out.push(` const COLUMNS: &'static str = "${columns.map((c) => `\\"${c.name}\\"`).join(", ")}";`); + out.push(" const FIELDS: &'static [Col] = &["); + for (const column of columns) { + out.push(` Col { key: "${column.jsKey}", column: "${column.name}", sql_type: "${sqlType(column)}" },`); + } + out.push(" ];"); + out.push("}"); + out.push(""); + out.push(`impl ${name} {`); + out.push(` /// createInsertSchema(${table})`); + out.push(" pub fn insert_schema() -> Object {"); + out.push(" Object::new(vec!["); + for (const column of columns) out.push(` ${zodField(column)},`); + out.push(" ])"); + out.push(" }"); + out.push("}"); + out.push(""); + } + + fs.writeFileSync(OUTPUT, out.join("\n")); + // Même mise en forme que cargo fmt (server-rs/rustfmt.toml) + execFileSync("rustfmt", ["--edition", "2024", "--config-path", path.dirname(path.dirname(OUTPUT)), OUTPUT]); + console.log(`${tables.size} tables écrites dans ${path.relative(process.cwd(), OUTPUT)}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server-rs/.gitignore b/server-rs/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/server-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock new file mode 100644 index 0000000..709dd9d --- /dev/null +++ b/server-rs/Cargo.lock @@ -0,0 +1,3188 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "logiflow" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "chrono-tz", + "form_urlencoded", + "futures-util", + "hmac", + "http-body-util", + "hyper", + "lettre", + "matchit", + "rand 0.8.8", + "reqwest", + "rust_decimal", + "scrypt", + "serde", + "serde_json", + "sha1", + "sha2", + "sqlx", + "subtle", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml new file mode 100644 index 0000000..137d66a --- /dev/null +++ b/server-rs/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "logiflow" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Serveur LogiFlow en Rust, placé devant le serveur Node (docs/PLAN-MIGRATION-RUST.md)" +publish = false + +[dependencies] +aes-gcm = "0.10" +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] } +form_urlencoded = "1" +futures-util = "0.3" +hmac = "0.12" +matchit = "0.8" +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] } +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } +http-body-util = "0.1" +# Raison HTTP d'origine (« Not Found »…) lue sur les réponses reqwest (météo) +hyper = "1" +rust_decimal = { version = "1", features = ["serde"] } +scrypt = { version = "0.11", default-features = false, features = ["std"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha1 = "0.10" +sha2 = "0.10" +subtle = "2" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "macros", "chrono", "rust_decimal", "json"] } +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["compression-gzip", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +chrono-tz = { version = "0.10", default-features = false, features = ["std"] } + +[profile.release] +codegen-units = 4 + +# Connexion (scrypt, N = 16384) : sans optimisation, une vérification de mot de +# passe prend plusieurs secondes en mode debug (banc, développement) +[profile.dev.package.scrypt] +opt-level = 3 + +[profile.dev.package.salsa20] +opt-level = 3 + +[profile.dev.package.pbkdf2] +opt-level = 3 + +[profile.dev.package.sha2] +opt-level = 3 diff --git a/server-rs/PORTAGE.md b/server-rs/PORTAGE.md new file mode 100644 index 0000000..c30911b --- /dev/null +++ b/server-rs/PORTAGE.md @@ -0,0 +1,71 @@ +# Modifier le serveur Rust sans changer son comportement + +Guide écrit pour le portage des adresses de l'ancien serveur Node (`server/routes.ts`) vers `server-rs/` (`docs/PLAN-MIGRATION-RUST.md`). Le portage est terminé et `server/` a quitté le dépôt ; son code reste lisible dans la copie de référence du banc (`.bench-reference/server/`, construite par `scripts/bench/reference-node.sh`). Les règles valent toujours : une adresse est fidèle quand le banc (`scripts/bench/`) ne montre **aucun écart** avec le Node de référence, pour les 5 comptes, en lecture comme en écriture ; tout changement voulu y est déclaré comme écart. + +## La règle : le même comportement que Node, bug compris + +- Même code HTTP, même corps JSON (valeurs, champs `null`, ordre des listes), mêmes messages d'erreur, même ordre des contrôles (droits, puis validation, puis base). +- Les défauts connus de Node (cloisonnement par magasin incomplet, contrôles de rôle manquants…) ont été **reproduits** et signalés par un commentaire qui cite l'identifiant de l'audit s'il existe (ex. `CONT-02`). Les corriger, c'est déclarer l'écart au banc (`deviation`) dans le même commit. +- Ne rien « améliorer » en route : une différence voulue doit être décidée et inscrite au banc. + +## Lire le code Node de référence + +Dans `.bench-reference/`, pour chaque adresse : + +1. le gestionnaire dans `server/routes.ts` (ou `server/externalApi.ts`, `server/localAuth.ts`) : contrôles, paramètres lus (`req.query`, `req.params`, `req.body`), schéma zod, messages, codes ; +2. la méthode de `server/storage.ts` (`DatabaseStorage`, lignes 387 à 3509 ; ignorer `MemStorage`) : requête, jointures, tri, projections (`groupSummaryColumns`…) ; +3. les schémas de `shared/schema.ts` (`insertXSchema`, `.omit`, `.extend`, `.partial`) ; +4. les services appelés (`server/invoiceVerification.ts`, `server/emailService.ts`…). + +## Outils du socle + +| Besoin | Rust | Équivalent Node | +|---|---|---| +| Utilisateur connecté (401 « Authentification requise » sinon) | extracteur `auth::CurrentUser` : `user.user` (ligne `UsersRow`), `role()`, `is_admin()`, `group_ids()` | `isAuthenticated`, `req.user`, `getCurrentUser(req)`, `storage.getUser(req.user.id)` | +| Utilisateur facultatif | `auth::MaybeUser` | `req.isAuthenticated()` | +| Corps de la requête | `request::JsonBody(body)` : `serde_json::Value`, `{}` si le type n'est pas JSON | `req.body` (`express.json()`) | +| Paramètres d'adresse | `request::JsQuery` : `get("x")`, `truthy("x")` (présent et non vide) | `req.query.x`, `if (req.query.x)` | +| Identifiant d'adresse | `Path` puis `request::int_id(&id, "message 500")` | `parseInt(req.params.id)` (NaN → erreur SQL → 500) | +| Lignes d'une table | `schema::XRow` (généré : clés, ordre et formats JSON de Drizzle), `XRow::select("WHERE … ORDER BY …")` | `db.select().from(x)` | +| Insertion, mise à jour, suppression | `db::insert::`, `db::update_by_id::(…, true)` (ajoute `updatedAt`), `db::delete_by_id` | `insert().values().returning()`, `update().set({ ...data, updatedAt: new Date() })` | +| Validation zod | `XRow::insert_schema()` (createInsertSchema), puis `.omit`, `.pick`, `.extend(vec![Field::…])`, `.partial()`, `.parse(&body)` | `insertXSchema.parse(req.body)` | +| Erreurs | `error::AppError::{forbidden, not_found, bad_request, internal, validation}`, `.or_500("message")` sur un `Result`, `error::json_or_empty` (`res.json(undefined)`) | `res.status(…).json({ message })` | +| Projections jointes | `projections::{GroupSummary, GroupReconciliation, UserSummary, PublicUser}` | `groupSummaryColumns`, `userSummaryColumns`… | +| Droits par module | `access::has_permission(role, "deliveries", "edit")` | `hasPermission(...)` (`shared/permissions.json`) | +| Secrets chiffrés | `crypto::decrypt(value, &state.config.encryption_key)` / `encrypt` | `decryptSecret` / `encryptSecret` | +| Sémantique JavaScript | `js::to_number`, `js::parse_int`, `js::new_date`, `js::iso`, `js::length` | `Number()`, `parseInt()`, `new Date()`, `toISOString()`, `.length` | +| Formats de date | `json::ts` (`2026-10-15T08:30:00.000Z`), `json::pg_text` (valeur brute du pilote : `2026-10-15 08:30:00`) ; `modules::now_iso()` | Date JSON ; colonnes lues par `sql\`…\`` | + +Points d'attention : + +- **Tri** : reprendre l'`ORDER BY` de Drizzle. Un tri fait en JavaScript (`.sort`) se refait en Rust à l'identique (tri stable, mêmes clés). Sans `ORDER BY` côté Node, garder la même requête (même jointure) pour obtenir le même ordre. +- **Montants** (`decimal`) : `rust_decimal::Decimal`, sérialisé en texte (`"12.50"`), comme Node. +- **Dates** (`date`) : `NaiveDate`, `"2026-10-15"`. **Horodatages** : `NaiveDateTime` sérialisé par `json::ts`. +- **Requêtes SQL** : `sqlx::query_as` à l'exécution, avec des paramètres liés (`$1`), jamais de valeur collée dans le texte SQL. Pour une jointure, une structure locale `#[derive(FromRow)]` ou des tuples, puis la forme JSON de Node. +- **Paramètres envoyés en texte** : `db::insert` / `update_by_id` envoient chaque valeur en texte converti par PostgreSQL (`$1::date`), comme node-pg. Pour une requête écrite à la main, lier des types Rust (`i32`, `String`, `NaiveDate`…). +- **Socle** (`db.rs`, `zod.rs`, `js.rs`…) : un ajout doit être purement additif ; changer un outil existant touche tous les modules, donc tout le banc. +- **Nouvelle adresse** : la déclarer dans le module (`.route(…)`) ; `build.rs` l'ajoute aux motifs d'Express (casse, « / » final) et elle passe par `router.rs` sans autre changement. + +## Le banc + +Une base et des ports par copie de travail (`BENCH_ID`, `BENCH_PORT_BASE`), le Node de référence construit une fois (`BENCH_NODE_DIR`, par défaut `.bench-reference`) : + +```bash +scripts/bench/reference-node.sh # une fois, puis à chaque changement de REFERENCE_NODE_COMMIT +export BENCH_ID=essai BENCH_PORT_BASE=3600 +scripts/bench/setup.sh # base modèle (après tout changement de seeds/) +cargo build --manifest-path server-rs/Cargo.toml +RUST_BIN=server-rs/target/debug/logiflow npx tsx scripts/bench/compare.ts --only dlc,tasks +npx tsx scripts/bench/compare.ts --mode node --only dlc,tasks # les cas sont-ils stables ? +``` + +1. **Les cas** : dans le fichier de cas du module (`scripts/bench/cases/`), des lectures avec les paramètres que l'interface envoie (chercher les appels dans `client/src`), et un scénario d'écriture par adresse qui modifie quelque chose, avec des corps valides et invalides, sur des lignes du magasin de l'utilisateur et d'un autre magasin. `--mode node` doit donner 0 écart. +2. **Données** : si la base du banc ne permet pas d'exercer un cas, ajouter des lignes dans `scripts/bench/seeds/.ts` (voir `seeds/README.md`), puis relancer `setup.sh`. +3. **Rust** : 0 écart, hors écarts déclarés. +4. Les services externes (NocoDB, webhooks, SMTP) sont simulés par `stub.ts` ; les appels reçus font partie de la comparaison. + +## Avant de rendre la main + +- `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test` ; +- banc complet : 0 écart, hors écarts déclarés ; +- un commit par changement, message en français, qui donne le résultat du banc. diff --git a/server-rs/README.md b/server-rs/README.md new file mode 100644 index 0000000..249a134 --- /dev/null +++ b/server-rs/README.md @@ -0,0 +1,50 @@ +# Serveur LogiFlow (Rust) + +Serveur unique de l'application : il sert l'API (`src/router.rs`) et l'interface construite par Vite (`dist/public`), applique les migrations au démarrage (`src/startup.rs`) et lance les tâches de fond (`src/jobs.rs` : sauvegarde automatique quotidienne, purge des caches, compte `admin` par défaut sur une base vide). Il a remplacé le serveur Node/Express, dont il reprend le comportement à l'identique (`docs/PLAN-MIGRATION-RUST.md`) : mêmes réponses, même cookie de session (`connect.sid`, table `sessions`), mêmes secrets chiffrés. + +## Lancer en local + +```bash +npx vite build # interface dans dist/public +DATABASE_URL=postgres://… SESSION_SECRET=… cargo run --manifest-path server-rs/Cargo.toml +``` + +Puis ouvrir http://localhost:3000. Pour travailler sur l'interface : `npm run dev:server` et, à côté, `npm run dev` (Vite sur http://localhost:5173, qui relaie `/api` vers le port 3000). + +| Variable | Rôle | Défaut | +|---|---|---| +| `PORT` | port d'écoute | `3000` | +| `DATABASE_URL` | base PostgreSQL | obligatoire | +| `STATIC_DIR` | interface construite par Vite | `dist/public` | +| `SESSION_SECRET` | signature du cookie de session | `fallback-secret-key` | +| `ENCRYPTION_KEY` | clé des secrets chiffrés en base (à défaut `SESSION_SECRET`) | — | +| `BACKUP_DIR` | dossier des sauvegardes (`pg_dump` doit être installé) | `backups` | +| `NODE_ENV` | renvoyé par `/api/health` | `production` | +| `ALLOWED_ORIGINS` | origines acceptées en plus de l'hôte du serveur pour les requêtes d'écriture (`https://a.fr,https://b.fr`), voir `src/origin.rs` | — | +| `RUST_LOG` | niveau des journaux (`info`, `debug`…) | `info` | + +Ne pas changer `SESSION_SECRET` ni `ENCRYPTION_KEY` d'une installation existante : les sessions ouvertes et les secrets déjà chiffrés (mot de passe SMTP, jeton NocoDB) deviendraient illisibles. + +## Vérifier + +```bash +cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test +``` + +Puis le banc de comparaison avec le serveur Node de référence (`scripts/bench/README.md`) : aucun écart attendu, hors écarts déclarés. + +## Organisation + +- `src/router.rs` : table des adresses de l'API. `src/express_routes.rs` retrouve l'adresse qu'Express aurait servie (casse, « / » final) ; `src/static_files.rs` sert l'interface et la page 404 d'Express. +- `src/modules/` : un fichier par module de l'application ; `src/services/` : services partagés (NocoDB, vérification des factures, courriels, dates). +- Socle : session et utilisateur (`auth.rs`), droits (`access.rs`, matrice de `shared/permissions.json`), validation au format zod (`zod.rs`), accès aux tables (`db.rs`), formats JSON de Node (`json.rs`, `js.rs`), secrets chiffrés (`crypto.rs`), erreurs (`error.rs`). +- `src/schema.rs` est **généré** depuis `shared/schema.ts` : après un changement de schéma, `DATABASE_URL=… npm run gen:rust-schema`, et la migration correspondante dans `src/startup.rs`. +- Ajouter ou modifier une adresse : `PORTAGE.md` (règles et outils du socle). + +## Maintenance (décision R6) + +- Mainteneur et relecteur : @R0m1k3 (`.github/CODEOWNERS`). Toute modification passe par une PR dont la CI est verte : `cargo fmt`, `clippy -D warnings`, tests, contrôle de type de l'interface et banc. +- Dans les réglages GitHub du dépôt, protéger `main` : relecture des CODEOWNERS et vérifications « Serveur Rust » obligatoires. +- Dépendances : `cargo update` puis banc complet, une fois par mois ; Rust stable, image `rust:1.97-alpine` montée en même temps. +- Heure : « aujourd'hui » se calcule à Paris (`src/clock.rs`, décision R5) ; ne jamais utiliser `Utc::now().date_naive()` pour une date du jour. +- Écritures d'un autre site refusées (`src/origin.rs`, décision R4) : un nouveau service qui appelle l'API depuis un navigateur sur une autre adresse se déclare dans `ALLOWED_ORIGINS`. diff --git a/server-rs/build.rs b/server-rs/build.rs new file mode 100644 index 0000000..d9f1252 --- /dev/null +++ b/server-rs/build.rs @@ -0,0 +1,54 @@ +// Liste des motifs d'adresse déclarés dans src/modules/*.rs et src/router.rs +// (`.route("…")`), pour retrouver la route qu'Express aurait servie +// (src/express_routes.rs). + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +fn main() { + println!("cargo:rerun-if-changed=src/modules"); + println!("cargo:rerun-if-changed=src/router.rs"); + let mut patterns = BTreeSet::new(); + collect( + &fs::read_to_string("src/router.rs").expect("src/router.rs"), + &mut patterns, + ); + for entry in fs::read_dir("src/modules").expect("src/modules") { + let path = entry.expect("fichier de module").path(); + if path.extension().is_some_and(|ext| ext == "rs") { + println!("cargo:rerun-if-changed={}", path.display()); + collect(&fs::read_to_string(&path).expect("module lisible"), &mut patterns); + } + } + let list: Vec = patterns.iter().map(|p| format!(" {p:?},")).collect(); + let out = Path::new(&std::env::var("OUT_DIR").unwrap()).join("route_patterns.rs"); + fs::write( + out, + format!( + "pub const ROUTE_PATTERNS: &[&str] = &[\n{}\n];\n", + list.join("\n") + ), + ) + .unwrap(); +} + +/// Motifs littéraux passés à `.route(` ; les jokers et les motifs finissant par « / » +/// sont laissés de côté +fn collect(source: &str, patterns: &mut BTreeSet) { + let mut rest = source; + while let Some(index) = rest.find(".route(") { + rest = &rest[index + ".route(".len()..]; + let trimmed = rest.trim_start(); + let Some(literal) = trimmed.strip_prefix('"') else { + continue; + }; + let Some(end) = literal.find('"') else { + continue; + }; + let pattern = &literal[..end]; + if pattern.starts_with("/api") && !pattern.contains("{*") && !pattern.ends_with('/') { + patterns.insert(pattern.to_string()); + } + } +} diff --git a/server-rs/rustfmt.toml b/server-rs/rustfmt.toml new file mode 100644 index 0000000..284d219 --- /dev/null +++ b/server-rs/rustfmt.toml @@ -0,0 +1 @@ +max_width = 110 diff --git a/server-rs/src/access.rs b/server-rs/src/access.rs new file mode 100644 index 0000000..05ae7b9 --- /dev/null +++ b/server-rs/src/access.rs @@ -0,0 +1,35 @@ +// Droits par rôle et par module : même matrice que l'interface et Node +// (shared/permissions.json, lue par shared/permissions.ts). + +use std::collections::HashMap; +use std::sync::LazyLock; + +type Matrix = HashMap>>; + +static PERMISSIONS: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!("../../shared/permissions.json")) + .expect("shared/permissions.json valide") +}); + +/// hasPermission(role, module, action) +pub fn has_permission(role: &str, module: &str, action: &str) -> bool { + PERMISSIONS + .get(module) + .and_then(|roles| roles.get(&role.to_lowercase())) + .is_some_and(|actions| actions.iter().any(|a| a == action)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matrice_partagee() { + assert!(has_permission("manager", "deliveries", "validate")); + assert!(!has_permission("manager", "deliveries", "delete")); + assert!(has_permission("Admin", "avoir", "delete")); + assert!(!has_permission("employee", "orders", "view")); + assert!(!has_permission("employee", "avoir", "view")); + assert!(!has_permission("admin", "inconnu", "view")); + } +} diff --git a/server-rs/src/auth.rs b/server-rs/src/auth.rs new file mode 100644 index 0000000..1751343 --- /dev/null +++ b/server-rs/src/auth.rs @@ -0,0 +1,257 @@ +// Session partagée avec Node : cookie connect.sid signé par express-session, +// session stockée par connect-pg-simple dans la table sessions +// (docs/PLAN-MIGRATION-RUST.md, « Sessions partagées entre Node et Rust »). +// L'utilisateur est relu à chaque requête, avec ses magasins, comme +// deserializeUser → storage.getUserWithGroups (server/storage.ts). + +use axum::extract::FromRequestParts; +use axum::http::HeaderMap; +use axum::http::request::Parts; +use base64::Engine; +use chrono::NaiveDateTime; +use hmac::{Hmac, Mac}; +use serde::Serialize; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +use crate::AppState; +use crate::db::Table; +use crate::error::AppError; +use crate::schema::UsersRow; + +pub const SESSION_COOKIE: &str = "connect.sid"; + +/// Magasin joint aux affectations (userGroupColumns : dates en valeur brute du pilote) +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct GroupBrief { + pub id: i32, + pub name: String, + pub color: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::pg_text_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::pg_text_opt")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UserGroup { + #[serde(rename = "userId")] + pub user_id: String, + #[serde(rename = "groupId")] + pub group_id: i32, + pub group: GroupBrief, +} + +/// Utilisateur connecté (UserWithGroups) +#[derive(Debug, Clone)] +pub struct CurrentUser { + pub user: UsersRow, + pub user_groups: Vec, +} + +impl CurrentUser { + pub fn id(&self) -> &str { + &self.user.id + } + pub fn role(&self) -> &str { + &self.user.role + } + pub fn is_admin(&self) -> bool { + self.user.role == "admin" + } + /// (user.userGroups || []).map(ug => ug.groupId) + pub fn group_ids(&self) -> Vec { + self.user_groups + .iter() + .map(|membership| membership.group_id) + .collect() + } +} + +/// Utilisateur connecté obligatoire : sinon 401 « Authentification requise » +impl FromRequestParts for CurrentUser { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + load_current_user(&parts.headers, state) + .await? + .ok_or_else(AppError::unauthorized) + } +} + +/// Utilisateur connecté s'il y en a un (routes publiques qui changent selon la session) +pub struct MaybeUser(pub Option); + +impl FromRequestParts for MaybeUser { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + Ok(Self(load_current_user(&parts.headers, state).await?)) + } +} + +async fn load_current_user(headers: &HeaderMap, state: &AppState) -> Result, AppError> { + let Some(sid) = session_id(headers, &state.config.session_secret) else { + return Ok(None); + }; + let session: Option<(serde_json::Value,)> = + sqlx::query_as("SELECT sess FROM sessions WHERE sid = $1 AND expire >= now()") + .bind(&sid) + .fetch_optional(&state.db) + .await + .map_err(server_error)?; + let Some(user_id) = session + .as_ref() + .and_then(|(sess,)| sess.pointer("/passport/user")) + .and_then(|user| user.as_str()) + else { + return Ok(None); + }; + user_with_groups(&state.db, user_id).await.map_err(server_error) +} + +fn server_error(error: sqlx::Error) -> AppError { + tracing::error!(%error, "lecture de la session"); + AppError::internal("Internal Server Error") +} + +/// storage.getUserWithGroups : l'utilisateur et ses magasins, en une requête +pub async fn user_with_groups(db: &sqlx::PgPool, user_id: &str) -> Result, sqlx::Error> { + let Some(user) = sqlx::query_as::<_, UsersRow>(&UsersRow::select("WHERE id = $1")) + .bind(user_id) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + // Même jointure que Node (sans tri) : les affectations vers un magasin + // inexistant sont ignorées + let rows: Vec<(i32, GroupBrief)> = sqlx::query_as::<_, (i32, i32, String, Option, Option, Option)>( + "SELECT user_groups.group_id, groups.id, groups.name, groups.color, groups.created_at, groups.updated_at + FROM users + LEFT JOIN user_groups ON user_groups.user_id = users.id + LEFT JOIN groups ON user_groups.group_id = groups.id + WHERE users.id = $1 AND groups.id IS NOT NULL", + ) + .bind(user_id) + .fetch_all(db) + .await? + .into_iter() + .map(|(group_id, id, name, color, created_at, updated_at)| { + (group_id, GroupBrief { id, name, color, created_at, updated_at }) + }) + .collect(); + let user_groups = rows + .into_iter() + .map(|(group_id, group)| UserGroup { + user_id: user_id.to_string(), + group_id, + group, + }) + .collect(); + Ok(Some(CurrentUser { user, user_groups })) +} + +/// Identifiant de session du cookie signé, si la signature est bonne +pub fn session_id(headers: &HeaderMap, secret: &str) -> Option { + let raw = cookie_value(headers, SESSION_COOKIE)?; + unsign(&percent_decode(&raw)?, secret) +} + +/// Premier cookie de ce nom, comme le paquet cookie utilisé par express-session +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(axum::http::header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|pair| pair.split_once('=')) + .find(|(key, _)| key.trim() == name) + .map(|(_, value)| value.trim().trim_matches('"').to_string()) +} + +/// decodeURIComponent ; valeur brute si l'encodage est invalide (paquet cookie) +fn percent_decode(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let decoded = bytes + .get(i + 1..i + 3) + .and_then(|hex| std::str::from_utf8(hex).ok()) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match decoded { + Some(byte) => { + out.push(byte); + i += 3; + } + None => return Some(input.to_string()), + } + } else { + out.push(bytes[i]); + i += 1; + } + } + Some(String::from_utf8(out).unwrap_or_else(|_| input.to_string())) +} + +/// « s:. », signature = HMAC-SHA256(secret, sid) en base64 sans « = » +/// (paquet cookie-signature) +fn unsign(value: &str, secret: &str) -> Option { + let signed = value.strip_prefix("s:")?; + let (sid, signature) = signed.rsplit_once('.')?; + let expected = sign(sid, secret); + if expected.as_bytes().ct_eq(signature.as_bytes()).into() { + Some(sid.to_string()) + } else { + None + } +} + +pub fn sign(sid: &str, secret: &str) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepte toute clé"); + mac.update(sid.as_bytes()); + base64::engine::general_purpose::STANDARD + .encode(mac.finalize().into_bytes()) + .trim_end_matches('=') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + // Valeur produite par Node : encodeURIComponent('s:' + cookieSignature.sign(sid, secret)) + const SID: &str = "wWepyClqhhFUoW9EuP9PHnH9zV0N0SHv"; + const SECRET: &str = "banc-secret-de-session"; + const NODE_COOKIE: &str = + "s%3AwWepyClqhhFUoW9EuP9PHnH9zV0N0SHv.9iMe%2BgwWTIz9RKnc97t6VsEcCzLrdYtWzVPRpG3dTT0"; + + fn headers(cookie: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, HeaderValue::from_str(cookie).unwrap()); + headers + } + + #[test] + fn lit_le_cookie_signe_par_node() { + let cookies = headers(&format!("autre=1; connect.sid={NODE_COOKIE}")); + assert_eq!(session_id(&cookies, SECRET).as_deref(), Some(SID)); + assert_eq!( + format!("{SID}.{}", sign(SID, SECRET)), + "wWepyClqhhFUoW9EuP9PHnH9zV0N0SHv.9iMe+gwWTIz9RKnc97t6VsEcCzLrdYtWzVPRpG3dTT0" + ); + } + + #[test] + fn refuse_un_autre_secret_ou_une_signature_falsifiee() { + assert_eq!( + session_id(&headers(&format!("connect.sid={NODE_COOKIE}")), "autre-secret"), + None + ); + assert_eq!(session_id(&headers("connect.sid=s%3Aabc.ZmF1c3Nl"), SECRET), None); + assert_eq!(session_id(&headers("connect.sid=abc"), SECRET), None); + } +} diff --git a/server-rs/src/clock.rs b/server-rs/src/clock.rs new file mode 100644 index 0000000..be43a9e --- /dev/null +++ b/server-rs/src/clock.rs @@ -0,0 +1,69 @@ +// Date et heure du jour à Paris (décision R5) : les magasins sont en France, et +// l'ancien serveur Node, qui comptait en UTC, rendait la date de la veille entre +// minuit et 1 h (2 h l'été). Les instants enregistrés en base restent en UTC ; +// seul « aujourd'hui » (et ce qui s'en déduit : année, mois, heure de la +// sauvegarde automatique) se calcule ici. + +use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use chrono_tz::{Europe::Paris, Tz}; + +/// Instant présent, à l'heure de Paris +pub fn now() -> DateTime { + Utc::now().with_timezone(&Paris) +} + +/// Date du jour à Paris +pub fn today() -> NaiveDate { + now().date_naive() +} + +/// Date à Paris d'un instant enregistré en UTC (colonne timestamp sans fuseau) +pub fn paris_date(utc: NaiveDateTime) -> NaiveDate { + Utc.from_utc_datetime(&utc).with_timezone(&Paris).date_naive() +} + +/// Instant UTC (sans fuseau, comme les colonnes) du début d'un jour de Paris +pub fn start_of_day_utc(day: NaiveDate) -> NaiveDateTime { + let midnight = day.and_hms_opt(0, 0, 0).expect("minuit existe"); + // Minuit existe toujours à Paris (les changements d'heure ont lieu à 2 h et 3 h) + Paris + .from_local_datetime(&midnight) + .earliest() + .map(|local| local.naive_utc()) + .unwrap_or(midnight) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utc(text: &str) -> NaiveDateTime { + NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S").unwrap() + } + + #[test] + fn date_de_paris() { + // Été (UTC+2) : 23 h 30 UTC, c'est déjà le lendemain à Paris + assert_eq!( + paris_date(utc("2026-07-14 23:30:00")), + NaiveDate::from_ymd_opt(2026, 7, 15).unwrap() + ); + // Hiver (UTC+1) + assert_eq!( + paris_date(utc("2026-12-31 22:59:59")), + NaiveDate::from_ymd_opt(2026, 12, 31).unwrap() + ); + assert_eq!( + paris_date(utc("2026-12-31 23:00:00")), + NaiveDate::from_ymd_opt(2027, 1, 1).unwrap() + ); + } + + #[test] + fn debut_du_jour_en_utc() { + let day = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(); + assert_eq!(start_of_day_utc(day), utc("2026-12-31 23:00:00")); + let day = NaiveDate::from_ymd_opt(2026, 7, 15).unwrap(); + assert_eq!(start_of_day_utc(day), utc("2026-07-14 22:00:00")); + } +} diff --git a/server-rs/src/config.rs b/server-rs/src/config.rs new file mode 100644 index 0000000..5e52d93 --- /dev/null +++ b/server-rs/src/config.rs @@ -0,0 +1,60 @@ +// Configuration lue dans l'environnement, mêmes variables que le serveur Node. + +use std::env; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Config { + pub port: u16, + pub database_url: String, + /// Interface construite par Vite (dist/public) + pub static_dir: PathBuf, + pub session_secret: String, + /// Clé de chiffrement des secrets (à défaut SESSION_SECRET), comme server/crypto.ts + pub encryption_key: String, + pub environment: String, + /// Dossier des sauvegardes pg_dump, comme server/backupService.ts : BACKUP_DIR + /// (à défaut /app/backups) en production, ./backups sinon + pub backup_dir: PathBuf, + /// Origines autorisées en plus de l'hôte du serveur pour les requêtes + /// d'écriture (ALLOWED_ORIGINS, séparées par des virgules), voir origin.rs + pub allowed_origins: Vec, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + let session_secret = env::var("SESSION_SECRET").unwrap_or_else(|_| "fallback-secret-key".into()); + let encryption_key = env::var("ENCRYPTION_KEY") + .ok() + .filter(|key| !key.is_empty()) + .or_else(|| env::var("SESSION_SECRET").ok().filter(|key| !key.is_empty())) + .unwrap_or_else(|| "logiflow-fallback-secret-key".into()); + let environment = env::var("NODE_ENV").unwrap_or_else(|_| "production".into()); + let backup_dir = if environment == "production" { + env::var("BACKUP_DIR") + .ok() + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/app/backups")) + } else { + env::current_dir().unwrap_or_default().join("backups") + }; + Ok(Self { + port: env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(3000), + database_url: env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL manquant"))?, + static_dir: env::var("STATIC_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("dist/public")), + session_secret, + encryption_key, + environment, + backup_dir, + allowed_origins: env::var("ALLOWED_ORIGINS") + .unwrap_or_default() + .split(',') + .map(|origin| origin.trim().trim_end_matches('/').to_ascii_lowercase()) + .filter(|origin| !origin.is_empty()) + .collect(), + }) + } +} diff --git a/server-rs/src/crypto.rs b/server-rs/src/crypto.rs new file mode 100644 index 0000000..617de41 --- /dev/null +++ b/server-rs/src/crypto.rs @@ -0,0 +1,92 @@ +// Secrets chiffrés au repos (mot de passe SMTP des magasins, jeton NocoDB), +// compatibles avec server/crypto.ts : AES-256-GCM, clé = SHA-256 de +// ENCRYPTION_KEY (à défaut SESSION_SECRET), format enc:v1::: +// en base64. Une valeur en clair (héritée) est rendue telle quelle. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +const PREFIX: &str = "enc:v1:"; + +fn cipher(secret: &str) -> Aes256Gcm { + let key = Sha256::digest(secret.as_bytes()); + Aes256Gcm::new_from_slice(&key).expect("clé de 32 octets") +} + +pub fn is_encrypted(value: &str) -> bool { + value.starts_with(PREFIX) +} + +/// encryptSecret : valeur vide ou déjà chiffrée rendue telle quelle +pub fn encrypt(value: &str, secret: &str) -> String { + if value.is_empty() || is_encrypted(value) { + return value.to_string(); + } + let mut iv = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut iv); + let sealed = cipher(secret) + .encrypt(Nonce::from_slice(&iv), value.as_bytes()) + .expect("chiffrement AES-GCM"); + // aes-gcm place l'étiquette (16 octets) à la fin ; Node la stocke à part + let (data, tag) = sealed.split_at(sealed.len() - 16); + format!( + "{PREFIX}{}:{}:{}", + STANDARD.encode(iv), + STANDARD.encode(tag), + STANDARD.encode(data) + ) +} + +/// decryptSecret : Err avec le message de Node si la valeur est illisible +pub fn decrypt(value: &str, secret: &str) -> Result { + if !is_encrypted(value) { + return Ok(value.to_string()); + } + let parts: Vec<&str> = value[PREFIX.len()..].split(':').collect(); + let [iv, tag, data] = parts.as_slice() else { + return Err("Secret chiffré illisible : format inattendu"); + }; + const UNREADABLE: &str = + "Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur."; + let decode = |part: &str| STANDARD.decode(part).map_err(|_| UNREADABLE); + let (iv, tag, mut data) = (decode(iv)?, decode(tag)?, decode(data)?); + if iv.len() != 12 { + return Err(UNREADABLE); + } + data.extend_from_slice(&tag); + let plain = cipher(secret) + .decrypt(Nonce::from_slice(&iv), data.as_ref()) + .map_err(|_| UNREADABLE)?; + String::from_utf8(plain).map_err(|_| UNREADABLE) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Chiffré par server/crypto.ts avec ENCRYPTION_KEY=banc-cle-de-chiffrement + const FROM_NODE: &str = "enc:v1:n8TrNmt+VyaEal3x:gYKLZrnmBiGakpe8TI0Klg==:mnA5kYLSoJAdXxU3j1lb3g=="; + + #[test] + fn dechiffre_une_valeur_chiffree_par_node() { + assert_eq!( + decrypt(FROM_NODE, "banc-cle-de-chiffrement").unwrap(), + "smtp-secret-banc" + ); + assert!(decrypt(FROM_NODE, "autre-cle").is_err()); + } + + #[test] + fn aller_retour_et_valeurs_en_clair() { + let sealed = encrypt("jeton", "cle"); + assert!(is_encrypted(&sealed)); + assert_eq!(decrypt(&sealed, "cle").unwrap(), "jeton"); + assert_eq!(encrypt(&sealed, "cle"), sealed); + assert_eq!(decrypt("en clair", "cle").unwrap(), "en clair"); + assert_eq!(encrypt("", "cle"), ""); + } +} diff --git a/server-rs/src/db.rs b/server-rs/src/db.rs new file mode 100644 index 0000000..c1ebd75 --- /dev/null +++ b/server-rs/src/db.rs @@ -0,0 +1,313 @@ +// Accès générique aux tables décrites dans schema.rs (généré), au comportement de +// Drizzle : insert().values(), update().set().returning(), select().from(). +// +// Comme node-pg, chaque valeur part en texte et PostgreSQL la convertit vers le +// type de la colonne ($1::date, $2::numeric…) : mêmes conversions, mêmes erreurs. + +use serde_json::Value; +use sqlx::postgres::{PgArguments, PgRow}; +use sqlx::{Arguments, FromRow, PgPool}; + +use crate::zod::Parsed; + +/// Colonne : clé JSON (Drizzle), nom SQL, type SQL +#[derive(Debug, Clone, Copy)] +pub struct Col { + pub key: &'static str, + pub column: &'static str, + pub sql_type: &'static str, +} + +pub trait Table: for<'r> FromRow<'r, PgRow> + Send + Unpin { + const TABLE: &'static str; + /// Colonnes dans l'ordre de shared/schema.ts, entre guillemets + const COLUMNS: &'static str; + const FIELDS: &'static [Col]; + + fn field(key: &str) -> Option<&'static Col> { + Self::FIELDS.iter().find(|col| col.key == key) + } + + /// SELECT des colonnes de la table, suivi de `rest` (WHERE, ORDER BY…) + fn select(rest: &str) -> String { + format!("SELECT {} FROM {} {rest}", Self::COLUMNS, Self::TABLE) + } +} + +/// Valeur JavaScript telle que node-pg l'envoie (prepareValue) : texte, ou NULL +pub fn pg_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::Bool(flag) => Some(flag.to_string()), + Value::Number(number) => Some(match number.as_f64() { + Some(float) => crate::js::number_to_string(float), + None => number.to_string(), + }), + Value::String(text) => Some(text.clone()), + other => Some(other.to_string()), + } +} + +/// prepareValue de node-pg pour une valeur du corps passée telle quelle à une +/// colonne qui n'est pas jsonb (sans schéma zod ni conversion par Drizzle) : +/// tableau → littéral de tableau PostgreSQL, objet → JSON, autre → String(valeur) +pub fn node_pg_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::Array(items) => Some(pg_array_literal(items)), + Value::Object(_) => Some(value.to_string()), + other => Some(crate::js::string_of(other)), + } +} + +/// Nombre JavaScript envoyé par node-pg à une colonne integer (String(nombre)) : +/// la valeur si PostgreSQL l'accepte, None s'il refuse (NaN, décimal, hors limites) +pub fn js_int4(number: f64) -> Option { + let integral = number.is_finite() && number.fract() == 0.0; + (integral && number >= f64::from(i32::MIN) && number <= f64::from(i32::MAX)).then_some(number as i32) +} + +/// parseInt(texte) envoyé à une colonne integer (identifiants, magasins) +pub fn parse_int4(text: &str) -> Option { + crate::js::parse_int(text).and_then(js_int4) +} + +/// arrayString de node-pg : {"a","b",NULL,{"c"}} +fn pg_array_literal(items: &[Value]) -> String { + let elements: Vec = items + .iter() + .map(|item| match item { + Value::Null => "NULL".to_string(), + Value::Array(nested) => pg_array_literal(nested), + other => { + let text = node_pg_text(other).unwrap_or_default(); + format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) + } + }) + .collect(); + format!("{{{}}}", elements.join(",")) +} + +/// Une ligne par son identifiant (colonne id) +pub async fn find_by_id(db: &PgPool, id: i32) -> Result, sqlx::Error> { + sqlx::query_as::<_, T>(&T::select("WHERE id = $1")) + .bind(id) + .fetch_optional(db) + .await +} + +/// Lignes dont l'identifiant figure dans `ids` : seconde moitié d'une jointure +/// (leftJoin de Drizzle), relue par identifiant pour garder les structures de schema.rs +pub async fn find_by_ids(db: &PgPool, ids: &[i32]) -> Result, sqlx::Error> { + sqlx::query_as::<_, T>(&T::select("WHERE id = ANY($1)")) + .bind(ids) + .fetch_all(db) + .await +} + +/// Colonnes de la table préfixées par `alias` (requête avec jointures, où « id » +/// serait ambigu) +pub fn columns_of(alias: &str) -> String { + T::COLUMNS + .split(", ") + .map(|column| format!("{alias}.{column}")) + .collect::>() + .join(", ") +} + +/// Objet de la requête passé tel quel à Drizzle, sans schéma zod +/// (db.insert(t).values({ ...req.body }), db.update(t).set({ ...req.body })) : +/// - les clés qui ne sont pas des colonnes sont ignorées ; +/// - une colonne timestamp attend un Date (mapToDriverValue appelle toISOString) : +/// toute valeur JSON autre que null lève une TypeError avant la requête, rendue ici +/// en Err avec le message de JavaScript ; +/// - jsonb : JSON.stringify ; autres colonnes : valeur préparée par node-pg. +pub fn drizzle_values(data: &serde_json::Map) -> Result { + let mut parsed = Parsed::new(); + for col in T::FIELDS { + let Some(value) = data.get(col.key) else { + continue; + }; + let prepared = match (col.sql_type, value) { + (_, Value::Null) => Value::Null, + ("timestamp", _) => return Err("value.toISOString is not a function".into()), + ("jsonb", other) => Value::String(other.to_string()), + (_, other) => node_pg_value(other), + }; + parsed.insert(col.key.to_string(), prepared); + } + Ok(parsed) +} + +/// prepareValue de node-pg pour une valeur JSON : un tableau devient un littéral de +/// tableau PostgreSQL ({"a","b"}), le reste part tel quel (pg_text) +pub fn node_pg_value(value: &Value) -> Value { + match value { + Value::Array(items) => Value::String(pg_array_literal(items)), + other => other.clone(), + } +} + +/// db.insert(table).values(data).returning() : les champs absents prennent la +/// valeur par défaut de la colonne +pub async fn insert(db: &PgPool, data: &Parsed) -> Result { + insert_with::(db, data, false).await +} + +/// Comme `insert`, mais les valeurs sont converties avant le calcul des valeurs +/// par défaut, comme node-pg dont PostgreSQL lit les paramètres dès leur +/// réception : une valeur refusée (date, nombre illisible) ne consomme pas de +/// numéro de la séquence de l'identifiant, et la ligne créée ensuite a le même +/// identifiant que sous Node. Une erreur d'exécution (clé étrangère) en consomme +/// un, comme sous Node. +pub async fn insert_values_first(db: &PgPool, data: &Parsed) -> Result { + insert_with::(db, data, true).await +} + +async fn insert_with(db: &PgPool, data: &Parsed, values_first: bool) -> Result { + let mut columns = Vec::new(); + let mut placeholders = Vec::new(); + let mut arguments = PgArguments::default(); + for col in T::FIELDS { + if let Some(value) = data.get(col.key) { + columns.push(format!("\"{}\"", col.column)); + arguments.add(pg_text(value)).map_err(sqlx::Error::Encode)?; + placeholders.push(format!("${}::{}", columns.len(), col.sql_type)); + } + } + let sql = if columns.is_empty() { + format!("INSERT INTO {} DEFAULT VALUES RETURNING {}", T::TABLE, T::COLUMNS) + } else if values_first { + // CTE matérialisée : ses conversions s'exécutent avant la projection de + // l'insertion, où nextval() est appelé + let converted: Vec = placeholders + .iter() + .zip(&columns) + .map(|(placeholder, column)| format!("{placeholder} AS {column}")) + .collect(); + format!( + "WITH converted AS MATERIALIZED (SELECT {}) INSERT INTO {} ({}) SELECT {} FROM converted RETURNING {}", + converted.join(", "), + T::TABLE, + columns.join(", "), + columns.join(", "), + T::COLUMNS + ) + } else { + format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING {}", + T::TABLE, + columns.join(", "), + placeholders.join(", "), + T::COLUMNS + ) + }; + sqlx::query_as_with::<_, T, _>(&sql, arguments) + .fetch_one(db) + .await +} + +/// db.update(table).set({ ...data, updatedAt: new Date() }).where(id).returning() +/// None si aucune ligne n'a cet identifiant +pub async fn update_by_id( + db: &PgPool, + id: i32, + data: &Parsed, + touch_updated_at: bool, +) -> Result, sqlx::Error> { + let mut sets = Vec::new(); + let mut arguments = PgArguments::default(); + let mut count = 0; + for col in T::FIELDS { + let value = if touch_updated_at && col.key == "updatedAt" { + Some(Value::String(crate::modules::now_iso())) + } else { + data.get(col.key).cloned() + }; + if let Some(value) = value { + count += 1; + arguments.add(pg_text(&value)).map_err(sqlx::Error::Encode)?; + sets.push(format!("\"{}\" = ${count}::{}", col.column, col.sql_type)); + } + } + if sets.is_empty() { + // Drizzle refuse un set() vide (« No values to set ») + return Err(sqlx::Error::Protocol("No values to set".into())); + } + arguments.add(id).map_err(sqlx::Error::Encode)?; + let sql = format!( + "UPDATE {} SET {} WHERE id = ${} RETURNING {}", + T::TABLE, + sets.join(", "), + count + 1, + T::COLUMNS + ); + sqlx::query_as_with::<_, T, _>(&sql, arguments) + .fetch_optional(db) + .await +} + +/// db.delete(table).where(eq(table.id, id)) +pub async fn delete_by_id(db: &PgPool, id: i32) -> Result { + let sql = format!("DELETE FROM {} WHERE id = $1", T::TABLE); + Ok(sqlx::query(&sql).bind(id).execute(db).await?.rows_affected()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::CustomerOrdersRow; + use serde_json::json; + + fn object(value: Value) -> serde_json::Map { + value.as_object().cloned().unwrap() + } + + #[test] + fn valeurs_comme_node_pg() { + // Valeurs de require('pg/lib/utils').prepareValue dans Node + assert_eq!(node_pg_text(&json!(null)), None); + assert_eq!(node_pg_text(&json!(2030.5)).as_deref(), Some("2030.5")); + assert_eq!(node_pg_text(&json!(true)).as_deref(), Some("true")); + assert_eq!(node_pg_text(&json!({ "a": 1 })).as_deref(), Some("{\"a\":1}")); + assert_eq!( + node_pg_text(&json!(["Spécial", "jar\"din", null, [1, 2], { "b": "c\\d" }])).as_deref(), + Some(r#"{"Spécial","jar\"din",NULL,{"1","2"},"{\"b\":\"c\\\\d\"}"}"#) + ); + } + + #[test] + fn entiers_acceptes_par_postgresql() { + assert_eq!(parse_int4("12abc"), Some(12)); + assert_eq!(parse_int4("-0"), Some(0)); + assert_eq!(parse_int4("abc"), None); + assert_eq!(parse_int4("99999999999"), None); + assert_eq!(js_int4(1.5), None); + assert_eq!(js_int4(f64::NAN), None); + assert_eq!(js_int4(-2147483648.0), Some(i32::MIN)); + } + + #[test] + fn corps_brut_comme_drizzle() { + let parsed = drizzle_values::(&object(json!({ + "inconnu": 1, "notes": ["a", "b\"c", null], "quantity": "2", "notifiedAt": null, "groupId": 1 + }))) + .unwrap(); + assert_eq!( + Value::Object(parsed), + json!({ "quantity": "2", "notifiedAt": null, "notes": r#"{"a","b\"c",NULL}"#, "groupId": 1 }) + ); + } + + #[test] + fn horodatage_en_texte_refuse() { + let error = + drizzle_values::(&object(json!({ "createdAt": "2026-10-15" }))).unwrap_err(); + assert_eq!(error, "value.toISOString is not a function"); + } + + #[test] + fn colonnes_prefixees() { + assert!(columns_of::("co").starts_with("co.\"id\", co.\"order_taker\"")); + } +} diff --git a/server-rs/src/error.rs b/server-rs/src/error.rs new file mode 100644 index 0000000..2711ffe --- /dev/null +++ b/server-rs/src/error.rs @@ -0,0 +1,87 @@ +// Erreurs de l'API : { "message": … } avec le code HTTP, comme les gestionnaires Node. + +use axum::Json; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde_json::{Value, json}; + +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub body: Value, +} + +pub type ApiResult = Result; + +impl AppError { + pub fn new(status: StatusCode, message: &str) -> Self { + Self { + status, + body: json!({ "message": message }), + } + } + pub fn with_body(status: StatusCode, body: Value) -> Self { + Self { status, body } + } + /// requireAuth (server/localAuth.ts) + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "Authentification requise") + } + pub fn bad_request(message: &str) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + pub fn forbidden(message: &str) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + pub fn not_found(message: &str) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + pub fn internal(message: &str) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } + /// Erreur zod renvoyée telle quelle : { message, errors } + pub fn validation(message: &str, issues: Vec) -> Self { + Self::with_body( + StatusCode::BAD_REQUEST, + json!({ "message": message, "errors": issues }), + ) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, Json(self.body)).into_response() + } +} + +/// Erreur imprévue (base, réseau) : journalisée, puis le message 500 du gestionnaire Node +pub trait OrInternal { + fn or_500(self, message: &str) -> Result; +} + +impl OrInternal for Result { + fn or_500(self, message: &str) -> Result { + self.map_err(|error| { + tracing::error!(%error, "{message}"); + AppError::internal(message) + }) + } +} + +/// res.json(undefined) : 200, type JSON, corps vide (ligne absente après une mise à jour) +pub fn empty_json() -> Response { + let mut response = StatusCode::OK.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + response +} + +/// Réponse JSON d'une valeur sérialisable, ou corps vide si elle est absente +pub fn json_or_empty(value: Option) -> Response { + match value { + Some(value) => Json(value).into_response(), + None => empty_json(), + } +} diff --git a/server-rs/src/express_routes.rs b/server-rs/src/express_routes.rs new file mode 100644 index 0000000..3e1e275 --- /dev/null +++ b/server-rs/src/express_routes.rs @@ -0,0 +1,139 @@ +// Correspondance des adresses à la manière d'Express : insensible à la casse, et une +// barre oblique finale tolérée (« /api/Orders/ » sert « /api/orders »), alors +// qu'axum compare exactement. Quand aucune route ne correspond, on cherche celle +// qu'Express aurait choisie et on rejoue la requête sur l'adresse canonique ; les +// paramètres gardent leur casse d'origine, comme req.params. + +use std::sync::{LazyLock, OnceLock}; + +use axum::Router; +use axum::extract::Request; +use axum::http::Uri; +use axum::response::Response; +use tower::ServiceExt; + +include!(concat!(env!("OUT_DIR"), "/route_patterns.rs")); + +/// Motif en minuscules → motif déclaré +static MATCHER: LazyLock> = LazyLock::new(|| { + let mut matcher = matchit::Router::new(); + for pattern in ROUTE_PATTERNS { + if let Err(error) = matcher.insert(lowercase_static(pattern), *pattern) { + tracing::warn!(%error, pattern, "motif d'adresse ignoré"); + } + } + matcher +}); + +/// Adresse demandée, avant sa réécriture en adresse canonique +#[derive(Clone)] +pub struct OriginalPath(pub String); + +/// Routeur de l'API, pour rejouer une requête sur l'adresse canonique +static API: OnceLock = OnceLock::new(); + +pub fn install(api: Router) { + let _ = API.set(api); +} + +/// Segments fixes en minuscules, paramètres ({id}) inchangés +fn lowercase_static(pattern: &str) -> String { + pattern + .split('/') + .map(|segment| { + if segment.starts_with('{') { + segment.to_string() + } else { + segment.to_ascii_lowercase() + } + }) + .collect::>() + .join("/") +} + +/// Adresse qu'Express servirait pour `path`, si elle diffère de `path` +pub fn canonical_path(path: &str) -> Option { + let trimmed = match path.strip_suffix('/') { + Some(stripped) if !stripped.is_empty() && !stripped.ends_with('/') => stripped, + _ => path, + }; + let lowered = trimmed.to_ascii_lowercase(); + let matched = MATCHER.at(&lowered).ok()?; + let canonical = matched + .value + .split('/') + .zip(trimmed.split('/')) + .map(|(pattern, original)| { + if pattern.starts_with('{') { + original + } else { + pattern + } + }) + .collect::>() + .join("/"); + (canonical != path).then_some(canonical) +} + +/// Rejoue la requête sur l'adresse canonique ; rend la requête intacte s'il n'y en a pas +pub async fn redispatch(mut request: Request) -> Result { + let Some(api) = API.get() else { + return Err(request); + }; + let Some(path) = canonical_path(request.uri().path()) else { + return Err(request); + }; + let path_and_query = match request.uri().query() { + Some(query) => format!("{path}?{query}"), + None => path, + }; + let Ok(uri) = path_and_query.parse::() else { + return Err(request); + }; + let original = request.uri().path().to_string(); + request.extensions_mut().insert(OriginalPath(original)); + *request.uri_mut() = uri; + Ok(api + .clone() + .oneshot(request) + .await + .unwrap_or_else(|never| match never {})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adresses_comme_express() { + assert_eq!( + canonical_path("/api/ext/v1/stores/").as_deref(), + Some("/api/ext/v1/stores") + ); + assert_eq!( + canonical_path("/api/ext/v1/SUPPLIERS").as_deref(), + Some("/api/ext/v1/suppliers") + ); + assert_eq!( + canonical_path("/api/ext/v1/Deliveries/5/").as_deref(), + Some("/api/ext/v1/deliveries/5") + ); + assert_eq!( + canonical_path("/API/Backups/Backup_A.SQL/download").as_deref(), + Some("/api/backups/Backup_A.SQL/download") + ); + // Déjà canonique, ou deux barres finales (refusées par Express) : rien à faire + assert_eq!(canonical_path("/api/orders"), None); + assert_eq!(canonical_path("/api/orders//"), None); + assert_eq!(canonical_path("/api/inconnue/"), None); + } + + #[test] + fn motifs_extraits_des_modules() { + assert!(ROUTE_PATTERNS.contains(&"/api/orders")); + assert!(ROUTE_PATTERNS.contains(&"/api/dlc-products/{id}/stock-epuise")); + assert!(ROUTE_PATTERNS.contains(&"/api/health")); + // Une entrée par adresse, toutes méthodes confondues + assert!(ROUTE_PATTERNS.len() > 80, "{} motifs", ROUTE_PATTERNS.len()); + } +} diff --git a/server-rs/src/http.rs b/server-rs/src/http.rs new file mode 100644 index 0000000..6626a54 --- /dev/null +++ b/server-rs/src/http.rs @@ -0,0 +1,116 @@ +// En-têtes des réponses de l'API servies par Rust, identiques à celles d'Express : +// - Cache-Control: private, no-cache (server/routes.ts, middleware /api), sauf +// sous /api/ext/v1 (API externe, enregistrée avant ce middleware) ; +// - JSON en « application/json; charset=utf-8 » ; +// - ETag faible calculé sur le corps (paquet etag d'Express) et 304 si le +// navigateur a déjà la même réponse. + +use axum::body::{Body, to_bytes}; +use axum::extract::Request; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use sha1::{Digest, Sha1}; + +// Au-delà, le corps n'est pas mis en mémoire pour l'ETag (exports volumineux) +const ETAG_MAX_BODY: usize = 50 * 1024 * 1024; + +pub async fn api_response_headers(request: Request, next: Next) -> Response { + let method = request.method().clone(); + let request_headers = request.headers().clone(); + let external_api = is_external_api(request.uri().path()); + let response = next.run(request).await; + let (mut parts, body) = response.into_parts(); + + if !external_api { + parts + .headers + .entry(header::CACHE_CONTROL) + .or_insert(HeaderValue::from_static("private, no-cache")); + } + if parts + .headers + .get(header::CONTENT_TYPE) + .is_some_and(|v| v == "application/json") + { + parts.headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + } + if parts.headers.contains_key(header::ETAG) || parts.headers.contains_key(header::CONTENT_DISPOSITION) { + return Response::from_parts(parts, body); + } + + let bytes = match to_bytes(body, ETAG_MAX_BODY).await { + Ok(bytes) => bytes, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Réponse trop volumineuse").into_response(), + }; + let etag = express_etag(&bytes); + if let Ok(value) = HeaderValue::from_str(&etag) { + parts.headers.insert(header::ETAG, value); + } + if matches!(method, Method::GET | Method::HEAD) + && (parts.status.is_success() || parts.status == StatusCode::NOT_MODIFIED) + && is_fresh(&request_headers, &etag) + { + parts.status = StatusCode::NOT_MODIFIED; + for name in [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::TRANSFER_ENCODING, + ] { + parts.headers.remove(name); + } + return Response::from_parts(parts, Body::empty()); + } + Response::from_parts(parts, Body::from(bytes)) +} + +/// Adresse de l'API externe (/api/ext/v1…) : Node l'enregistre avant son +/// middleware /api, ses réponses n'ont donc pas de Cache-Control +fn is_external_api(path: &str) -> bool { + let prefix = crate::modules::external_api::EXTERNAL_API_PREFIX; + path.strip_prefix(prefix) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) +} + +/// W/"-", comme etag(body, { weak: true }) +pub fn express_etag(body: &[u8]) -> String { + let hash = base64::engine::general_purpose::STANDARD.encode(Sha1::digest(body)); + format!("W/\"{:x}-{}\"", body.len(), &hash[..27]) +} + +fn is_fresh(request: &HeaderMap, etag: &str) -> bool { + let cache_control = request + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if cache_control.contains("no-cache") { + return false; + } + let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) else { + return false; + }; + let bare = etag.trim_start_matches("W/"); + none_match.trim() == "*" + || none_match + .split(',') + .any(|candidate| candidate.trim().trim_start_matches("W/") == bare) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etag_identique_a_express() { + // Valeurs produites par require('etag')(…, { weak: true }) dans Node + assert_eq!(express_etag(b""), "W/\"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk\""); + assert_eq!( + express_etag(b"{\"showDefault\":false}"), + express_etag(b"{\"showDefault\":false}") + ); + } +} diff --git a/server-rs/src/jobs.rs b/server-rs/src/jobs.rs new file mode 100644 index 0000000..54cf66d --- /dev/null +++ b/server-rs/src/jobs.rs @@ -0,0 +1,197 @@ +// Tâches de démarrage et de fond, reprises du serveur Node : +// - compte administrateur par défaut (createDefaultAdminUser, server/localAuth.ts) ; +// - configuration météo depuis VISUAL_CROSSING_API_KEY (server/weatherAutoConfig.ts) ; +// - sauvegarde automatique quotidienne, vérifiée toutes les heures (server/backupService.ts) ; +// - purge du cache de vérification des factures toutes les 6 h (server/maintenance.ts). +// Comme dans Node, une tâche en échec est journalisée sans arrêter le serveur. Le +// serveur tourne en UTC (image Docker sans TZ) : « 2 h » et « aujourd'hui » sont en UTC. + +use std::time::Duration; + +use chrono::{NaiveDateTime, Timelike, Utc}; +use sqlx::PgPool; + +use crate::AppState; +use crate::modules::{backups, session_routes, weather}; + +const BACKUP_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); +const CACHE_PURGE_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +const CACHE_PURGE_FIRST_DELAY: Duration = Duration::from_secs(60); + +/// Lance les tâches de fond (sauvegarde, purge, configuration météo) +pub fn spawn(state: AppState) { + tokio::spawn(initialize_weather_config(state.db.clone())); + tokio::spawn(backup_scheduler(state.clone())); + tokio::spawn(cache_purge(state.db.clone())); +} + +/// Compte « admin » créé s'il n'existe pas (mot de passe « admin », à changer à la +/// première connexion), ou mot de passe remis au bon format s'il est illisible. +/// La valeur « admin.salt », acceptée par l'accès de développement de Node pour le +/// mot de passe « admin », est remplacée par une vraie empreinte du même mot de passe. +pub async fn ensure_default_admin(db: &PgPool) { + let existing: Result)>, sqlx::Error> = + sqlx::query_as("SELECT id, password FROM users WHERE username = 'admin'") + .fetch_optional(db) + .await; + let result = match existing { + Ok(None) => { + let password = session_routes::hash_password("admin".into()).await; + sqlx::query( + "INSERT INTO users (id, username, email, first_name, last_name, password, role, password_changed) + VALUES ('admin_local', 'admin', 'admin@logiflow.com', 'Administrateur', 'Système', $1, 'admin', false)", + ) + .bind(password) + .execute(db) + .await + .map(|_| tracing::info!("compte admin créé (mot de passe par défaut : admin)")) + } + Ok(Some((id, password))) => { + let unreadable = password + .as_deref() + .is_none_or(|p| !p.contains('.') || p == "admin.salt"); + if unreadable { + let password = session_routes::hash_password("admin".into()).await; + sqlx::query( + "UPDATE users SET password = $1, password_changed = false, updated_at = $2 WHERE id = $3", + ) + .bind(password) + .bind(now_ms()) + .bind(id) + .execute(db) + .await + .map(|_| tracing::info!("mot de passe du compte admin remis au bon format")) + } else { + Ok(()) + } + } + Err(error) => Err(error), + }; + if let Err(error) = result { + tracing::error!(%error, "vérification du compte admin en échec"); + } +} + +/// Instant présent à la milliseconde, comme new Date() écrit par Drizzle +fn now_ms() -> NaiveDateTime { + let now = Utc::now().naive_utc(); + now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000) + .unwrap_or(now) +} + +/// initializeWeatherConfig : configuration active déjà présente, ou clé d'API de +/// l'environnement testée puis enregistrée pour « Nancy, France » +async fn initialize_weather_config(db: PgPool) { + let settings = match weather::weather_settings(&db).await { + Ok(settings) => settings, + Err(error) => { + tracing::error!(%error, "configuration météo illisible"); + return; + } + }; + if settings.as_ref().is_some_and(|s| s.is_active) { + return; + } + let Some(api_key) = std::env::var("VISUAL_CROSSING_API_KEY") + .ok() + .filter(|key| !key.is_empty()) + else { + return; + }; + const LOCATION: &str = "Nancy, France"; + let (success, message) = weather::test_api_connection(&api_key, LOCATION).await; + if !success { + tracing::error!(%message, "clé météo de l'environnement refusée"); + return; + } + let result = match settings { + Some(existing) => sqlx::query( + "UPDATE weather_settings SET api_key = $1, location = $2, is_active = true, updated_at = $3 WHERE id = $4", + ) + .bind(&api_key) + .bind(LOCATION) + .bind(now_ms()) + .bind(existing.id) + .execute(&db) + .await, + None => sqlx::query("INSERT INTO weather_settings (api_key, location, is_active) VALUES ($1, $2, true)") + .bind(&api_key) + .bind(LOCATION) + .execute(&db) + .await, + }; + match result { + Ok(_) => tracing::info!("météo configurée depuis VISUAL_CROSSING_API_KEY"), + Err(error) => tracing::error!(%error, "enregistrement de la configuration météo en échec"), + } +} + +/// Toutes les heures (et au démarrage) : après 2 h, une sauvegarde automatique par +/// jour, sauf si Paramètres les a désactivées +async fn backup_scheduler(state: AppState) { + let mut last_automatic_backup: Option = None; + let mut interval = tokio::time::interval(BACKUP_CHECK_INTERVAL); + loop { + interval.tick().await; + if let Err(error) = check_automatic_backup(&state, &mut last_automatic_backup).await { + tracing::error!(%error, "vérification de la sauvegarde automatique en échec"); + } + } +} + +async fn check_automatic_backup(state: &AppState, last_done: &mut Option) -> anyhow::Result<()> { + let enabled: Option<(Option,)> = + sqlx::query_as("SELECT automatic_backups_enabled FROM utilities LIMIT 1") + .fetch_optional(&state.db) + .await?; + if matches!(enabled, Some((Some(false),))) { + return Ok(()); + } + // Une fois par jour, à partir de 2 h à Paris (décision R5) + let now = crate::clock::now(); + let today = now.format("%Y-%m-%d").to_string(); + if now.hour() < 2 || last_done.as_deref() == Some(today.as_str()) { + return Ok(()); + } + let last_backup: Option<(Option,)> = sqlx::query_as( + "SELECT created_at FROM database_backups WHERE backup_type = 'automatic' ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&state.db) + .await?; + let last_backup_day = last_backup + .and_then(|(created,)| created) + .map(|created| crate::clock::paris_date(created).format("%Y-%m-%d").to_string()); + if last_backup_day.as_deref() == Some(today.as_str()) { + return Ok(()); + } + // Un administrateur, à défaut n'importe quel compte (clé étrangère created_by) + let user: Option<(String,)> = + sqlx::query_as("SELECT id FROM users ORDER BY (role = 'admin') DESC LIMIT 1") + .fetch_optional(&state.db) + .await?; + let Some((user_id,)) = user else { + tracing::warn!("aucun compte : sauvegarde automatique ignorée"); + return Ok(()); + }; + tracing::info!("sauvegarde automatique en cours"); + backups::create_backup(state, "automatic", &user_id).await?; + *last_done = Some(today); + tracing::info!("sauvegarde automatique terminée"); + Ok(()) +} + +/// Purge du cache de vérification des factures : 60 s après le démarrage, puis toutes les 6 h +async fn cache_purge(db: PgPool) { + tokio::time::sleep(CACHE_PURGE_FIRST_DELAY).await; + let mut interval = tokio::time::interval(CACHE_PURGE_INTERVAL); + loop { + interval.tick().await; + match sqlx::query("DELETE FROM invoice_verification_cache WHERE expires_at < NOW()") + .execute(&db) + .await + { + Ok(result) => tracing::info!(rows = result.rows_affected(), "cache des factures purgé"), + Err(error) => tracing::error!(%error, "purge du cache des factures en échec"), + } + } +} diff --git a/server-rs/src/js.rs b/server-rs/src/js.rs new file mode 100644 index 0000000..8fa27a8 --- /dev/null +++ b/server-rs/src/js.rs @@ -0,0 +1,542 @@ +// Conversions au comportement de JavaScript, sur lesquelles le code Node s'appuie +// (Number(), parseInt(), new Date(), longueur d'une chaîne, toISOString()). +// Le serveur Node tourne en UTC (image Docker sans TZ) : l'heure locale est l'UTC. + +use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use serde_json::{Number, Value}; + +const MAX_DATE_MS: f64 = 8.64e15; + +/// Number(valeur) +pub fn to_number(value: &Value) -> f64 { + match value { + Value::Null => 0.0, + Value::Bool(true) => 1.0, + Value::Bool(false) => 0.0, + Value::Number(number) => number.as_f64().unwrap_or(f64::NAN), + Value::String(text) => string_to_number(text), + Value::Array(items) => match items.as_slice() { + [] => 0.0, + [single] if !single.is_array() && !single.is_object() => match single { + Value::Null => 0.0, + other => to_number(&Value::String(display(other))), + }, + _ => f64::NAN, + }, + Value::Object(_) => f64::NAN, + } +} + +fn display(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn string_to_number(text: &str) -> f64 { + let trimmed = text.trim(); + if trimmed.is_empty() { + return 0.0; + } + let (sign, unsigned) = match trimmed.as_bytes()[0] { + b'-' => (-1.0, &trimmed[1..]), + b'+' => (1.0, &trimmed[1..]), + _ => (1.0, trimmed), + }; + if unsigned == "Infinity" { + return sign * f64::INFINITY; + } + // Préfixes 0x / 0o / 0b : sans signe en JavaScript + if sign == 1.0 && trimmed.len() > 2 && !trimmed.starts_with(['+', '-']) { + let radix = match &trimmed[..2] { + "0x" | "0X" => Some(16), + "0o" | "0O" => Some(8), + "0b" | "0B" => Some(2), + _ => None, + }; + if let Some(radix) = radix { + return u64::from_str_radix(&trimmed[2..], radix) + .map(|n| n as f64) + .unwrap_or(f64::NAN); + } + } + let valid = { + let bytes = unsigned.as_bytes(); + let mut i = 0; + let mut digits = 0; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + digits += 1; + } + if i < bytes.len() && bytes[i] == b'.' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + digits += 1; + } + } + if digits > 0 && i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + i += 1; + if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') { + i += 1; + } + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if start == i { + return f64::NAN; + } + } + digits > 0 && i == bytes.len() + }; + if !valid { + return f64::NAN; + } + unsigned.parse::().map(|n| sign * n).unwrap_or(f64::NAN) +} + +/// Valeur « vraie » en JavaScript (`if (x)`, `x || défaut`, `Boolean(x)`) : +/// fausse pour null, false, 0, NaN et la chaîne vide +pub fn truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(flag) => *flag, + Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()), + Value::String(text) => !text.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +/// parseInt(texte, 10) : chiffres de tête, None si aucun (NaN) +pub fn parse_int(text: &str) -> Option { + let trimmed = text.trim_start(); + let (sign, rest) = match trimmed.chars().next() { + Some('-') => (-1.0, &trimmed[1..]), + Some('+') => (1.0, &trimmed[1..]), + _ => (1.0, trimmed), + }; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + digits.parse::().ok().map(|n| sign * n) +} + +/// Blancs retirés par String.prototype.trim() (WhiteSpace et LineTerminator) : +/// U+FEFF en fait partie, U+0085 non, à l'inverse de str::trim +fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}' + ..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}' + ) +} + +/// texte.trim() +pub fn trim(text: &str) -> &str { + text.trim_matches(is_js_whitespace) +} + +/// String(valeur) : texte tel quel, nombre sans « .0 », tableau joint par « , », +/// objet « [object Object] » (gabarits `${…}`, clés d'objet) +pub fn to_js_string(value: &Value) -> String { + string_of(value) +} + +/// parseFloat(texte) : plus long préfixe décimal après les blancs de tête, NaN sinon +pub fn parse_float(text: &str) -> f64 { + let text = text.trim_start_matches(is_js_whitespace); + let (sign, unsigned) = match text.as_bytes().first() { + Some(b'-') => (-1.0, &text[1..]), + Some(b'+') => (1.0, &text[1..]), + _ => (1.0, text), + }; + if unsigned.starts_with("Infinity") { + return sign * f64::INFINITY; + } + let bytes = unsigned.as_bytes(); + let mut end = 0; + let mut digits = 0; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + digits += 1; + } + if end < bytes.len() && bytes[end] == b'.' { + let mut fraction_end = end + 1; + while fraction_end < bytes.len() && bytes[fraction_end].is_ascii_digit() { + fraction_end += 1; + digits += 1; + } + end = fraction_end; + } + if digits == 0 { + return f64::NAN; + } + // Exposant seulement s'il a au moins un chiffre (« 1e » vaut 1) + if end < bytes.len() && matches!(bytes[end], b'e' | b'E') { + let mut exponent_end = end + 1; + if exponent_end < bytes.len() && matches!(bytes[exponent_end], b'+' | b'-') { + exponent_end += 1; + } + let start = exponent_end; + while exponent_end < bytes.len() && bytes[exponent_end].is_ascii_digit() { + exponent_end += 1; + } + if exponent_end > start { + end = exponent_end; + } + } + unsigned[..end] + .parse::() + .map(|n| sign * n) + .unwrap_or(f64::NAN) +} + +/// encodeURIComponent(texte) : tout sauf A-Z a-z 0-9 - _ . ! ~ * ' ( ) en %XX (UTF-8) +pub fn encode_uri_component(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Buffer.from(texte, 'base64') : alphabets standard et « url » acceptés, autres +/// caractères ignorés, lecture arrêtée au premier « = » +pub fn buffer_from_base64(text: &str) -> Vec { + let mut out = Vec::with_capacity(text.len() * 3 / 4); + let (mut bits, mut count) = (0u32, 0u32); + for c in text.chars() { + let value = match c { + 'A'..='Z' => c as u32 - 'A' as u32, + 'a'..='z' => c as u32 - 'a' as u32 + 26, + '0'..='9' => c as u32 - '0' as u32 + 52, + '+' | '-' => 62, + '/' | '_' => 63, + '=' => break, + _ => continue, + }; + bits = (bits << 6) | value; + count += 6; + if count >= 8 { + count -= 8; + out.push((bits >> count) as u8); + bits &= (1 << count) - 1; + } + } + out +} + +/// Nombre JSON tel que JSON.stringify l'écrirait (entier sans « .0 ») +pub fn number_value(number: f64) -> Value { + if number.fract() == 0.0 && number.abs() <= 9_007_199_254_740_991.0 { + Value::Number(Number::from(number as i64)) + } else { + Number::from_f64(number).map(Value::Number).unwrap_or(Value::Null) + } +} + +/// String(nombre) pour les messages +pub fn number_to_string(number: f64) -> String { + number_string(number) +} + +/// String(nombre) complet (Number.prototype.toString) : -0 → « 0 », notation +/// exponentielle au-delà de 1e21 et en deçà de 1e-6, NaN et Infinity +pub fn number_string(number: f64) -> String { + if number.is_nan() { + return "NaN".into(); + } + if number.is_infinite() { + return if number > 0.0 { "Infinity" } else { "-Infinity" }.into(); + } + if number == 0.0 { + return "0".into(); + } + // Chiffres significatifs les plus courts et exposant, comme JavaScript + let scientific = format!("{:e}", number.abs()); + let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0")); + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + let k = digits.len() as i32; + let n = exponent.parse::().unwrap_or(0) + 1; + let body = if k <= n && n <= 21 { + format!("{digits}{}", "0".repeat((n - k) as usize)) + } else if 0 < n && n <= 21 { + format!("{}.{}", &digits[..n as usize], &digits[n as usize..]) + } else if -6 < n && n <= 0 { + format!("0.{}{digits}", "0".repeat((-n) as usize)) + } else { + let sign = if n > 0 { '+' } else { '-' }; + let (first, rest) = digits.split_at(1); + let fraction = if rest.is_empty() { + String::new() + } else { + format!(".{rest}") + }; + format!("{first}{fraction}e{sign}{}", (n - 1).abs()) + }; + if number < 0.0 { format!("-{body}") } else { body } +} + +/// String(valeur) d'une valeur JSON (gabarits `${…}`, Array.prototype.join) +pub fn string_of(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Bool(flag) => flag.to_string(), + Value::Number(number) => number_string(number.as_f64().unwrap_or(f64::NAN)), + Value::String(text) => text.clone(), + // [a, null, b].join(',') : null vide + Value::Array(items) => items + .iter() + .map(|item| { + if item.is_null() { + String::new() + } else { + string_of(item) + } + }) + .collect::>() + .join(","), + Value::Object(_) => "[object Object]".into(), + } +} + +/// Longueur d'une chaîne en JavaScript (unités UTF-16) +pub fn length(text: &str) -> usize { + text.encode_utf16().count() +} + +/// new Date(valeur), None si « Invalid Date » +pub fn new_date(value: &Value) -> Option> { + let millis = match value { + Value::Null => 0.0, + Value::Bool(flag) => f64::from(u8::from(*flag)), + Value::Number(number) => number.as_f64()?, + Value::String(text) => return parse_date(text), + _ => return None, + }; + from_millis(millis) +} + +fn from_millis(millis: f64) -> Option> { + if !millis.is_finite() || millis.abs() > MAX_DATE_MS { + return None; + } + Utc.timestamp_millis_opt(millis.trunc() as i64).single() +} + +/// Date.parse pour les formats envoyés par l'interface : ISO 8601 (date seule = UTC +/// minuit, date et heure sans fuseau = heure locale, donc UTC ici), et « AAAA-MM-JJ HH:MM:SS » +pub fn parse_date(text: &str) -> Option> { + let text = text.trim(); + if let Ok(date) = DateTime::parse_from_rfc3339(text) { + return Some(date.with_timezone(&Utc)); + } + if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m-%d") { + return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?)); + } + for format in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%d %H:%M", + ] { + if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) { + return Some(Utc.from_utc_datetime(&naive)); + } + } + for format in [ + "%Y-%m-%dT%H:%M:%S%.f%:z", + "%Y-%m-%dT%H:%M%:z", + "%Y-%m-%dT%H:%M:%S%.fZ", + "%Y-%m-%dT%H:%MZ", + ] { + if let Ok(date) = DateTime::parse_from_str(text, format) { + return Some(date.with_timezone(&Utc)); + } + if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) { + return Some(Utc.from_utc_datetime(&naive)); + } + } + if let Ok(date) = NaiveDate::parse_from_str(text, "%Y-%m") { + return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?)); + } + DateTime::parse_from_rfc2822(text) + .ok() + .map(|date| date.with_timezone(&Utc)) +} + +/// date.toISOString() +pub fn iso(date: &DateTime) -> String { + date.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn number_comme_javascript() { + assert_eq!(to_number(&json!("")), 0.0); + assert_eq!(to_number(&json!(" 12 ")), 12.0); + assert_eq!(to_number(&json!("1e3")), 1000.0); + assert_eq!(to_number(&json!("0x1A")), 26.0); + assert!(to_number(&json!("12abc")).is_nan()); + assert!(to_number(&json!("abc")).is_nan()); + assert_eq!(to_number(&json!(null)), 0.0); + assert_eq!(to_number(&json!(true)), 1.0); + assert_eq!(to_number(&json!([])), 0.0); + assert_eq!(to_number(&json!(["7"])), 7.0); + assert!(to_number(&json!({})).is_nan()); + assert_eq!(to_number(&json!("-3.5")), -3.5); + assert_eq!(to_number(&json!(".5")), 0.5); + } + + #[test] + fn verite_comme_javascript() { + for falsy in [json!(null), json!(false), json!(0), json!(0.0), json!("")] { + assert!(!truthy(&falsy), "{falsy}"); + } + for truthy_value in [json!(true), json!(-1), json!("0"), json!([]), json!({})] { + assert!(truthy(&truthy_value), "{truthy_value}"); + } + } + + #[test] + fn parse_int_comme_javascript() { + assert_eq!(parse_int("12abc"), Some(12.0)); + assert_eq!(parse_int(" -4"), Some(-4.0)); + assert_eq!(parse_int("abc"), None); + assert_eq!(parse_int(""), None); + } + + #[test] + fn dates_comme_javascript() { + assert_eq!( + iso(&new_date(&json!("2026-10-15")).unwrap()), + "2026-10-15T00:00:00.000Z" + ); + assert_eq!( + iso(&new_date(&json!("2026-10-15T08:30")).unwrap()), + "2026-10-15T08:30:00.000Z" + ); + assert_eq!( + iso(&new_date(&json!("2026-10-15T08:30:00.123+02:00")).unwrap()), + "2026-10-15T06:30:00.123Z" + ); + assert_eq!(iso(&new_date(&json!(null)).unwrap()), "1970-01-01T00:00:00.000Z"); + assert!(new_date(&json!("pas une date")).is_none()); + } + + #[test] + fn string_de_nombres_comme_javascript() { + // Valeurs de String(n) dans Node + for (number, expected) in [ + (0.0, "0"), + (-0.0, "0"), + (2026.0, "2026"), + (-3.0, "-3"), + (18.3, "18.3"), + (0.1 + 0.2, "0.30000000000000004"), + (1.2291666666666667, "1.2291666666666667"), + (123456789012345680000.0, "123456789012345680000"), + (1e21, "1e+21"), + (1.5e300, "1.5e+300"), + (0.000001, "0.000001"), + (1e-7, "1e-7"), + (-2.5e-8, "-2.5e-8"), + (f64::NAN, "NaN"), + (f64::NEG_INFINITY, "-Infinity"), + ] { + assert_eq!(number_string(number), expected, "{number}"); + } + } + + #[test] + fn string_et_verite_des_valeurs_json() { + assert_eq!(string_of(&json!([1, null, "a", [2, 3]])), "1,,a,2,3"); + assert_eq!(string_of(&json!({ "a": 1 })), "[object Object]"); + assert_eq!(string_of(&json!(null)), "null"); + assert_eq!(string_of(&json!(48.5)), "48.5"); + assert!(!truthy(&json!(0)) && !truthy(&json!("")) && !truthy(&json!(null))); + assert!(truthy(&json!("0")) && truthy(&json!([])) && truthy(&json!({}))); + } + + #[test] + fn parse_float_comme_javascript() { + assert_eq!(parse_float("18.3"), 18.3); + assert_eq!(parse_float(" -2.50abc"), -2.5); + assert_eq!(parse_float(".5"), 0.5); + assert_eq!(parse_float("5."), 5.0); + assert_eq!(parse_float("1e3x"), 1000.0); + assert_eq!(parse_float("1e"), 1.0); + assert_eq!(parse_float("-Infinityx"), f64::NEG_INFINITY); + assert!(parse_float("abc").is_nan()); + assert!(parse_float(".").is_nan()); + assert!(parse_float("").is_nan()); + } + + #[test] + fn parse_float_valeurs_de_node() { + // Valeurs relevées avec parseFloat() dans Node 22 + assert_eq!(parse_float("1250.5"), 1250.5); + assert_eq!(parse_float(" 12.5abc"), 12.5); + assert_eq!(parse_float("1 234,56"), 1.0); + assert!(parse_float("abc").is_nan()); + assert!(parse_float("").is_nan()); + assert_eq!(parse_float(".5"), 0.5); + assert_eq!(parse_float("-Infinity x"), f64::NEG_INFINITY); + assert_eq!(parse_float("1e3"), 1000.0); + assert_eq!(parse_float("0x10"), 0.0); + assert_eq!(parse_float("1e"), 1.0); + assert_eq!(parse_float("+3."), 3.0); + assert_eq!(parse_float("1.2.3"), 1.2); + assert_eq!(parse_float("\u{a0} 7"), 7.0); + } + + #[test] + fn chaines_comme_javascript() { + assert_eq!(trim(" \u{a0}\u{feff}x\u{85}"), "x\u{85}"); + assert_eq!(to_js_string(&json!(1.0)), "1"); + assert_eq!(to_js_string(&json!([1, null, "a"])), "1,,a"); + assert_eq!(to_js_string(&json!({})), "[object Object]"); + assert_eq!( + encode_uri_component("FAC 2001/é?&=#+()!*'~-_."), + "FAC%202001%2F%C3%A9%3F%26%3D%23%2B()!*'~-_." + ); + } + + #[test] + fn base64_comme_buffer_from() { + // Buffer.from(texte, 'base64').toString('hex') dans Node 22 + let hex = |text: &str| { + buffer_from_base64(text) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + }; + assert_eq!(hex("ab!cd"), "69b71d"); + assert_eq!(hex("QU JD"), "414243"); + assert_eq!(hex("Q=UJD"), ""); + assert_eq!(hex("QUJ=D"), "4142"); + assert_eq!(hex("QU-_"), "414fbf"); + assert_eq!(hex("QUJDRA==x"), "41424344"); + assert_eq!(hex("Q\u{e9}UJD"), "414243"); + assert_eq!(buffer_from_base64("JVBE Ri0x\nLjQ=").len(), 8); + assert_eq!(buffer_from_base64("a").len(), 0); + assert_eq!(buffer_from_base64("AA=A").len(), 1); + } + + #[test] + fn longueur_utf16() { + assert_eq!(length("été"), 3); + assert_eq!(length("😀"), 2); + } +} diff --git a/server-rs/src/json.rs b/server-rs/src/json.rs new file mode 100644 index 0000000..f179262 --- /dev/null +++ b/server-rs/src/json.rs @@ -0,0 +1,158 @@ +// Formats JSON de l'API Node, à reproduire à l'identique. + +use chrono::NaiveDateTime; +use rust_decimal::Decimal; +use serde::Serializer; + +/// Colonne timestamp lue par Drizzle : Date JavaScript, « 2026-10-15T08:30:00.000Z » +pub fn ts(value: &NaiveDateTime, serializer: S) -> Result { + serializer.serialize_str(&format_ts(value)) +} + +pub fn ts_opt(value: &Option, serializer: S) -> Result { + match value { + Some(value) => ts(value, serializer), + None => serializer.serialize_none(), + } +} + +pub fn format_ts(value: &NaiveDateTime) -> String { + value.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +/// Valeur brute du pilote PostgreSQL (colonnes lues par sql`…` dans Drizzle) : +/// « 2026-10-15 08:30:00 », fraction de seconde sans zéros de fin +pub fn pg_text(value: &NaiveDateTime, serializer: S) -> Result { + serializer.serialize_str(&format_pg_text(value)) +} + +pub fn pg_text_opt(value: &Option, serializer: S) -> Result { + match value { + Some(value) => pg_text(value, serializer), + None => serializer.serialize_none(), + } +} + +pub fn format_pg_text(value: &NaiveDateTime) -> String { + let base = value.format("%Y-%m-%d %H:%M:%S").to_string(); + let micros = value.and_utc().timestamp_subsec_micros(); + if micros == 0 { + base + } else { + format!("{base}.{}", format!("{micros:06}").trim_end_matches('0')) + } +} + +/// Colonne numeric(…, ÉCHELLE) lue par node-pg : le texte de PostgreSQL ("12.50"). +/// sqlx lit zéro sans son échelle (0.00 devient 0) : elle est rétablie ici. +pub fn numeric(value: &Decimal, serializer: S) -> Result { + let mut value = *value; + if value.scale() < SCALE { + value.rescale(SCALE); + } + serializer.serialize_str(&value.to_string()) +} + +pub fn numeric_opt( + value: &Option, + serializer: S, +) -> Result { + match value { + Some(value) => numeric::(value, serializer), + None => serializer.serialize_none(), + } +} + +/// Champs d'une ligne, pour la compléter comme `{ ...ligne, group, creator }` en +/// JavaScript (les clés ajoutées viennent après celles de la ligne) +pub fn spread(row: &T) -> serde_json::Map { + match serde_json::to_value(row) { + Ok(serde_json::Value::Object(fields)) => fields, + _ => serde_json::Map::new(), + } +} + +/// stripSmtpPassword (server/sanitize.ts), appliqué par Node à toute réponse /api : +/// une clé smtpPassword ou smtp_password devient smtpPasswordSet: Boolean(valeur), +/// à la même place. À appliquer aux réponses qui joignent la ligne complète d'un +/// magasin (getDlcProduct, getSavTicket…). +pub fn strip_smtp_password(value: serde_json::Value) -> serde_json::Value { + strip_at_depth(value, 0) +} + +fn strip_at_depth(value: serde_json::Value, depth: usize) -> serde_json::Value { + use serde_json::Value; + // Même profondeur maximale que Node + if depth > 8 { + return value; + } + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .map(|item| strip_at_depth(item, depth + 1)) + .collect(), + ), + Value::Object(entries) => Value::Object( + entries + .into_iter() + .map(|(key, entry)| { + if key == "smtpPassword" || key == "smtp_password" { + ( + "smtpPasswordSet".to_string(), + Value::Bool(crate::js::truthy(&entry)), + ) + } else { + (key, strip_at_depth(entry, depth + 1)) + } + }) + .collect(), + ), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn montants_avec_l_echelle_de_la_colonne() { + #[derive(serde::Serialize)] + struct Row { + #[serde(serialize_with = "numeric_opt::<2, _>")] + amount: Option, + } + let text = |amount| serde_json::to_string(&Row { amount }).unwrap(); + assert_eq!(text(Some(Decimal::ZERO)), r#"{"amount":"0.00"}"#); + assert_eq!(text(Some(Decimal::new(1250, 2))), r#"{"amount":"12.50"}"#); + assert_eq!(text(None), r#"{"amount":null}"#); + } + + #[test] + fn mot_de_passe_smtp_masque() { + let group = + serde_json::json!({ "id": 1, "smtpUser": "u", "smtpPassword": "enc:v1:x", "name": "Nancy" }); + let stripped = strip_smtp_password(serde_json::json!([{ "group": group }, { "smtp_password": "" }])); + assert_eq!( + serde_json::to_string(&stripped).unwrap(), + r#"[{"group":{"id":1,"smtpUser":"u","smtpPasswordSet":true,"name":"Nancy"}},{"smtpPasswordSet":false}]"# + ); + } + + #[test] + fn formats() { + let date = NaiveDate::from_ymd_opt(2026, 10, 15) + .unwrap() + .and_hms_micro_opt(8, 30, 0, 123_400) + .unwrap(); + assert_eq!(format_ts(&date), "2026-10-15T08:30:00.123Z"); + assert_eq!(format_pg_text(&date), "2026-10-15 08:30:00.1234"); + let whole = NaiveDate::from_ymd_opt(2026, 10, 15) + .unwrap() + .and_hms_opt(8, 0, 0) + .unwrap(); + assert_eq!(format_pg_text(&whole), "2026-10-15 08:00:00"); + } +} diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs new file mode 100644 index 0000000..0e1ae81 --- /dev/null +++ b/server-rs/src/main.rs @@ -0,0 +1,97 @@ +// Serveur LogiFlow : l'API (router.rs), l'interface construite par Vite +// (static_files.rs), les migrations de démarrage et les tâches de fond. +// Il a remplacé le serveur Node (docs/PLAN-MIGRATION-RUST.md). + +mod access; +mod auth; +mod clock; +mod config; +mod crypto; +mod db; +mod error; +mod express_routes; +mod http; +mod jobs; +mod js; +mod json; +mod modules; +mod origin; +mod projections; +mod request; +mod router; +mod schema; +mod services; +mod startup; +mod static_files; +mod zod; + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use sqlx::postgres::PgPoolOptions; +use tracing_subscriber::EnvFilter; + +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + pub db: sqlx::PgPool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + // Pas de couleurs dans les journaux Docker + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())) + .with_env_filter( + EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn")), + ) + .init(); + + let config = Config::from_env()?; + // Connexion paresseuse : le serveur démarre même si la base n'est pas encore prête + let db = PgPoolOptions::new() + .max_connections(20) + .acquire_timeout(Duration::from_secs(15)) + .idle_timeout(Duration::from_secs(30)) + .connect_lazy(&config.database_url)?; + + // Comme Node : migrations avant d'accepter des requêtes, puis compte admin, + // index en arrière-plan et tâches de fond + startup::run_migrations(&db, &config.encryption_key).await; + jobs::ensure_default_admin(&db).await; + startup::spawn_performance_indexes(db.clone()); + + let state = AppState { + config: Arc::new(config), + db, + }; + let port = state.config.port; + tracing::info!(port, "LogiFlow démarre"); + + jobs::spawn(state.clone()); + let app = router::build(state); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?; + axum::serve(listener, app.into_make_service_with_connect_info::()) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + let terminate = async { + if let Ok(mut signal) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + signal.recv().await; + } + }; + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("arrêt demandé"); +} diff --git a/server-rs/src/modules/admin.rs b/server-rs/src/modules/admin.rs new file mode 100644 index 0000000..9e6e2c3 --- /dev/null +++ b/server-rs/src/modules/admin.rs @@ -0,0 +1,329 @@ +// Administration : diagnostic du schéma (GET /api/debug/log-schema et +// /api/debug/download-schema, réservés à l'administrateur et à la production), +// exécution de SQL (sql.rs) et adresses de secours (emergency.rs : migration +// d'urgence, réinitialisation du compte admin). + +use axum::extract::State; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::post}; +use serde_json::json; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::error::{ApiResult, AppError}; +use crate::http::express_etag; +use crate::js; + +mod emergency; +mod sql; + +pub fn routes() -> Router { + Router::new() + .route("/api/debug/log-schema", get(log_schema)) + .route("/api/debug/download-schema", get(download_schema)) + .route("/api/admin/execute-sql", post(sql::execute_sql)) + .route( + "/api/admin/emergency-migration", + post(emergency::emergency_migration), + ) + .route( + "/api/emergency-admin-reset", + post(emergency::emergency_admin_reset), + ) +} + +const RULE: &str = "==============================================="; + +#[derive(sqlx::FromRow)] +struct Column { + column_name: String, + data_type: String, + character_maximum_length: Option, + is_nullable: String, + column_default: Option, +} + +struct Table { + name: String, + columns: Vec, + /// Nombre de lignes, ou message de l'erreur de comptage + count: Result, +} + +#[derive(sqlx::FromRow)] +struct ForeignKey { + table_name: String, + column_name: String, + foreign_table_name: String, + foreign_column_name: String, +} + +/// Tables du schéma public, leurs colonnes et leur nombre de lignes, puis les +/// clés étrangères : mêmes requêtes que Node +async fn scan(db: &sqlx::PgPool) -> Result<(Vec, Vec), sqlx::Error> { + let names: Vec<(String,)> = sqlx::query_as( + "SELECT table_name::text + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name", + ) + .fetch_all(db) + .await?; + let mut tables = Vec::with_capacity(names.len()); + for (name,) in names { + let columns: Vec = sqlx::query_as( + "SELECT column_name::text, data_type::text, character_maximum_length::int, + is_nullable::text, column_default::text + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position", + ) + .bind(&name) + .fetch_all(db) + .await?; + let count = if columns.is_empty() { + Ok(0) + } else { + // Nom lu dans le catalogue, entre guillemets comme dans Node + sqlx::query_as::<_, (i64,)>(&format!( + "SELECT COUNT(*) AS total FROM \"{}\"", + name.replace('"', "\"\"") + )) + .fetch_one(db) + .await + .map(|(total,)| total) + .map_err(|error| match error { + sqlx::Error::Database(database) => database.message().to_string(), + other => other.to_string(), + }) + }; + tables.push(Table { name, columns, count }); + } + let foreign_keys: Vec = sqlx::query_as( + "SELECT + tc.table_name::text, + kcu.column_name::text, + ccu.table_name::text AS foreign_table_name, + ccu.column_name::text AS foreign_column_name, + tc.constraint_name::text + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + ORDER BY tc.table_name, kcu.column_name", + ) + .fetch_all(db) + .await?; + Ok((tables, foreign_keys)) +} + +/// ` 01. nom_de_colonne : type(255) NULL DEFAULT …` +fn column_line(index: usize, column: &Column) -> String { + let padding = 25usize.saturating_sub(js::length(&column.column_name)); + let max_length = match column.character_maximum_length { + Some(length) if length != 0 => format!("({length})"), + _ => String::new(), + }; + let nullable = if column.is_nullable == "YES" { + "NULL" + } else { + "NOT NULL" + }; + let default = match column.column_default.as_deref() { + Some(default) if !default.is_empty() => format!(" DEFAULT {default}"), + _ => String::new(), + }; + format!( + " {:02}. {}{} : {}{max_length} {nullable}{default}", + index + 1, + column.column_name, + " ".repeat(padding), + column.data_type + ) +} + +fn count_line(count: &Result) -> String { + match count { + Ok(total) => format!(" 📈 Nombre d'enregistrements: {total}"), + Err(message) => format!(" ⚠️ Impossible de compter les enregistrements: {message}"), + } +} + +fn foreign_key_line(fk: &ForeignKey) -> String { + format!( + " {}.{} → {}.{}", + fk.table_name, fk.column_name, fk.foreign_table_name, fk.foreign_column_name + ) +} + +fn check_access(user: &CurrentUser, state: &AppState, denied: &str) -> Result<(), AppError> { + if !user.is_admin() { + return Err(AppError::forbidden(denied)); + } + if state.config.environment != "production" { + return Err(AppError::with_body( + StatusCode::BAD_REQUEST, + json!({ "error": "Cette route fonctionne uniquement en production sur votre serveur privé" }), + )); + } + Ok(()) +} + +fn scan_error(error: sqlx::Error) -> AppError { + tracing::error!(%error, "scan du schéma"); + let message = match &error { + sqlx::Error::Database(database) => database.message().to_string(), + other => other.to_string(), + }; + AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": message })) +} + +/// Écrit le schéma dans le journal du serveur et renvoie un résumé +async fn log_schema(State(state): State, user: CurrentUser) -> ApiResult { + check_access( + &user, + &state, + "Accès refusé. Seuls les administrateurs peuvent utiliser cette route de debug.", + )?; + let timestamp = crate::modules::now_iso(); + let (tables, foreign_keys) = scan(&state.db).await.map_err(scan_error)?; + + let mut log = format!( + "\n🔍 ===== DÉBUT SCAN SCHÉMA BASE DE DONNÉES =====\n⏰ Timestamp: {timestamp}\n\n📊 TOTAL DES TABLES TROUVÉES: {}\n==========================================\n", + tables.len() + ); + for table in &tables { + log.push_str(&format!( + "\n🔸 TABLE: {}\n{}\n", + table.name.to_uppercase(), + "=".repeat(js::length(&table.name) + 8) + )); + if !table.columns.is_empty() { + for (index, column) in table.columns.iter().enumerate() { + log.push_str(&column_line(index, column)); + log.push('\n'); + } + log.push_str(&count_line(&table.count)); + log.push('\n'); + } + } + log.push_str("\n🔗 CONTRAINTES DE CLÉS ÉTRANGÈRES:\n===================================\n"); + if foreign_keys.is_empty() { + log.push_str(" Aucune contrainte de clé étrangère trouvée\n"); + } + for fk in &foreign_keys { + log.push_str(&foreign_key_line(fk)); + log.push('\n'); + } + log.push_str("\n🏁 ===== FIN SCAN SCHÉMA BASE DE DONNÉES =====\n"); + tracing::info!("{log}"); + + Ok(Json(json!({ + "success": true, + "message": "Schéma loggé avec succès dans les logs du serveur", + "totalTables": tables.len(), + "totalForeignKeys": foreign_keys.len(), + "timestamp": crate::modules::now_iso(), + "downloadUrl": "/api/debug/download-schema", + })) + .into_response()) +} + +/// Rapport texte du schéma, en pièce jointe logiflow-schema-AAAA-MM-JJ.txt +async fn download_schema(State(state): State, user: CurrentUser) -> ApiResult { + check_access( + &user, + &state, + "Accès refusé. Seuls les administrateurs peuvent télécharger le rapport de schéma.", + )?; + let timestamp = crate::modules::now_iso(); + let mut report = format!( + "LOGIFLOW - RAPPORT SCHÉMA BASE DE DONNÉES PRODUCTION\n{RULE}\n\nGÉNÉRÉ LE : {timestamp}\nSERVEUR : Production privé LogiFlow\nENVIRONNEMENT : PostgreSQL Docker\nUTILISATEUR : {}\n\n{RULE}\nRÉSUMÉ DU SCAN\n{RULE}\n\n", + user.user.username + ); + let (tables, foreign_keys) = scan(&state.db).await.map_err(scan_error)?; + + report.push_str(&format!( + "TOTAL DES TABLES TROUVÉES: {}\n==========================================\n\n", + tables.len() + )); + for table in &tables { + report.push_str(&format!( + "TABLE: {}\n{}\n\n", + table.name.to_uppercase(), + "=".repeat(js::length(&table.name) + 6) + )); + if !table.columns.is_empty() { + for (index, column) in table.columns.iter().enumerate() { + report.push_str(&column_line(index, column)); + report.push('\n'); + } + report.push_str(&count_line(&table.count)); + report.push_str("\n\n"); + } + } + report.push_str("CONTRAINTES DE CLÉS ÉTRANGÈRES:\n===================================\n\n"); + if foreign_keys.is_empty() { + report.push_str(" Aucune contrainte de clé étrangère trouvée\n"); + } + for fk in &foreign_keys { + report.push_str(&foreign_key_line(fk)); + report.push('\n'); + } + report.push_str(&format!("\n{RULE}\nFIN DU RAPPORT - {timestamp}\n{RULE}")); + + let filename = format!("logiflow-schema-{}.txt", &crate::modules::now_iso()[..10]); + // res.send calcule l'ETag sur le corps + let etag = express_etag(report.as_bytes()); + let mut response = report.into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) { + headers.insert(header::CONTENT_DISPOSITION, value); + } + if let Ok(value) = HeaderValue::from_str(&etag) { + headers.insert(header::ETAG, value); + } + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ligne_de_colonne_comme_node() { + let column = Column { + column_name: "smtp_password".into(), + data_type: "character varying".into(), + character_maximum_length: Some(255), + is_nullable: "YES".into(), + column_default: None, + }; + assert_eq!( + column_line(21, &column), + " 22. smtp_password : character varying(255) NULL" + ); + let id = Column { + column_name: "id".into(), + data_type: "integer".into(), + character_maximum_length: None, + is_nullable: "NO".into(), + column_default: Some("nextval('groups_id_seq'::regclass)".into()), + }; + assert_eq!( + column_line(0, &id), + " 01. id : integer NOT NULL DEFAULT nextval('groups_id_seq'::regclass)" + ); + } +} diff --git a/server-rs/src/modules/admin/emergency.rs b/server-rs/src/modules/admin/emergency.rs new file mode 100644 index 0000000..2e31c51 --- /dev/null +++ b/server-rs/src/modules/admin/emergency.rs @@ -0,0 +1,89 @@ +// Adresses de secours : +// - POST /api/admin/emergency-migration (server/routes.ts, admin) : relance les +// migrations de démarrage (startup.rs), puis la création des index en +// arrière-plan ; les erreurs sont journalisées, la réponse est toujours un succès ; +// - POST /api/emergency-admin-reset (server/index.production.ts, sans session) : +// remet le mot de passe « admin » au compte admin si le corps porte le secret +// EMERGENCY_SECRET de l'environnement ; l'adresse n'existe pas (404) sans lui. + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::Table; +use crate::error::{ApiResult, AppError}; +use crate::modules::now_iso; +use crate::modules::session_routes::hash_password; +use crate::schema::UsersRow; +use crate::zod::Parsed; + +pub async fn emergency_migration(State(state): State, user: CurrentUser) -> ApiResult { + if !user.is_admin() { + return Err(AppError::forbidden("Insufficient permissions")); + } + tracing::warn!(user = user.id(), "migration d'urgence demandée"); + crate::startup::run_migrations(&state.db, &state.config.encryption_key).await; + crate::startup::spawn_performance_indexes(state.db.clone()); + Ok(Json(json!({ + "success": true, + "message": "Emergency migration executed successfully", + "timestamp": now_iso(), + })) + .into_response()) +} + +fn error_body(status: StatusCode, body: Value) -> AppError { + AppError::with_body(status, body) +} + +pub async fn emergency_admin_reset( + State(state): State, + crate::request::JsonBody(body): crate::request::JsonBody, +) -> ApiResult { + let secret = std::env::var("EMERGENCY_SECRET").unwrap_or_default(); + if secret.is_empty() { + return Err(error_body(StatusCode::NOT_FOUND, json!({ "error": "Not found" }))); + } + if body.get("secret").and_then(Value::as_str) != Some(secret.as_str()) { + tracing::warn!("réinitialisation du compte admin avec un secret invalide"); + return Err(error_body( + StatusCode::FORBIDDEN, + json!({ "error": "Invalid emergency secret" }), + )); + } + tracing::warn!("réinitialisation d'urgence du mot de passe admin"); + let failed = |error: sqlx::Error| { + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "Reset failed", "details": crate::services::nocodb::database_message(&error) }), + ) + }; + // storage.getUserByUsername('admin') + let admin: Option = sqlx::query_as(&UsersRow::select("WHERE username = $1")) + .bind("admin") + .fetch_optional(&state.db) + .await + .map_err(failed)?; + let Some(admin) = admin else { + return Err(error_body( + StatusCode::NOT_FOUND, + json!({ "error": "Admin user not found" }), + )); + }; + let mut data = Parsed::new(); + data.insert("password".into(), json!(hash_password("admin".to_string()).await)); + data.insert("passwordChanged".into(), json!(false)); + crate::modules::users::update_user(&state.db, &admin.id, &data) + .await + .map_err(failed)?; + Ok(Json(json!({ + "success": true, + "message": "Admin password reset to admin/admin", + "adminId": admin.id, + })) + .into_response()) +} diff --git a/server-rs/src/modules/admin/sql.rs b/server-rs/src/modules/admin/sql.rs new file mode 100644 index 0000000..0f41359 --- /dev/null +++ b/server-rs/src/modules/admin/sql.rs @@ -0,0 +1,564 @@ +// POST /api/admin/execute-sql (server/routes.ts) : SQL saisi par l'administrateur +// dans la page SQL de l'interface (SQLExecutor.tsx), exécuté tel quel, comme +// db.execute(sql.raw(…)) de Drizzle sur node-pg : protocole simple (plusieurs +// instructions possibles, résultats en texte), lignes converties par les +// analyseurs de node-pg (pg-types), sauf dates, horodatages et intervalles que +// Drizzle laisse en texte. +// +// Faille connue, reprise telle quelle (audit SQL-01) : aucune confirmation ni +// restriction des instructions, l'administrateur exécute ce qu'il veut. +// +// Approximations : +// - rowCount : node-pg lit le nombre de l'étiquette de fin de commande +// (« UPDATE 3 »), null quand elle n'en porte pas (« CREATE TABLE ») ; sqlx ne +// rend que ce nombre (0 sans nombre), la commande est donc reconnue à son +// premier mot quand il vaut 0 ; +// - tableaux d'intervalles : laissés en texte (objets PostgresInterval dans Node). + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use chrono::{NaiveDate, TimeZone, Utc}; +use futures_util::TryStreamExt; +use serde_json::{Map, Value, json}; +use sqlx::postgres::PgRow; +use sqlx::{Column, Either, Row, ValueRef}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::request::JsonBody; + +fn error_body(status: StatusCode, body: Value) -> AppError { + AppError::with_body(status, body) +} + +// ---------- Exécution ---------- + +/// Ce que node-pg rend : un résultat par instruction terminée (tableau de +/// résultats au-delà d'une), les lignes de l'unique instruction +struct Outcome { + /// rows_affected de chaque fin de commande, dans l'ordre + completions: Vec, + rows: Vec>, +} + +async fn run(db: &sqlx::PgPool, sql: &str) -> Result { + let mut stream = sqlx::raw_sql(sql).fetch_many(db); + let mut outcome = Outcome { + completions: Vec::new(), + rows: Vec::new(), + }; + while let Some(item) = stream.try_next().await? { + match item { + Either::Left(done) => outcome.completions.push(done.rows_affected()), + Either::Right(row) => outcome.rows.push(row_json(&row)), + } + } + Ok(outcome) +} + +/// Ligne en objet : une colonne de même nom remplace la précédente, à sa place +fn row_json(row: &PgRow) -> Map { + let mut out = Map::new(); + for column in row.columns() { + let value = match row.try_get_raw(column.ordinal()) { + Ok(raw) if !raw.is_null() => { + let oid = raw.type_info().oid().map_or(0, |oid| oid.0); + match raw.as_str() { + Ok(text) => node_pg_value(oid, text), + Err(_) => Value::Null, + } + } + _ => Value::Null, + }; + out.insert(column.name().to_string(), value); + } + out +} + +/// Premier mot d'une instruction, commentaires et parenthèses ouvrantes passés +fn first_keyword(sql: &str) -> String { + let mut rest = sql; + loop { + rest = rest.trim_start_matches(|c: char| c.is_whitespace() || c == '('); + if let Some(after) = rest.strip_prefix("--") { + rest = after.split_once('\n').map_or("", |(_, next)| next); + } else if let Some(after) = rest.strip_prefix("/*") { + rest = after.split_once("*/").map_or("", |(_, next)| next); + } else { + break; + } + } + rest.chars() + .take_while(|c| c.is_ascii_alphabetic()) + .collect::() + .to_ascii_uppercase() +} + +/// L'étiquette de fin de commande porte-t-elle un nombre ? (« SELECT n », +/// « INSERT 0 n », … ; CREATE TABLE … AS et CREATE MATERIALIZED VIEW rendent +/// « SELECT n ») +fn tag_has_count(sql: &str) -> bool { + const COUNTED: &[&str] = &[ + "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "MOVE", "FETCH", "COPY", "VALUES", "TABLE", "WITH", + ]; + let keyword = first_keyword(sql); + if COUNTED.contains(&keyword.as_str()) { + return true; + } + if keyword != "CREATE" { + return false; + } + let words: Vec = sql + .split(|c: char| c.is_whitespace() || c == '(' || c == ')') + .filter(|word| !word.is_empty()) + .map(str::to_ascii_uppercase) + .collect(); + let Some(as_index) = words.iter().position(|word| word == "AS") else { + return false; + }; + let before = &words[..as_index]; + let creates_table = before + .iter() + .any(|word| word == "TABLE" || word == "MATERIALIZED"); + let query_follows = words + .get(as_index + 1) + .is_some_and(|word| ["SELECT", "VALUES", "TABLE", "WITH", "EXECUTE"].contains(&word.as_str())); + creates_table && query_follows +} + +/// rowCount d'une instruction seule : null si l'étiquette n'a pas de nombre +fn row_count(sql: &str, rows_affected: u64) -> Value { + if rows_affected > 0 || tag_has_count(sql) { + json!(rows_affected) + } else { + Value::Null + } +} + +pub async fn execute_sql( + State(state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + if !user.is_admin() { + return Err(error_body( + StatusCode::FORBIDDEN, + json!({ "error": "Accès refusé - Admin uniquement" }), + )); + } + let Some(sql) = body + .get("sql") + .and_then(Value::as_str) + .filter(|sql| !sql.is_empty()) + else { + return Err(error_body( + StatusCode::BAD_REQUEST, + json!({ "error": "SQL query requis" }), + )); + }; + tracing::info!(user = user.id(), "exécution SQL de l'administrateur"); + let mut logs = vec!["🔄 Exécution SQL démarrée...".to_string()]; + + let outcome = match run(&state.db, sql).await { + Ok(outcome) => outcome, + Err(error) => { + let message = crate::services::nocodb::database_message(&error); + logs.push(format!("❌ Erreur SQL: {message}")); + return Err(error_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": format!("Erreur SQL: {message}"), "logs": logs }), + )); + } + }; + + logs.push("✅ SQL exécuté avec succès".to_string()); + // Plusieurs instructions : node-pg rend un tableau de résultats, sans rows ni rowCount + if outcome.completions.len() > 1 { + logs.push("📊 Nombre de lignes affectées: 0".to_string()); + return Ok(Json(json!({ "success": true, "logs": logs })).into_response()); + } + // Requête vide (blancs seuls) : aucun nombre + let count = outcome + .completions + .first() + .map_or(Value::Null, |rows_affected| row_count(sql, *rows_affected)); + logs.push(format!( + "📊 Nombre de lignes affectées: {}", + count.as_u64().unwrap_or(0) + )); + let rows: Vec = outcome.rows.into_iter().map(Value::Object).collect(); + if !rows.is_empty() { + logs.push(format!("📋 Nombre de lignes retournées: {}", rows.len())); + // JSON.stringify(lignes, null, 2) + if rows.len() <= 10 { + logs.push(format!( + "📋 Résultats: {}", + serde_json::to_string_pretty(&rows).unwrap_or_default() + )); + } else { + logs.push(format!( + "📋 Échantillon (10 premières lignes): {}", + serde_json::to_string_pretty(&rows[..10]).unwrap_or_default() + )); + } + } + Ok(Json(json!({ "success": true, "logs": logs, "results": rows, "rowCount": count })).into_response()) +} + +// ---------- Valeurs, au comportement de node-pg (pg-types) ---------- + +/// Valeur texte d'une colonne selon son type (OID PostgreSQL) +fn node_pg_value(oid: u32, text: &str) -> Value { + match oid { + 16 => json!(parse_bool(text)), + 17 => buffer_json(&parse_bytea(text)), + // int2, int4, oid : parseInt + 21 | 23 | 26 => int_value(text), + // float4, float8 : parseFloat + 700 | 701 => js::number_value(js::parse_float(text)), + // json, jsonb + 114 | 3802 => serde_json::from_str(text).unwrap_or(Value::Null), + 600 => parse_point(text), + 718 => parse_circle(text), + // Tableaux + 1000 => parse_array(text, &|entry| json!(parse_bool(entry))), + 1001 => parse_array(text, &|entry| buffer_json(&parse_bytea(entry))), + 1005 | 1007 | 1028 => parse_array(text, &int_value), + 1016 => parse_array(text, &|entry| json!(js::trim(entry))), + 1017 => parse_array(text, &parse_point), + 1021 | 1022 | 1231 => parse_array(text, &|entry| js::number_value(js::parse_float(entry))), + 651 | 791 | 1008 | 1009 | 1014 | 1015 | 1040 | 1041 | 1183 | 1270 | 2951 | 3907 => { + parse_array(text, &|entry| json!(entry)) + } + 1115 | 1182 | 1185 => parse_array(text, &parse_date), + 199 | 3807 => parse_array(text, &|entry| serde_json::from_str(entry).unwrap_or(Value::Null)), + // int8, numeric, texte, dates, horodatages, intervalles… : le texte tel quel + _ => json!(text), + } +} + +/// parseInt(texte, 10), NaN écrit null +fn int_value(text: &str) -> Value { + js::number_value(js::parse_int(text).unwrap_or(f64::NAN)) +} + +fn parse_bool(text: &str) -> bool { + matches!(text, "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1") +} + +/// postgres-bytea : format hexadécimal (\x…) ou échappé (\ooo, \\) +fn parse_bytea(text: &str) -> Vec { + if let Some(hex) = text.strip_prefix("\\x") { + return js_hex(hex); + } + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] != '\\' { + out.push(chars[i] as u32 as u8); + i += 1; + continue; + } + let octal: String = chars[i + 1..].iter().take(3).collect(); + if octal.len() == 3 && octal.chars().all(|c| ('0'..='7').contains(&c)) { + out.push(u8::from_str_radix(&octal, 8).unwrap_or_default()); + i += 4; + } else { + let mut backslashes = 1; + while i + backslashes < chars.len() && chars[i + backslashes] == '\\' { + backslashes += 1; + } + out.extend(std::iter::repeat_n(b'\\', backslashes / 2)); + i += (backslashes / 2) * 2; + if backslashes == 1 { + // Barre isolée : JavaScript n'avance pas (boucle infinie évitée ici) + i += 1; + } + } + } + out +} + +/// Buffer.from(texte, 'hex') : paires lues jusqu'au premier caractère invalide +fn js_hex(text: &str) -> Vec { + let digit = |c: u8| (c as char).to_digit(16).map(|d| d as u8); + let mut out = Vec::with_capacity(text.len() / 2); + for pair in text.as_bytes().chunks_exact(2) { + match (digit(pair[0]), digit(pair[1])) { + (Some(high), Some(low)) => out.push((high << 4) | low), + _ => break, + } + } + out +} + +/// JSON d'un Buffer : { type: "Buffer", data: [octets] } +fn buffer_json(bytes: &[u8]) -> Value { + json!({ "type": "Buffer", "data": bytes }) +} + +/// parsePoint : « (x,y) » → { x, y } +fn parse_point(text: &str) -> Value { + if !text.starts_with('(') { + return Value::Null; + } + let inner = text.get(1..text.len().saturating_sub(1)).unwrap_or_default(); + let mut values = inner.split(','); + let x = js::parse_float(values.next().unwrap_or_default()); + let y = values.next().map_or(f64::NAN, js::parse_float); + json!({ "x": js::number_value(x), "y": js::number_value(y) }) +} + +/// parseCircle : « <(x,y),r> » → { x, y, radius } +fn parse_circle(text: &str) -> Value { + let chars: Vec = text.chars().collect(); + if chars.first() != Some(&'<') && chars.get(1) != Some(&'(') { + return Value::Null; + } + let mut point = String::from("("); + let mut radius = String::new(); + let mut point_parsed = false; + for &c in chars.iter().take(chars.len().saturating_sub(1)).skip(2) { + if !point_parsed { + point.push(c); + } + if c == ')' { + point_parsed = true; + continue; + } else if !point_parsed || c == ',' { + continue; + } + radius.push(c); + } + let mut result = parse_point(&point); + if let Some(object) = result.as_object_mut() { + object.insert("radius".into(), js::number_value(js::parse_float(&radius))); + } + result +} + +/// postgres-date : Date JavaScript (heure locale du serveur Node : UTC), écrite en +/// ISO ; infinity et date illisible écrites null +fn parse_date(text: &str) -> Value { + let (date_part, rest) = text.split_once(' ').unwrap_or((text, "")); + let mut fields = date_part.splitn(3, '-'); + let (Some(year), Some(month), Some(day)) = (fields.next(), fields.next(), fields.next()) else { + return Value::Null; + }; + let (Ok(year), Ok(month), Ok(day)) = (year.parse::(), month.parse::(), day.parse::()) + else { + return Value::Null; + }; + let Some(date) = NaiveDate::from_ymd_opt(year, month, day) else { + return Value::Null; + }; + // Heure, fraction (tronquée à la milliseconde) et décalage éventuel + let (time, offset) = match rest.find(['+', '-', 'Z']) { + Some(index) => (&rest[..index], Some(&rest[index..])), + None => (rest, None), + }; + let mut millis = 0; + let naive = if time.is_empty() { + date.and_hms_opt(0, 0, 0) + } else { + let (clock, fraction) = time.split_once('.').unwrap_or((time, "")); + let mut parts = clock.split(':').map(|part| part.parse::().ok()); + let (Some(Some(hour)), Some(Some(minute)), Some(Some(second))) = + (parts.next(), parts.next(), parts.next()) + else { + return Value::Null; + }; + millis = format!("{fraction:0<3}")[..3].parse::().unwrap_or(0); + date.and_hms_opt(hour, minute, second) + }; + let Some(naive) = naive else { + return Value::Null; + }; + let mut instant = Utc.from_utc_datetime(&naive) + chrono::Duration::milliseconds(millis); + if let Some(offset) = offset.filter(|offset| !offset.starts_with('Z')) { + let sign = if offset.starts_with('-') { -1 } else { 1 }; + let mut units = offset[1..] + .split(':') + .map(|unit| unit.parse::().unwrap_or(0)); + let seconds = + units.next().unwrap_or(0) * 3600 + units.next().unwrap_or(0) * 60 + units.next().unwrap_or(0); + instant -= chrono::Duration::seconds(sign * seconds); + } + json!(js::iso(&instant)) +} + +/// Tableau PostgreSQL ({a,"b c",NULL,{d}}) au comportement de postgres-array : +/// `transform` appliqué à chaque élément non NULL +fn parse_array(text: &str, transform: &dyn Fn(&str) -> Value) -> Value { + let chars: Vec = text.chars().collect(); + Value::Array(ArrayParser::new(&chars, transform).parse(false)) +} + +struct ArrayParser<'a> { + source: &'a [char], + transform: &'a dyn Fn(&str) -> Value, + position: usize, + entries: Vec, + recorded: Vec, + dimension: i32, +} + +impl<'a> ArrayParser<'a> { + fn new(source: &'a [char], transform: &'a dyn Fn(&str) -> Value) -> Self { + Self { + source, + transform, + position: 0, + entries: Vec::new(), + recorded: Vec::new(), + dimension: 0, + } + } + + fn next_character(&mut self) -> (Option, bool) { + let character = self.source.get(self.position).copied(); + self.position += 1; + if character == Some('\\') { + let escaped = self.source.get(self.position).copied(); + self.position += 1; + return (escaped, true); + } + (character, false) + } + + fn new_entry(&mut self, include_empty: bool) { + if !self.recorded.is_empty() || include_empty { + let entry: String = self.recorded.drain(..).collect(); + let value = if entry == "NULL" && !include_empty { + Value::Null + } else { + (self.transform)(&entry) + }; + self.entries.push(value); + } + } + + fn parse(mut self, nested: bool) -> Vec { + self.parse_with_position(nested).0 + } + + fn parse_with_position(&mut self, nested: bool) -> (Vec, usize) { + let mut quote = false; + // Décoration de dimensions ([1:2]={…}) + if self.source.first() == Some(&'[') { + while self.position < self.source.len() { + if self.next_character().0 == Some('=') { + break; + } + } + } + while self.position < self.source.len() { + let (character, escaped) = self.next_character(); + match character { + Some('{') if !quote => { + self.dimension += 1; + if self.dimension > 1 { + let mut parser = ArrayParser::new(&self.source[self.position - 1..], self.transform); + let (nested_entries, consumed) = parser.parse_with_position(true); + self.entries.push(Value::Array(nested_entries)); + self.position = (self.position + consumed).saturating_sub(2); + } + } + Some('}') if !quote => { + self.dimension -= 1; + if self.dimension == 0 { + self.new_entry(false); + if nested { + return (std::mem::take(&mut self.entries), self.position); + } + } + } + Some('"') if !escaped => { + if quote { + self.new_entry(true); + } + quote = !quote; + } + Some(',') if !quote => self.new_entry(false), + Some(other) => self.recorded.push(other), + None => {} + } + } + (std::mem::take(&mut self.entries), self.position) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valeurs_comme_node_pg() { + // Résultats de node-pg (pg-types 2.2) pour ces textes + assert_eq!(node_pg_value(23, "12"), json!(12)); + assert_eq!(node_pg_value(20, "12345678901"), json!("12345678901")); + assert_eq!(node_pg_value(701, "2"), json!(2)); + assert_eq!(node_pg_value(701, "NaN"), Value::Null); + assert_eq!(node_pg_value(1700, "1.50"), json!("1.50")); + assert_eq!(node_pg_value(16, "f"), json!(false)); + assert_eq!( + node_pg_value(17, "\\x78ff"), + json!({ "type": "Buffer", "data": [120, 255] }) + ); + assert_eq!( + node_pg_value(1114, "2026-10-15 10:00:00.5"), + json!("2026-10-15 10:00:00.5") + ); + assert_eq!( + node_pg_value(3802, r#"{"a": [1, "b"]}"#), + json!({ "a": [1, "b"] }) + ); + assert_eq!(node_pg_value(600, "(1.5,-2)"), json!({ "x": 1.5, "y": -2 })); + assert_eq!( + node_pg_value(718, "<(1,2),3>"), + json!({ "x": 1, "y": 2, "radius": 3 }) + ); + } + + #[test] + fn tableaux_comme_postgres_array() { + assert_eq!(node_pg_value(1007, "{1,2,NULL}"), json!([1, 2, null])); + assert_eq!( + node_pg_value(1009, r#"{a,"b c","NULL",NULL,"x\"y"}"#), + json!(["a", "b c", "NULL", null, "x\"y"]) + ); + assert_eq!(node_pg_value(1007, "{{1,2},{3,4}}"), json!([[1, 2], [3, 4]])); + assert_eq!(node_pg_value(1009, "{}"), json!([])); + assert_eq!(node_pg_value(1016, "{1, 2}"), json!(["1", "2"])); + assert_eq!(node_pg_value(1009, "[0:1]={a,b}"), json!(["a", "b"])); + assert_eq!( + node_pg_value(1182, "{2026-10-15,infinity}"), + json!(["2026-10-15T00:00:00.000Z", null]) + ); + assert_eq!( + node_pg_value(1185, "{\"2026-01-01 08:00:00.123456+02\"}"), + json!(["2026-01-01T06:00:00.123Z"]) + ); + } + + #[test] + fn nombre_de_lignes_de_l_etiquette() { + assert_eq!(row_count("UPDATE groups SET x = 1 WHERE false", 0), json!(0)); + assert_eq!(row_count(" -- commentaire\n(SELECT 1 WHERE false)", 0), json!(0)); + assert_eq!(row_count("ANALYZE groups", 0), Value::Null); + assert_eq!(row_count("CREATE TABLE t (x int)", 0), Value::Null); + assert_eq!(row_count("create table t as select 1 where false", 0), json!(0)); + assert_eq!(row_count("CREATE VIEW v AS SELECT 1", 0), Value::Null); + assert_eq!( + row_count("CREATE TABLE t (id int GENERATED ALWAYS AS IDENTITY)", 0), + Value::Null + ); + assert_eq!(row_count("SHOW timezone", 0), Value::Null); + assert_eq!(row_count("DELETE FROM t", 4), json!(4)); + } +} diff --git a/server-rs/src/modules/analytics.rs b/server-rs/src/modules/analytics.rs new file mode 100644 index 0000000..f04c7ba --- /dev/null +++ b/server-rs/src/modules/analytics.rs @@ -0,0 +1,713 @@ +// Statistiques détaillées : /api/analytics/summary, /timeseries, /by-supplier, +// /by-store, /export (server/routes.ts, « Analytics routes » ; storage +// getAnalyticsSummary, getAnalyticsTimeseries, getAnalyticsBySupplier, +// getAnalyticsByStore). +// +// Node assemble une partie de ces requêtes en texte (timeseries, by-store) : +// elles sont ici paramétrées, avec les mêmes résultats. Dans ces requêtes en +// texte, un identifiant décimal ou hors limites est comparé sans erreur (il ne +// correspond à rien) et seul un NaN ou un Infinity fait échouer la requête ; +// dans les requêtes de Drizzle, tout identifiant que PostgreSQL refuse comme +// integer la fait échouer. +// +// Comme Node aujourd'hui : « Montant total » additionne BL et factures (ANA-04), +// /by-store ignore les magasins de l'utilisateur (ANA-05), une liste de +// magasins vidée par le filtre des droits n'en filtre plus aucun, et un compte +// sans magasin voit tout (ANA-06) ; le directeur voit tous les magasins ; le +// taux de rapprochement vaut null (NaN) quand il n'y a aucune livraison. + +use std::collections::BTreeMap; + +use axum::extract::State; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, routing::get}; +use chrono::{DateTime, Datelike, Utc}; +use serde_json::{Map, Value, json}; +use sqlx::postgres::PgArguments; +use sqlx::{Arguments, PgPool, Postgres}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::js_int4; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::request::JsQuery; + +pub fn routes() -> Router { + Router::new() + .route("/api/analytics/summary", get(summary)) + .route("/api/analytics/timeseries", get(timeseries)) + .route("/api/analytics/by-supplier", get(by_supplier)) + .route("/api/analytics/by-store", get(by_store)) + .route("/api/analytics/export", get(export)) +} + +// ---------- Filtres ---------- + +/// Date du filtre : `new Date(texte)`, None si « Invalid Date » +type FilterDate = Option>; + +/// Filtres de l'adresse, tels que Node les lit +#[derive(Default)] +struct Filters { + start_date: Option, + end_date: Option, + /// split(',').map(Number) + supplier_ids: Option>, + group_ids: Option>, + status: Option>, +} + +/// Échec d'une requête : Node le transforme en 500 (ou en liste vide pour by-store) +#[derive(Debug)] +struct Failure; + +impl From for Failure { + fn from(error: sqlx::Error) -> Self { + tracing::error!(%error, "statistiques détaillées"); + Self + } +} + +impl Filters { + fn read(query: &JsQuery) -> Self { + let date = |key: &str| query.truthy(key).map(|text| js::new_date(&json!(text))); + let numbers = |key: &str| { + query + .truthy(key) + .map(|text| text.split(',').map(|part| js::to_number(&json!(part))).collect()) + }; + Self { + start_date: date("startDate"), + end_date: date("endDate"), + supplier_ids: numbers("supplierIds"), + group_ids: numbers("groupIds"), + status: query + .truthy("status") + .map(|text| text.split(',').map(str::to_string).collect()), + } + } + + /// Hors admin et directeur : seulement les magasins de l'utilisateur (la + /// liste filtrée peut devenir vide, et alors ne filtre plus rien) + fn restrict_to(mut self, user: &CurrentUser) -> Self { + if user.role() != "admin" && user.role() != "directeur" { + let own: Vec = user.group_ids().into_iter().map(f64::from).collect(); + self.group_ids = Some(match self.group_ids { + Some(requested) => requested.into_iter().filter(|id| own.contains(id)).collect(), + None => own, + }); + } + self + } + + /// date.toISOString().split('T')[0] : Err pour « Invalid Date » (RangeError) + fn day(date: &Option) -> Result, Failure> { + match date { + None => Ok(None), + Some(None) => Err(Failure), + Some(Some(date)) => Ok(Some(iso_day(date))), + } + } + + fn days(&self) -> Result<(Option, Option), Failure> { + Ok((Self::day(&self.start_date)?, Self::day(&self.end_date)?)) + } +} + +/// Partie date de toISOString (années hors 0..9999 sur 6 chiffres signés) +fn iso_day(date: &DateTime) -> String { + let year = date.year(); + let year = if (0..=9999).contains(&year) { + format!("{year:04}") + } else { + format!("{}{:06}", if year < 0 { '-' } else { '+' }, year.abs()) + }; + format!("{year}-{}", date.format("%m-%d")) +} + +/// Liste non vide (filters.x?.length) +fn non_empty(list: &Option>) -> Option<&[T]> { + list.as_deref().filter(|list| !list.is_empty()) +} + +/// Conditions WHERE paramétrées +#[derive(Default)] +struct Conditions { + clauses: Vec, + arguments: PgArguments, +} + +impl Conditions { + fn placeholder(&self) -> String { + format!("${}", self.arguments.len()) + } + + /// colonne op $n::type (date en texte, convertie par PostgreSQL) + fn compare(&mut self, column: &str, operator: &str, value: &str, sql_type: &str) -> Result<(), Failure> { + self.arguments.add(value).map_err(sqlx::Error::Encode)?; + self.clauses + .push(format!("{column} {operator} {}::{sql_type}", self.placeholder())); + Ok(()) + } + + /// inArray de Drizzle sur une colonne integer : chaque nombre doit être un entier + fn integers(&mut self, column: &str, ids: &[f64]) -> Result<(), Failure> { + let mut placeholders = Vec::new(); + for id in ids { + let id = js_int4(*id).ok_or(Failure)?; + self.arguments.add(id).map_err(sqlx::Error::Encode)?; + placeholders.push(self.placeholder()); + } + self.clauses + .push(format!("{column} IN ({})", placeholders.join(", "))); + Ok(()) + } + + /// `colonne IN (${ids.join(',')})` écrit dans le texte SQL par Node : un NaN ou + /// un Infinity est une erreur SQL, les autres nombres sont comparés sans erreur + fn literal_integers(&mut self, column: &str, ids: &[f64]) -> Result<(), Failure> { + if ids.iter().any(|id| !id.is_finite()) { + return Err(Failure); + } + let matching: Vec = ids.iter().filter_map(|id| js_int4(*id)).collect(); + if matching.is_empty() { + self.clauses.push("FALSE".into()); + return Ok(()); + } + let mut placeholders = Vec::new(); + for id in matching { + self.arguments.add(id).map_err(sqlx::Error::Encode)?; + placeholders.push(self.placeholder()); + } + self.clauses + .push(format!("{column} IN ({})", placeholders.join(", "))); + Ok(()) + } + + fn texts(&mut self, column: &str, values: &[String]) -> Result<(), Failure> { + let mut placeholders = Vec::new(); + for value in values { + self.arguments.add(value.as_str()).map_err(sqlx::Error::Encode)?; + placeholders.push(self.placeholder()); + } + self.clauses + .push(format!("{column} IN ({})", placeholders.join(", "))); + Ok(()) + } + + fn where_clause(&self) -> String { + if self.clauses.is_empty() { + String::new() + } else { + format!("WHERE ({})", self.clauses.join(" AND ")) + } + } +} + +/// Number(texte numeric) ; NaN → 0 (Number(x) || 0) +fn number_or_zero(text: Option<&str>) -> f64 { + let number = text.map_or(0.0, |text| js::to_number(&json!(text))); + if number.is_nan() { 0.0 } else { number } +} + +// ---------- Lignes rendues (JSON ou CSV) ---------- + +/// Valeur d'une ligne : nombre JavaScript, texte, ou objet sérialisé tel quel +enum Cell { + Number(f64), + Text(String), + Json(Value), +} + +type Row = Vec<(&'static str, Cell)>; + +impl Cell { + fn json(&self) -> Value { + match self { + Self::Number(number) => js::number_value(*number), + Self::Text(text) => json!(text), + Self::Json(value) => value.clone(), + } + } + + /// Valeur dans le CSV : typeof val === 'object' ? JSON.stringify(val) : val + fn csv(&self) -> String { + match self { + Self::Number(number) => js::number_string(*number), + Self::Text(text) => text.clone(), + Self::Json(value) => value.to_string(), + } + } +} + +fn row_json(row: &Row) -> Value { + Value::Object( + row.iter() + .map(|(key, cell)| ((*key).to_string(), cell.json())) + .collect::>(), + ) +} + +fn rows_json(rows: &[Row]) -> Value { + Value::Array(rows.iter().map(row_json).collect()) +} + +/// En-têtes puis une ligne par élément, valeurs séparées par des virgules sans guillemets +fn to_csv(rows: &[Row]) -> String { + let headers: Vec<&str> = rows[0].iter().map(|(key, _)| *key).collect(); + let lines: Vec = rows + .iter() + .map(|row| { + row.iter() + .map(|(_, cell)| cell.csv()) + .collect::>() + .join(",") + }) + .collect(); + format!("{}\n{}", headers.join(","), lines.join("\n")) +} + +// ---------- Requêtes ---------- + +/// getAnalyticsSummary +async fn summary_row(db: &PgPool, filters: &Filters) -> Result { + let (start, end) = filters.days()?; + let conditions = |date_column: &str, prefix: &str| -> Result { + let mut conditions = Conditions::default(); + if let Some(start) = &start { + conditions.compare(&format!("{prefix}.{date_column}"), ">=", start, "date")?; + } + if let Some(end) = &end { + conditions.compare(&format!("{prefix}.{date_column}"), "<=", end, "date")?; + } + if let Some(ids) = non_empty(&filters.supplier_ids) { + conditions.integers(&format!("{prefix}.supplier_id"), ids)?; + } + if let Some(ids) = non_empty(&filters.group_ids) { + conditions.integers(&format!("{prefix}.group_id"), ids)?; + } + if let Some(status) = non_empty(&filters.status) { + conditions.texts(&format!("{prefix}.status"), status)?; + } + Ok(conditions) + }; + let orders = conditions("planned_date", "orders")?; + let deliveries = conditions("scheduled_date", "deliveries")?; + let suppliers = conditions("scheduled_date", "deliveries")?; + + // Magasins les plus actifs : seul le filtre des magasins s'applique + let mut stores = PgArguments::default(); + let store_ids = non_empty(&filters.group_ids); + let mut store_list = || -> Result { + let mut placeholders = Vec::new(); + for id in store_ids.unwrap_or_default() { + stores + .add(js_int4(*id).ok_or(Failure)?) + .map_err(sqlx::Error::Encode)?; + placeholders.push(format!("${}", stores.len())); + } + Ok(format!("({})", placeholders.join(", "))) + }; + let (orders_filter, deliveries_filter, stores_filter) = if store_ids.is_some() { + ( + format!("WHERE group_id IN {}", store_list()?), + format!("WHERE group_id IN {}", store_list()?), + format!("WHERE g.id IN {}", store_list()?), + ) + } else { + Default::default() + }; + let stores_sql = format!( + "SELECT g.id AS id, g.name AS name, COALESCE(o.count, 0) AS orders, COALESCE(d.count, 0) AS deliveries + FROM groups g + LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM orders {orders_filter} GROUP BY group_id) o ON o.group_id = g.id + LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM deliveries {deliveries_filter} GROUP BY group_id) d ON d.group_id = g.id + {stores_filter} + ORDER BY COALESCE(o.count, 0) + COALESCE(d.count, 0) DESC + LIMIT 5" + ); + let orders_sql = format!("SELECT COUNT(*) FROM orders {}", orders.where_clause()); + let deliveries_sql = format!( + "SELECT COUNT(*), + (COALESCE(SUM(CAST(bl_amount AS NUMERIC)), 0) + COALESCE(SUM(CAST(invoice_amount AS NUMERIC)), 0))::text, + COUNT(CASE WHEN reconciled = true THEN 1 END), + (AVG(EXTRACT(EPOCH FROM (delivered_date - scheduled_date)) / 86400))::text + FROM deliveries {}", + deliveries.where_clause() + ); + let suppliers_sql = format!( + "SELECT suppliers.id, suppliers.name, COUNT(deliveries.id), + COALESCE(SUM(CAST(deliveries.bl_amount AS NUMERIC)), 0)::text + FROM deliveries + INNER JOIN suppliers ON deliveries.supplier_id = suppliers.id + {} + GROUP BY suppliers.id, suppliers.name + ORDER BY COUNT(deliveries.id) DESC + LIMIT 5", + suppliers.where_clause() + ); + // Promise.all : les quatre requêtes vont à leur terme, la première erreur l'emporte + let (orders, deliveries, top_suppliers, top_stores) = tokio::join!( + sqlx::query_as_with::(&orders_sql, orders.arguments).fetch_one(db), + sqlx::query_as_with::), _>( + &deliveries_sql, + deliveries.arguments + ) + .fetch_one(db), + sqlx::query_as_with::(&suppliers_sql, suppliers.arguments) + .persistent(false) + .fetch_all(db), + sqlx::query_as_with::(&stores_sql, stores) + .persistent(false) + .fetch_all(db), + ); + let ((total_orders,), (count, amount, reconciled, average), top_suppliers, top_stores) = + (orders?, deliveries?, top_suppliers?, top_stores?); + + let top_suppliers: Vec = top_suppliers + .into_iter() + .map(|(id, name, count, amount)| { + json!({ "id": id, "name": name, "count": count, "amount": js::number_value(js::to_number(&json!(amount))) }) + }) + .collect(); + let top_stores: Vec = top_stores + .into_iter() + .map(|(id, name, orders, deliveries)| json!({ "id": id, "name": name, "orders": orders, "deliveries": deliveries })) + .collect(); + // deliveryStats.count est un texte (bigint), toujours « vrai » : 0 livraison → NaN + let reconciliation_rate = reconciled as f64 / count as f64 * 100.0; + Ok(vec![ + ("totalOrders", Cell::Number(total_orders as f64)), + ("totalDeliveries", Cell::Number(count as f64)), + ("reconciliationRate", Cell::Number(reconciliation_rate)), + ("totalAmount", Cell::Number(number_or_zero(Some(&amount)))), + ( + "avgDeliveryDelay", + Cell::Number(number_or_zero(average.as_deref())), + ), + ("topSuppliers", Cell::Json(Value::Array(top_suppliers))), + ("topStores", Cell::Json(Value::Array(top_stores))), + ]) +} + +/// getAnalyticsTimeseries : commandes et livraisons par jour, semaine ou mois +async fn timeseries_rows(db: &PgPool, filters: &Filters, granularity: &str) -> Result, Failure> { + let format = match granularity { + "day" => "YYYY-MM-DD", + "week" => "YYYY-IW", + _ => "YYYY-MM", + }; + let (start, end) = filters.days()?; + let series = |table: &str, date_column: &str| -> Result<(String, Conditions), Failure> { + let mut conditions = Conditions::default(); + conditions.arguments.add(format).map_err(sqlx::Error::Encode)?; + if let Some(start) = &start { + conditions.compare(date_column, ">=", start, "date")?; + } + if let Some(end) = &end { + conditions.compare(date_column, "<=", end, "date")?; + } + if let Some(ids) = non_empty(&filters.supplier_ids) { + conditions.literal_integers("supplier_id", ids)?; + } + if let Some(ids) = non_empty(&filters.group_ids) { + conditions.literal_integers("group_id", ids)?; + } + let sql = format!( + "SELECT TO_CHAR({date_column}, $1) AS date, COUNT(*) AS count FROM {table} {} GROUP BY 1 ORDER BY 1", + conditions.where_clause() + ); + Ok((sql, conditions)) + }; + let (orders_sql, orders) = series("orders", "planned_date")?; + let (deliveries_sql, deliveries) = series("deliveries", "scheduled_date")?; + let (order_rows, delivery_rows) = tokio::join!( + sqlx::query_as_with::(&orders_sql, orders.arguments).fetch_all(db), + sqlx::query_as_with::(&deliveries_sql, deliveries.arguments) + .fetch_all(db), + ); + let (order_rows, delivery_rows) = (order_rows?, delivery_rows?); + + // Map date → { orders, deliveries } triée par date (localeCompare sur des + // chiffres et des tirets : ordre des octets, celui de BTreeMap) + let mut series: BTreeMap = BTreeMap::new(); + for (date, count) in order_rows { + series.insert(date, (count, 0)); + } + for (date, count) in delivery_rows { + series.entry(date).or_default().1 = count; + } + Ok(series + .into_iter() + .map(|(date, (orders, deliveries))| { + vec![ + ("date", Cell::Text(date)), + ("orders", Cell::Number(orders as f64)), + ("deliveries", Cell::Number(deliveries as f64)), + ] + }) + .collect()) +} + +/// getAnalyticsBySupplier : livraisons et montant des BL par fournisseur +async fn supplier_rows(db: &PgPool, filters: &Filters) -> Result, Failure> { + let (start, end) = filters.days()?; + let mut conditions = Conditions::default(); + if let Some(start) = &start { + conditions.compare("deliveries.scheduled_date", ">=", start, "date")?; + } + if let Some(end) = &end { + conditions.compare("deliveries.scheduled_date", "<=", end, "date")?; + } + if let Some(ids) = non_empty(&filters.group_ids) { + conditions.integers("deliveries.group_id", ids)?; + } + let sql = format!( + "SELECT suppliers.id, suppliers.name, COUNT(deliveries.id), + COALESCE(SUM(CAST(deliveries.bl_amount AS NUMERIC)), 0)::text + FROM deliveries + INNER JOIN suppliers ON deliveries.supplier_id = suppliers.id + {} + GROUP BY suppliers.id, suppliers.name + ORDER BY COUNT(deliveries.id) DESC", + conditions.where_clause() + ); + let rows: Vec<(i32, String, i64, String)> = sqlx::query_as_with(&sql, conditions.arguments) + .persistent(false) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map(|(id, name, deliveries, amount)| { + vec![ + ("supplierId", Cell::Number(f64::from(id))), + ("supplierName", Cell::Text(name)), + ("deliveries", Cell::Number(deliveries as f64)), + ("amount", Cell::Number(js::to_number(&json!(amount)))), + ] + }) + .collect()) +} + +/// getAnalyticsByStore : commandes et livraisons par magasin ; toute erreur +/// rend une liste vide +async fn store_rows(db: &PgPool, filters: &Filters) -> Vec { + store_rows_or_fail(db, filters).await.unwrap_or_default() +} + +async fn store_rows_or_fail(db: &PgPool, filters: &Filters) -> Result, Failure> { + let (start, end) = filters.days()?; + let mut conditions = Conditions::default(); + let mut orders = Vec::new(); + let mut deliveries = Vec::new(); + if let Some(start) = &start { + conditions.compare("planned_date", ">=", start, "date")?; + orders.push(conditions.clauses.pop().unwrap_or_default()); + conditions.compare("scheduled_date", ">=", start, "date")?; + deliveries.push(conditions.clauses.pop().unwrap_or_default()); + } + if let Some(end) = &end { + conditions.compare("planned_date", "<=", end, "date")?; + orders.push(conditions.clauses.pop().unwrap_or_default()); + conditions.compare("scheduled_date", "<=", end, "date")?; + deliveries.push(conditions.clauses.pop().unwrap_or_default()); + } + if let Some(ids) = non_empty(&filters.supplier_ids) { + conditions.literal_integers("supplier_id", ids)?; + let clause = conditions.clauses.pop().unwrap_or_default(); + // Même liste pour les deux sous-requêtes (paramètres réutilisés) + orders.push(clause.clone()); + deliveries.push(clause); + } + let and = + |clauses: &[String]| -> String { clauses.iter().map(|clause| format!(" AND {clause}")).collect() }; + let sql = format!( + "SELECT g.id AS \"groupId\", g.name AS \"storeName\", COALESCE(o.count, 0) AS orders, COALESCE(d.count, 0) AS deliveries + FROM groups g + LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM orders WHERE 1=1{} GROUP BY group_id) o ON g.id = o.group_id + LEFT JOIN (SELECT group_id, COUNT(*) AS count FROM deliveries WHERE 1=1{} GROUP BY group_id) d ON g.id = d.group_id + ORDER BY (COALESCE(o.count, 0) + COALESCE(d.count, 0)) DESC", + and(&orders), + and(&deliveries) + ); + let rows: Vec<(i32, String, i64, i64)> = sqlx::query_as_with(&sql, conditions.arguments) + .persistent(false) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map(|(id, name, orders, deliveries)| { + vec![ + ("groupId", Cell::Number(f64::from(id))), + ("storeName", Cell::Text(name)), + ("orders", Cell::Number(orders as f64)), + ("deliveries", Cell::Number(deliveries as f64)), + ] + }) + .collect()) +} + +// ---------- Adresses ---------- + +fn failed(message: &str) -> AppError { + AppError::internal(message) +} + +async fn summary(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let filters = Filters::read(&query).restrict_to(&user); + let row = summary_row(&state.db, &filters) + .await + .map_err(|_| failed("Failed to fetch analytics summary"))?; + Ok(Json(row_json(&row)).into_response()) +} + +async fn timeseries(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let filters = Filters { + status: None, + ..Filters::read(&query) + } + .restrict_to(&user); + let granularity = query.truthy("granularity").unwrap_or("day"); + let rows = timeseries_rows(&state.db, &filters, granularity) + .await + .map_err(|_| failed("Failed to fetch analytics timeseries"))?; + Ok(Json(rows_json(&rows)).into_response()) +} + +async fn by_supplier(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let filters = Filters { + supplier_ids: None, + status: None, + ..Filters::read(&query) + } + .restrict_to(&user); + let rows = supplier_rows(&state.db, &filters) + .await + .map_err(|_| failed("Failed to fetch analytics by supplier"))?; + Ok(Json(rows_json(&rows)).into_response()) +} + +async fn by_store(State(state): State, _user: CurrentUser, query: JsQuery) -> ApiResult { + // Ni les magasins de l'utilisateur ni ?groupIds (ANA-05) + let filters = Filters::read(&query); + Ok(Json(rows_json(&store_rows(&state.db, &filters).await)).into_response()) +} + +/// Caractères refusés par Node dans un en-tête (checkInvalidHeaderChar) +fn header_bytes(value: &str) -> Option> { + value + .chars() + .map(|c| match c as u32 { + 0x09 | 0x20..=0x7e | 0x80..=0xff => Some(c as u32 as u8), + _ => None, + }) + .collect() +} + +async fn export(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to export analytics"; + let kind = query.truthy("type").unwrap_or("summary"); + let filters = Filters::read(&query).restrict_to(&user); + let filename = format!( + "analytics_{kind}_{}.csv", + crate::clock::today().format("%Y-%m-%d") + ); + let rows = match kind { + "timeseries" => timeseries_rows(&state.db, &filters, "day").await, + "suppliers" => supplier_rows(&state.db, &filters).await, + "stores" => Ok(store_rows(&state.db, &filters).await), + _ => summary_row(&state.db, &filters).await.map(|row| vec![row]), + } + .map_err(|_| failed(FAILED))?; + if rows.is_empty() { + return Err(AppError::not_found("No data to export")); + } + let csv = to_csv(&rows); + let csv_type = HeaderValue::from_static("text/csv; charset=utf-8"); + // Nom de fichier invalide pour un en-tête : Node échoue après avoir posé le + // type CSV, et l'erreur JSON part avec ce type + let disposition = header_bytes(&format!("attachment; filename=\"{filename}\"")) + .and_then(|bytes| HeaderValue::from_bytes(&bytes).ok()); + let Some(disposition) = disposition else { + let body = json!({ "message": FAILED }).to_string(); + return Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + [(header::CONTENT_TYPE, csv_type)], + body, + ) + .into_response()); + }; + let etag = HeaderValue::from_str(&crate::http::express_etag(csv.as_bytes())).ok(); + let mut response: Response = ( + [ + (header::CONTENT_TYPE, csv_type), + (header::CONTENT_DISPOSITION, disposition), + ], + csv, + ) + .into_response(); + if let Some(etag) = etag { + response.headers_mut().insert(header::ETAG, etag); + } + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csv_comme_node() { + // Valeurs produites par la conversion CSV de /api/analytics/export dans Node + let summary = vec![vec![ + ("totalOrders", Cell::Number(9.0)), + ("reconciliationRate", Cell::Number(f64::NAN)), + ("avgDeliveryDelay", Cell::Number(1.2291666666666667)), + ( + "topSuppliers", + Cell::Json(json!([{ "id": 4, "name": "Jardin & Co", "count": 2, "amount": 450.25 }])), + ), + ]]; + assert_eq!( + to_csv(&summary), + "totalOrders,reconciliationRate,avgDeliveryDelay,topSuppliers\n9,NaN,1.2291666666666667,[{\"id\":4,\"name\":\"Jardin & Co\",\"count\":2,\"amount\":450.25}]" + ); + // NaN en JSON : null + assert_eq!(row_json(&summary[0])["reconciliationRate"], Value::Null); + let rows = vec![ + vec![ + ("date", Cell::Text("2026-10-01".into())), + ("orders", Cell::Number(2.0)), + ], + vec![ + ("date", Cell::Text("2026-10-02".into())), + ("orders", Cell::Number(0.0)), + ], + ]; + assert_eq!(to_csv(&rows), "date,orders\n2026-10-01,2\n2026-10-02,0"); + } + + #[test] + fn listes_d_identifiants_comme_le_texte_sql_de_node() { + let mut conditions = Conditions::default(); + conditions + .literal_integers("supplier_id", &[1.5, 2.0, 99999999999.0]) + .unwrap(); + assert_eq!(conditions.clauses, vec!["supplier_id IN ($1)"]); + conditions.literal_integers("group_id", &[1.5]).unwrap(); + assert_eq!(conditions.clauses[1], "FALSE"); + assert!(conditions.literal_integers("group_id", &[1.0, f64::NAN]).is_err()); + assert!(Conditions::default().integers("group_id", &[1.5]).is_err()); + } + + #[test] + fn dates_des_filtres() { + let date = |text: &str| js::new_date(&json!(text)).unwrap(); + assert_eq!(iso_day(&date("2026-09-15T23:30:00.000+02:00")), "2026-09-15"); + assert_eq!(iso_day(&date("2026-09-15T22:30:00.000-02:00")), "2026-09-16"); + assert_eq!(header_bytes("é€"), None); + assert_eq!(header_bytes("résumé").unwrap().len(), 6); + } +} diff --git a/server-rs/src/modules/announcements.rs b/server-rs/src/modules/announcements.rs new file mode 100644 index 0000000..cf23692 --- /dev/null +++ b/server-rs/src/modules/announcements.rs @@ -0,0 +1,386 @@ +// Informations du tableau de bord : /api/announcements (server/routes.ts, +// « Announcement routes », branche production ; table dashboard_messages). +// Les replis de Node sur erreur SQL passent par server/announcementStorage.ts +// (AnnouncementDatabaseStorage) : ils sont reproduits. +// Comme Node aujourd'hui : un non-admin voit les informations de tous les +// magasins (DASH-13), et un magasin illisible (?storeId=abc) fait retomber sur +// toutes les informations, sans limite, avec l'auteur sous une autre forme. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::put}; +use chrono::NaiveDateTime; +use serde_json::{Map, Value, json}; +use sqlx::PgPool; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, parse_int4}; +use crate::error::{ApiResult, AppError, OrInternal}; +use crate::js::truthy; +use crate::request::{JsQuery, JsonBody}; +use crate::schema::DashboardMessagesRow; +use crate::zod::Object; + +pub fn routes() -> Router { + Router::new() + .route("/api/announcements", get(list).post(create)) + .route("/api/announcements/{id}", put(update).delete(remove)) +} + +/// insertAnnouncementSchema (insertDashboardMessageSchema) +fn insert_schema() -> Object { + DashboardMessagesRow::insert_schema().omit(&["id", "createdAt"]) +} + +/// Information lue avec son auteur (users.username = created_by) et son magasin +#[derive(sqlx::FromRow)] +struct MessageRow { + id: i32, + title: String, + content: String, + #[sqlx(rename = "type")] + type_: String, + store_id: Option, + created_by: String, + created_at: Option, + author_id: Option, + author_username: Option, + author_first_name: Option, + author_last_name: Option, + author_name: Option, + group_id: Option, + group_name: Option, +} + +const MESSAGE_COLUMNS: &str = "dashboard_messages.id, dashboard_messages.title, dashboard_messages.content, + dashboard_messages.type, dashboard_messages.store_id, dashboard_messages.created_by, + dashboard_messages.created_at, users.id AS author_id, users.username AS author_username, + users.first_name AS author_first_name, users.last_name AS author_last_name, + users.name AS author_name, groups.id AS group_id, groups.name AS group_name"; + +/// Liste de la branche production : 5 dernières, filtrées par magasin pour l'admin +fn latest_sql(filtered: bool) -> String { + let filter = if filtered { + "WHERE (dashboard_messages.store_id = $1 OR dashboard_messages.store_id IS NULL)" + } else { + "" + }; + format!( + "SELECT {MESSAGE_COLUMNS} + FROM dashboard_messages + LEFT JOIN users ON users.username = dashboard_messages.created_by + LEFT JOIN groups ON groups.id = dashboard_messages.store_id + {filter} + ORDER BY dashboard_messages.created_at DESC LIMIT 5" + ) +} + +/// AnnouncementDatabaseStorage.getAnnouncements() : toutes, sans limite +fn stored_sql() -> String { + format!( + "SELECT {MESSAGE_COLUMNS} + FROM dashboard_messages + LEFT JOIN users ON dashboard_messages.created_by = users.username + LEFT JOIN groups ON dashboard_messages.store_id = groups.id + ORDER BY dashboard_messages.created_at DESC" + ) +} + +impl MessageRow { + /// Champs de dashboard_messages, dans l'ordre de la table + fn message(&self) -> Map { + let mut message = Map::new(); + message.insert("id".into(), json!(self.id)); + message.insert("title".into(), json!(self.title)); + message.insert("content".into(), json!(self.content)); + message.insert("type".into(), json!(self.type_)); + message.insert("storeId".into(), json!(self.store_id)); + message.insert("createdBy".into(), json!(self.created_by)); + let created_at = self.created_at.as_ref().map(crate::json::format_ts); + message.insert("createdAt".into(), json!(created_at)); + message + } + + /// Forme de la liste (branche production de GET /api/announcements) + fn listed(&self) -> Value { + let mut message = self.message(); + message.insert("author".into(), self.author()); + // message.storeId && groupId ? { id, name } : null + let group = match (self.store_id, self.group_id) { + (Some(store_id), Some(group_id)) if store_id != 0 && group_id != 0 => { + json!({ "id": group_id, "name": self.group_name }) + } + _ => Value::Null, + }; + message.insert("group".into(), group); + Value::Object(message) + } + + /// Auteur affiché : nom d'affichage, sinon prénom et nom, sinon identifiant ; + /// « Utilisateur Inconnu » si le compte n'existe plus + fn author(&self) -> Value { + let Some(author_id) = self.author_id.as_deref().filter(|id| !id.is_empty()) else { + return json!({ + "id": self.created_by, + "firstName": "Utilisateur", + "lastName": "Inconnu", + "username": self.created_by, + }); + }; + let non_empty = |name: &Option| name.clone().filter(|name| !name.is_empty()); + let username = self.author_username.clone().unwrap_or_default(); + let (first_name, last_name) = ( + non_empty(&self.author_first_name), + non_empty(&self.author_last_name), + ); + let full_name = match (&first_name, &last_name) { + (Some(first), Some(last)) => Some(format!("{first} {last}")), + _ => None, + }; + let display_name = non_empty(&self.author_name) + .or(full_name) + .unwrap_or_else(|| username.clone()); + let mut words = display_name.split(' '); + let first_word = words.next().map(str::to_string).filter(|word| !word.is_empty()); + let other_words = Some(words.collect::>().join(" ")).filter(|words| !words.is_empty()); + json!({ + "id": author_id, + "firstName": first_name.or(first_word).unwrap_or_else(|| username.clone()), + "lastName": last_name.or(other_words).unwrap_or_default(), + "username": username, + }) + } + + /// Forme de storage.getAnnouncements() : auteur et magasin joints tels quels + /// (null quand la jointure ne trouve rien, comme Drizzle) + fn stored(&self) -> Value { + let mut message = self.message(); + let author = match &self.author_id { + Some(id) => json!({ + "id": id, + "firstName": self.author_first_name, + "lastName": self.author_last_name, + "username": self.author_username, + }), + None => Value::Null, + }; + let group = match self.group_id { + Some(id) => json!({ "id": id, "name": self.group_name }), + None => Value::Null, + }; + message.insert("author".into(), author); + message.insert("group".into(), group); + Value::Object(message) + } +} + +/// None quand la requête de Node échoue (magasin illisible, base indisponible) +async fn latest(db: &PgPool, store_filter: Option<&str>) -> Option> { + let result = match store_filter { + None => { + sqlx::query_as(&latest_sql(false)) + .persistent(false) + .fetch_all(db) + .await + } + Some(raw) => { + // parseInt illisible ou hors limites : PostgreSQL refuse le paramètre + let store_id = parse_int4(raw)?; + sqlx::query_as(&latest_sql(true)) + .bind(store_id) + .persistent(false) + .fetch_all(db) + .await + } + }; + result + .map_err(|error| tracing::warn!(%error, "informations : repli sur la lecture complète")) + .ok() +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + // Admin avec ?storeId : informations de ce magasin et informations globales + let store_filter = if user.is_admin() { + query.truthy("storeId") + } else { + None + }; + let announcements: Vec = match latest(&state.db, store_filter).await { + Some(rows) => rows.iter().map(MessageRow::listed).collect(), + None => { + let rows: Vec = sqlx::query_as(&stored_sql()) + .persistent(false) + .fetch_all(&state.db) + .await + .or_500("Failed to fetch announcements")?; + rows.iter().map(MessageRow::stored).collect() + } + }; + Ok(Json(announcements).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create announcement"; + if !user.is_admin() { + return Err(AppError::forbidden( + "Only administrators can create announcements", + )); + } + // Champs repris un par un du corps : type « info » et magasin null à défaut + let mut candidate = Map::new(); + for key in ["title", "content"] { + if let Some(value) = body.get(key) { + candidate.insert(key.into(), value.clone()); + } + } + let given = |key: &str| body.get(key).filter(|value| truthy(value)).cloned(); + candidate.insert("type".into(), given("type").unwrap_or_else(|| json!("info"))); + candidate.insert("storeId".into(), given("storeId").unwrap_or(Value::Null)); + candidate.insert("createdBy".into(), json!(user.user.username)); + let data = insert_schema() + .parse(&Value::Object(candidate)) + .map_err(|issues| AppError::validation("Validation error", issues))?; + + let created: DashboardMessagesRow = match db::insert(&state.db, &data).await { + Ok(row) => row, + Err(error) => { + // Repli de Node : storage.createAnnouncement refait l'insertion et + // rend la ligne seule + tracing::warn!(%error, "information : nouvel essai d'enregistrement"); + let row: DashboardMessagesRow = db::insert(&state.db, &data).await.or_500(FAILED)?; + return Ok((StatusCode::CREATED, Json(row)).into_response()); + } + }; + let mut announcement = match serde_json::to_value(created).or_500(FAILED)? { + Value::Object(fields) => fields, + _ => Map::new(), + }; + let author = + json!({ "id": user.id(), "firstName": user.user.first_name, "lastName": user.user.last_name }); + announcement.insert("author".into(), author); + announcement.insert("group".into(), Value::Null); + Ok((StatusCode::CREATED, Json(announcement)).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update announcement"; + if !user.is_admin() { + return Err(AppError::forbidden("Only administrators can edit announcements")); + } + // Identifiant illisible : la lecture de l'information échoue côté Node + let id = parse_int4(&id).ok_or_else(|| AppError::internal(FAILED))?; + let existing: Option<(i32,)> = sqlx::query_as("SELECT id FROM dashboard_messages WHERE id = $1") + .bind(id) + .fetch_optional(&state.db) + .await + .or_500(FAILED)?; + if existing.is_none() { + return Err(AppError::not_found("Announcement not found")); + } + let data = insert_schema() + .partial() + .parse(&body) + .map_err(|issues| AppError::validation("Validation error", issues))?; + // Corps vide après validation : Drizzle refuse (« No values to set ») + let updated = db::update_by_id::(&state.db, id, &data, false) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::internal(FAILED))?; + Ok(Json(updated).into_response()) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete announcement"; + if !user.is_admin() { + return Err(AppError::forbidden( + "Only administrators can delete announcements", + )); + } + let id = parse_int4(&id).ok_or_else(|| AppError::internal(FAILED))?; + // Repli de Node sur erreur : storage.deleteAnnouncement refait la suppression + let deleted = match db::delete_by_id::(&state.db, id).await { + Ok(count) => count, + Err(error) => { + tracing::warn!(%error, "information : nouvel essai de suppression"); + db::delete_by_id::(&state.db, id) + .await + .or_500(FAILED)? + } + }; + if deleted == 0 { + return Err(AppError::not_found("Announcement not found")); + } + Ok(Json(json!({ "message": "Announcement deleted successfully" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(name: Option<&str>, first: Option<&str>, last: Option<&str>) -> MessageRow { + MessageRow { + id: 1, + title: "t".into(), + content: "c".into(), + type_: "info".into(), + store_id: Some(0), + created_by: "auteur".into(), + created_at: None, + author_id: Some("u1".into()), + author_username: Some("auteur".into()), + author_first_name: first.map(Into::into), + author_last_name: last.map(Into::into), + author_name: name.map(Into::into), + group_id: Some(1), + group_name: Some("Nancy".into()), + } + } + + fn names(row: MessageRow) -> (String, String) { + let author = row.author(); + ( + author["firstName"].as_str().unwrap().to_string(), + author["lastName"].as_str().unwrap().to_string(), + ) + } + + #[test] + fn auteur_affiche_comme_node() { + assert_eq!( + names(row(None, Some("Alice"), Some("Admin"))), + ("Alice".into(), "Admin".into()) + ); + assert_eq!( + names(row(Some("Équipe Rédaction Nancy"), None, None)), + ("Équipe".into(), "Rédaction Nancy".into()) + ); + assert_eq!( + names(row(None, Some("Paulette"), None)), + ("Paulette".into(), "".into()) + ); + assert_eq!( + names(row(None, None, Some("Martin"))), + ("auteur".into(), "Martin".into()) + ); + let mut missing = row(None, None, None); + missing.author_id = None; + assert_eq!(missing.author()["lastName"], "Inconnu"); + assert_eq!(missing.stored()["author"], Value::Null); + } + + #[test] + fn magasin_joint_seulement_si_store_id_vrai() { + // storeId 0 : pas de magasin dans la liste, même s'il existe + assert_eq!(row(None, None, None).listed()["group"], Value::Null); + let mut nancy = row(None, None, None); + nancy.store_id = Some(1); + assert_eq!(nancy.listed()["group"], json!({ "id": 1, "name": "Nancy" })); + } +} diff --git a/server-rs/src/modules/avoirs.rs b/server-rs/src/modules/avoirs.rs new file mode 100644 index 0000000..b470e18 --- /dev/null +++ b/server-rs/src/modules/avoirs.rs @@ -0,0 +1,570 @@ +// Avoirs : /api/avoirs (server/routes.ts, « Avoir routes » ; storage.ts getAvoirs à +// updateAvoirNocodbVerification). La création et le passage au statut « Reçu » +// envoient l'avoir au webhook du magasin (node-fetch, sans délai maximal) ; s'il +// répond 2xx, l'avoir est marqué « webhook envoyé ». +// +// Défauts de Node reproduits : +// - GET /api/avoirs : un directeur voit les avoirs de tous les magasins, et un +// compte sans magasin, sans storeId, voit tous les avoirs (liste de magasins vide +// = aucun filtre) (AVOIRS-API-08) ; +// - PUT : montant, référence et commentaire absents du corps sont effacés +// (AVOIRS-API-06) ; magasin, fournisseur et créateur modifiables sans contrôle, +// ni règle de rôle pour « Reçu » ni protection des avoirs validés (AVOIRS-API-07) ; +// - webhook-status et nocodb-verification ne contrôlent pas le magasin (directeur +// compris) et acceptent un identifiant inexistant (AVOIRS-API-08) ; +// - le webhook porte un nom de fournisseur factice (AVOIRS-API-05) ; +// - GET /:id joint la ligne complète du magasin (mot de passe SMTP remplacé par +// smtpPasswordSet), la liste sa projection de rapprochement. + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use serde_json::{Map, Value, json}; +use sqlx::PgPool; + +use crate::AppState; +use crate::access::has_permission; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::json::{spread, strip_smtp_password}; +use crate::modules::bap::node_fetch_post; +use crate::modules::now_iso; +use crate::projections::{GroupReconciliation, UserSummary}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{AvoirsRow, GroupsRow, SuppliersRow}; +use crate::services::invoice_verification as verification; +use crate::services::nocodb::{database_message, not_a_string_error, truthy}; +use crate::zod::{Field, Object, Parsed}; + +pub fn routes() -> Router { + Router::new() + .route("/api/avoirs", get(list).post(create)) + .route("/api/avoirs/{id}", get(show).put(update).delete(remove)) + .route("/api/avoirs/{id}/verify-invoice", post(verify_invoice)) + .route("/api/avoirs/{id}/webhook-status", put(webhook_status)) + .route("/api/avoirs/{id}/nocodb-verification", put(nocodb_verification)) +} + +const STATUSES: &[&str] = &["En attente de demande", "Demandé", "Reçu"]; +const RECEIVED: &str = "Reçu"; + +/// insertAvoirSchema (shared/schema.ts) +fn avoir_schema() -> Object { + AvoirsRow::insert_schema() + .omit(&[ + "id", + "createdAt", + "updatedAt", + "webhookSent", + "nocodbVerified", + "nocodbVerifiedAt", + "processedAt", + ]) + .extend(vec![ + Field::enumeration("status", STATUSES).default(json!("En attente de demande")), + Field::coerce_number("amount").optional().nullable(), + Field::string("invoiceReference").optional(), + Field::string("comment").optional(), + Field::boolean("commercialProcessed").default(json!(false)), + ]) +} + +/// Admin et directeur voient tous les magasins ; les autres, les leurs +fn sees_all_stores(user: &CurrentUser) -> bool { + ["admin", "directeur"].contains(&user.role()) +} + +/// storage.getAvoir : l'avoir de l'identifiant de l'adresse (parseInt, envoyé en +/// texte : un identifiant illisible fait échouer la requête, comme dans Node) +async fn find_avoir(db: &PgPool, raw_id: &str) -> Result, sqlx::Error> { + let id = js::parse_int(raw_id).map_or_else(|| "NaN".to_string(), js::number_to_string); + sqlx::query_as(&AvoirsRow::select("WHERE id = $1::int4")) + .bind(id) + .fetch_optional(db) + .await +} + +/// Avoir existant, sinon 404 ; une erreur de la base donne le 500 de l'adresse +async fn existing_avoir(db: &PgPool, raw_id: &str, failed: &str) -> Result { + find_avoir(db, raw_id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Avoir not found")) +} + +/// Hors admin et directeur, l'avoir doit être d'un magasin de l'utilisateur +fn check_store(user: &CurrentUser, avoir: &AvoirsRow, denied: &str) -> Result<(), AppError> { + if sees_all_stores(user) || user.group_ids().contains(&avoir.group_id) { + Ok(()) + } else { + Err(AppError::forbidden(denied)) + } +} + +// ---------- Lecture ---------- + +/// Magasins dont les avoirs sont lus +enum Scope { + /// Aucun filtre + All, + Stores(Vec), + /// Magasin demandé hors des siens : [] sans requête + Nothing, +} + +/// storeId de l'adresse : admin et directeur, ce magasin ; les autres, ce magasin +/// s'il est l'un des leurs, sinon rien ; sans storeId, les leurs (aucun pour un +/// compte sans magasin, donc tous les avoirs) +fn scope(user: &CurrentUser, query: &JsQuery) -> Result { + let store = query.truthy("storeId"); + if sees_all_stores(user) { + return Ok(match store { + Some(store) => Scope::Stores(vec![int_id(store, "Failed to fetch avoirs")?]), + None => Scope::All, + }); + } + let mine = user.group_ids(); + Ok(match store { + None if mine.is_empty() => Scope::All, + None => Scope::Stores(mine), + Some(store) => match js::parse_int(store) { + Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => { + Scope::Stores(vec![requested as i32]) + } + _ => Scope::Nothing, + }, + }) +} + +/// Fiches jointes à des avoirs, par identifiant (seconde moitié des leftJoin) +struct Relations { + suppliers: HashMap, + groups: HashMap, + creators: HashMap, +} + +impl Relations { + async fn load(db: &PgPool, avoirs: &[AvoirsRow]) -> Result { + let supplier_ids: Vec = avoirs.iter().map(|avoir| avoir.supplier_id).collect(); + let suppliers = db::find_by_ids::(db, &supplier_ids) + .await? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = avoirs.iter().map(|avoir| avoir.group_id).collect(); + let groups: Vec = sqlx::query_as(&format!( + "SELECT {} FROM groups g WHERE g.id = ANY($1)", + GroupReconciliation::columns("g") + )) + .bind(&group_ids) + .fetch_all(db) + .await?; + let creator_ids: Vec = avoirs.iter().map(|avoir| avoir.created_by.clone()).collect(); + Ok(Self { + suppliers, + groups: groups.into_iter().map(|group| (group.id, group)).collect(), + creators: UserSummary::by_ids(db, &creator_ids).await?, + }) + } + + /// { ...avoir, supplier, group, creator } (null quand la ligne jointe manque) + fn attach(&self, avoir: &AvoirsRow) -> Value { + let mut entry = spread(avoir); + entry.insert("supplier".into(), json!(self.suppliers.get(&avoir.supplier_id))); + entry.insert("group".into(), json!(self.groups.get(&avoir.group_id))); + entry.insert("creator".into(), json!(self.creators.get(&avoir.created_by))); + Value::Object(entry) + } +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch avoirs"; + let group_ids = match scope(&user, &query)? { + Scope::Nothing => return Ok(Json(json!([])).into_response()), + Scope::All => None, + Scope::Stores(ids) => Some(ids), + }; + let avoirs: Vec = sqlx::query_as(&AvoirsRow::select( + "WHERE ($1::int4[] IS NULL OR group_id = ANY($1)) ORDER BY created_at DESC", + )) + .bind(group_ids) + .fetch_all(&state.db) + .await + .or_500(FAILED)?; + let relations = Relations::load(&state.db, &avoirs).await.or_500(FAILED)?; + let out: Vec = avoirs.iter().map(|avoir| relations.attach(avoir)).collect(); + Ok(Json(out).into_response()) +} + +async fn show(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to fetch avoir"; + let avoir = existing_avoir(&state.db, &id, FAILED).await?; + check_store(&user, &avoir, "Access denied")?; + let supplier = db::find_by_id::(&state.db, avoir.supplier_id) + .await + .or_500(FAILED)?; + // getAvoir joint la ligne complète du magasin + let group = db::find_by_id::(&state.db, avoir.group_id) + .await + .or_500(FAILED)?; + let creators = UserSummary::by_ids(&state.db, std::slice::from_ref(&avoir.created_by)) + .await + .or_500(FAILED)?; + let mut body = spread(&avoir); + body.insert("supplier".into(), json!(supplier)); + body.insert("group".into(), json!(group)); + body.insert("creator".into(), json!(creators.get(&avoir.created_by))); + Ok(Json(strip_smtp_password(Value::Object(body))).into_response()) +} + +// ---------- Webhook du magasin ---------- + +/// Nom affiché du créateur : « prénom nom » s'il a les deux, sinon l'identifiant +fn display_name(user: &CurrentUser) -> String { + let account = &user.user; + match (account.first_name.as_deref(), account.last_name.as_deref()) { + (Some(first), Some(last)) if !first.is_empty() && !last.is_empty() => format!("{first} {last}"), + _ => account.username.clone(), + } +} + +/// storage.updateAvoirWebhookStatus +async fn set_webhook_sent(db: &PgPool, id: i32, sent: Value) -> Result<(), sqlx::Error> { + let mut data = Parsed::new(); + data.insert("webhookSent".into(), db::node_pg_value(&sent)); + db::update_by_id::(db, id, &data, true) + .await + .map(|_| ()) +} + +/// Envoie l'avoir au webhook de son magasin s'il en a un ; une réponse 2xx marque +/// l'avoir « webhook envoyé ». Toute erreur est seulement journalisée, comme Node. +async fn notify_store(db: &PgPool, avoir_id: i32, group_id: i32, payload: impl FnOnce(&GroupsRow) -> Value) { + let group = match db::find_by_id::(db, group_id).await { + Ok(Some(group)) => group, + Ok(None) => return, + Err(error) => { + tracing::error!(%error, avoir_id, "webhook avoir : lecture du magasin"); + return; + } + }; + let Some(url) = group.webhook_url.clone().filter(|url| !url.is_empty()) else { + return; + }; + let body = payload(&group).to_string(); + let response = node_fetch_post( + &url, + |request| request.header("Content-Type", "application/json").body(body), + None, + ) + .await; + match response { + Ok((status, _)) if status.is_success() => { + if let Err(error) = set_webhook_sent(db, avoir_id, Value::Bool(true)).await { + tracing::error!(%error, avoir_id, "webhook avoir : marquage"); + } + } + Ok((status, _)) => tracing::error!(avoir_id, %status, "webhook avoir refusé"), + Err(error) => tracing::error!(avoir_id, message = error.message, "webhook avoir en échec"), + } +} + +/// Champs communs des messages du webhook (createdAt / status, processedAt à part) +fn webhook_fields(avoir: &AvoirsRow, supplier_name: &str, group: &GroupsRow) -> Map { + let mut fields = Map::new(); + fields.insert("type".into(), json!("Avoir")); + fields.insert("avoirId".into(), json!(avoir.id)); + let row = spread(avoir); + fields.insert( + "invoiceReference".into(), + row.get("invoiceReference").cloned().unwrap_or(Value::Null), + ); + fields.insert("amount".into(), row.get("amount").cloned().unwrap_or(Value::Null)); + fields.insert("supplierName".into(), json!(supplier_name)); + fields.insert("groupName".into(), json!(group.name)); + fields.insert("comment".into(), json!(avoir.comment.clone().unwrap_or_default())); + fields.insert("commercialProcessed".into(), json!(avoir.commercial_processed)); + fields +} + +// ---------- Écriture ---------- + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create avoir"; + // { ...req.body, createdBy: user.id } + let mut input = body.as_object().cloned().unwrap_or_default(); + input.insert("createdBy".into(), json!(user.id())); + let data = avoir_schema() + .parse(&Value::Object(input)) + .map_err(|issues| AppError::validation("Validation error", issues))?; + let group_id = data.get("groupId").and_then(Value::as_f64).unwrap_or_default(); + if !sees_all_stores(&user) && !user.group_ids().iter().any(|id| f64::from(*id) == group_id) { + return Err(AppError::forbidden("Access denied to this group")); + } + let avoir: AvoirsRow = db::insert(&state.db, &data).await.or_500(FAILED)?; + tracing::info!(id = avoir.id, user = user.id(), "avoir créé"); + + let created_by = display_name(&user); + let row = spread(&avoir); + notify_store(&state.db, avoir.id, avoir.group_id, |group| { + let mut fields = webhook_fields(&avoir, "Unknown", group); + fields.insert("createdBy".into(), json!(created_by)); + fields.insert( + "createdAt".into(), + row.get("createdAt").cloned().unwrap_or(Value::Null), + ); + Value::Object(fields) + }) + .await; + // L'avoir tel qu'inséré, avant le marquage du webhook + Ok(Json(avoir).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update avoir"; + let existing = existing_avoir(&state.db, &id, FAILED).await?; + check_store(&user, &existing, "Access denied")?; + let validated = avoir_schema() + .partial() + .parse(&body) + .map_err(|issues| AppError::validation("Validation error", issues))?; + // Absents du corps, montant, référence et commentaire sont effacés (AVOIRS-API-06) + let mut data = validated.clone(); + for key in ["amount", "invoiceReference", "comment"] { + data.entry(key).or_insert(Value::Null); + } + let updated = db::update_by_id::(&state.db, existing.id, &data, true) + .await + .or_500(FAILED)?; + tracing::info!(id = existing.id, user = user.id(), "avoir modifié"); + + let becomes_received = + validated.get("status").and_then(Value::as_str) == Some(RECEIVED) && existing.status != RECEIVED; + let Some(updated) = updated else { + // Ligne disparue entre-temps : updatedAvoir.groupId lève une TypeError + return if becomes_received { + Err(AppError::internal(FAILED)) + } else { + Ok(json_or_empty(None::)) + }; + }; + if becomes_received { + // Magasin 1 par défaut pour l'admin si l'avoir n'en a pas (groupId à 0) + let group_id = if updated.group_id == 0 && user.is_admin() { + 1 + } else { + updated.group_id + }; + let created_by = display_name(&user); + notify_store(&state.db, updated.id, group_id, |group| { + let mut fields = webhook_fields(&updated, "Fournisseur", group); + fields.insert("status".into(), json!(RECEIVED)); + fields.insert("createdBy".into(), json!(created_by)); + fields.insert("processedAt".into(), json!(now_iso())); + Value::Object(fields) + }) + .await; + } + Ok(Json(updated).into_response()) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete avoir"; + let avoir = existing_avoir(&state.db, &id, FAILED).await?; + if !sees_all_stores(&user) { + return Err(AppError::forbidden("Insufficient permissions to delete avoirs")); + } + if user.role() == "directeur" && !user.group_ids().contains(&avoir.group_id) { + return Err(AppError::forbidden("Access denied")); + } + db::delete_by_id::(&state.db, avoir.id) + .await + .or_500(FAILED)?; + tracing::info!(id = avoir.id, user = user.id(), "avoir supprimé"); + Ok(Json(json!({ "message": "Avoir deleted successfully" })).into_response()) +} + +// ---------- Vérification et statuts ---------- + +async fn verify_invoice( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + let failed = |message: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Failed to verify avoir invoice", "error": message }), + ) + }; + let avoir = find_avoir(&state.db, &id) + .await + .map_err(|error| failed(database_message(&error)))? + .ok_or_else(|| AppError::not_found("Avoir not found"))?; + if !has_permission(user.role(), "deliveries", "view") { + return Err(AppError::forbidden("Insufficient permissions")); + } + // Ici, seul l'admin voit tous les magasins (directeur compris dans le contrôle) + if !user.is_admin() && !user.group_ids().contains(&avoir.group_id) { + return Err(AppError::forbidden("Access denied to this group")); + } + let supplier = db::find_by_id::(&state.db, avoir.supplier_id) + .await + .map_err(|error| failed(database_message(&error)))?; + let group = db::find_by_id::(&state.db, avoir.group_id) + .await + .map_err(|error| failed(database_message(&error)))?; + if supplier.is_none() || group.is_none() { + return Err(AppError::bad_request( + "Avoir missing supplier or group information", + )); + } + + // !invoiceReference || !invoiceReference.trim() + let reference = body.get("invoiceReference"); + let reference = match reference { + Some(value) if truthy(value) => match value.as_str() { + Some(text) => text, + None => { + return Err(failed(not_a_string_error( + "invoiceReference", + "trim", + Some(value), + ))); + } + }, + _ => return Err(AppError::bad_request("Référence de facture requise")), + }; + if js::trim(reference).is_empty() { + return Err(AppError::bad_request("Référence de facture requise")); + } + let force_refresh = body.get("forceRefresh").is_some_and(truthy); + // Les avoirs ne sont jamais « rapprochés » comme les livraisons + let result = verification::verify_invoice(&state, reference, avoir.group_id, force_refresh, false).await; + Ok(Json(result).into_response()) +} + +/// Seuls l'admin et le directeur changent l'état d'un avoir (sans contrôle du magasin) +fn admin_or_directeur(user: &CurrentUser) -> Result<(), AppError> { + if sees_all_stores(user) { + Ok(()) + } else { + Err(AppError::forbidden("Insufficient permissions")) + } +} + +async fn webhook_status( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update webhook status"; + admin_or_directeur(&user)?; + let id = int_id(&id, FAILED)?; + // webhookSent absent : seule la date de modification change + let mut data = Parsed::new(); + if let Some(sent) = body.get("webhookSent") { + data.insert("webhookSent".into(), db::node_pg_value(sent)); + } + db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Webhook status updated successfully" })).into_response()) +} + +async fn nocodb_verification( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update verification status"; + admin_or_directeur(&user)?; + let id = int_id(&id, FAILED)?; + let verified = body.get("verified"); + let is_verified = verified.is_some_and(truthy); + let mut data = Parsed::new(); + if let Some(verified) = verified { + data.insert("nocodbVerified".into(), db::node_pg_value(verified)); + } + data.insert( + "nocodbVerifiedAt".into(), + if is_verified { + json!(now_iso()) + } else { + Value::Null + }, + ); + db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + + // Validé : l'entrée du cache de sa facture devient permanente (erreur ignorée) + if is_verified { + match db::find_by_id::(&state.db, id).await { + Ok(Some(avoir)) => { + if let Some(reference) = avoir + .invoice_reference + .as_deref() + .filter(|reference| !js::trim(reference).is_empty()) + { + verification::update_cache_as_reconciled(&state.db, reference, avoir.group_id).await; + } + } + Ok(None) => {} + Err(error) => tracing::error!(%error, id, "marquage du cache de l'avoir validé"), + } + } + tracing::info!( + id, + verified = is_verified, + user = user.id(), + "vérification NocoDB de l'avoir" + ); + Ok(Json(json!({ "message": "NocoDB verification status updated successfully" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_de_creation_comme_zod() { + let schema = avoir_schema(); + let data = schema + .parse(&json!({ "supplierId": 1, "groupId": 2, "amount": "", "createdBy": "u", "inconnu": 1 })) + .unwrap(); + assert_eq!( + Value::Object(data), + json!({ "supplierId": 1, "groupId": 2, "amount": 0, "commercialProcessed": false, + "status": "En attente de demande", "createdBy": "u" }) + ); + let issues = schema + .parse(&json!({ "supplierId": 1, "groupId": 1, "invoiceReference": null, "createdBy": "u" })) + .unwrap_err(); + assert_eq!(issues[0]["path"], json!(["invoiceReference"])); + assert_eq!(issues[0]["message"], json!("Expected string, received null")); + } + + #[test] + fn modification_partielle_sans_valeurs_par_defaut() { + let data = avoir_schema() + .partial() + .parse(&json!({ "status": "Reçu" })) + .unwrap(); + assert_eq!(Value::Object(data), json!({ "status": "Reçu" })); + } +} diff --git a/server-rs/src/modules/backups.rs b/server-rs/src/modules/backups.rs new file mode 100644 index 0000000..910a4ff --- /dev/null +++ b/server-rs/src/modules/backups.rs @@ -0,0 +1,563 @@ +// Sauvegardes de la base : /api/backups (server/routes.ts, « Backup management +// routes », et server/backupService.ts), réservées à l'administrateur. +// +// pg_dump est lancé sans shell, avec des arguments séparés ; mêmes options, même +// nom de fichier, mêmes lignes dans database_backups, dix sauvegardes gardées. +// +// Écart voulu (audit BACKUP-09) : Node joint le nom reçu dans l'adresse au +// dossier des sauvegardes sans le contrôler (« ../ » sort du dossier). Rust +// n'accepte qu'un simple nom de fichier (ni « / », ni « \ », ni « . » ou « .. ») ; +// sinon la réponse est celle d'une sauvegarde introuvable. +// +// Non reportées ici : la sauvegarde automatique (vérification horaire, et après +// chaque connexion) reste faite par Node jusqu'à la bascule. + +use std::path::{Path as FsPath, PathBuf}; +use std::process::Stdio; +use std::time::UNIX_EPOCH; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::delete, routing::get}; +use chrono::{DateTime, Utc}; +use rand::RngCore; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal}; +use crate::schema::DatabaseBackupsRow; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/backups", get(list).post(create)) + .route("/api/backups/{filename}", delete(remove)) + .route("/api/backups/{filename}/download", get(download)) +} + +/// maxBackups de backupService +const MAX_BACKUPS: i64 = 10; + +fn access_denied() -> AppError { + AppError::forbidden("Access denied") +} + +fn admin_only(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(access_denied()) + } +} + +/// Simple nom de fichier, sans chemin (BACKUP-09) +fn is_plain_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// nanoid() : 21 caractères de l'alphabet sûr pour les adresses +fn nanoid() -> String { + const ALPHABET: &[u8] = b"useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; + let mut bytes = [0u8; 21]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes + .iter() + .map(|byte| ALPHABET[(byte & 63) as usize] as char) + .collect() +} + +/// backup__.sql +fn backup_filename(kind: &str, now: &DateTime) -> String { + let stamp = crate::js::iso(now).replace([':', '.'], "-"); + format!("backup_{kind}_{stamp}.sql") +} + +/// Paramètres de connexion lus dans DATABASE_URL comme new URL() : nom, mot de +/// passe et base gardent leur encodage d'origine +#[derive(Debug, PartialEq)] +struct DatabaseConfig { + host: String, + port: String, + database: String, + username: String, + password: String, +} + +fn database_config(url: &str) -> Option { + let (_, rest) = url.split_once("://")?; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, tail) = rest.split_at(authority_end); + let (userinfo, host_port) = match authority.rsplit_once('@') { + Some((userinfo, host_port)) => (userinfo, host_port), + None => ("", authority), + }; + let (username, password) = userinfo.split_once(':').unwrap_or((userinfo, "")); + let (host, port) = if host_port.starts_with('[') { + let end = host_port.find(']')?; + let port = host_port[end + 1..].strip_prefix(':').unwrap_or(""); + (&host_port[..=end], port) + } else { + host_port.rsplit_once(':').unwrap_or((host_port, "")) + }; + let path = tail.split(['?', '#']).next().unwrap_or(""); + Some(DatabaseConfig { + host: host.to_string(), + port: if port.is_empty() { + "5432".into() + } else { + port.to_string() + }, + database: path.strip_prefix('/').unwrap_or(path).to_string(), + username: username.to_string(), + password: password.to_string(), + }) +} + +/// Dossier des sauvegardes, créé au besoin (repli sur /tmp/backups comme +/// ensureBackupDirectory au démarrage de Node) +async fn backup_dir(state: &AppState) -> PathBuf { + let dir = state.config.backup_dir.clone(); + if tokio::fs::create_dir_all(&dir).await.is_ok() { + return dir; + } + let fallback = PathBuf::from("/tmp/backups"); + let _ = tokio::fs::create_dir_all(&fallback).await; + fallback +} + +/// getBackupList : les dix plus récentes ; une erreur donne une liste vide +async fn list(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let backups: Vec = + sqlx::query_as(&DatabaseBackupsRow::select("ORDER BY created_at DESC LIMIT $1")) + .bind(MAX_BACKUPS) + .fetch_all(&state.db) + .await + .unwrap_or_else(|error| { + tracing::error!(%error, "liste des sauvegardes"); + Vec::new() + }); + Ok(Json(backups).into_response()) +} + +async fn create(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let backup = create_backup(&state, "manual", user.id()) + .await + .or_500("Failed to create backup")?; + Ok(Json(backup).into_response()) +} + +/// backupService.createBackup : ligne « creating », pg_dump, puis taille et +/// nombre de tables, et ménage des anciennes sauvegardes +pub(crate) async fn create_backup( + state: &AppState, + kind: &str, + created_by: &str, +) -> anyhow::Result { + let now = Utc::now(); + let id = nanoid(); + let filename = backup_filename(kind, &now); + let dir = backup_dir(state).await; + let filepath = dir.join(&filename); + let label = if kind == "manual" { "Manuel" } else { "Automatique" }; + + let mut row = Parsed::new(); + row.insert("id".into(), Value::String(id.clone())); + row.insert("filename".into(), Value::String(filename.clone())); + // toLocaleDateString('fr-FR') : JJ/MM/AAAA, date de Paris (décision R5) + row.insert( + "description".into(), + Value::String(format!( + "{label} backup du {}", + now.with_timezone(&chrono_tz::Europe::Paris).format("%d/%m/%Y") + )), + ); + row.insert("size".into(), json!(0)); + row.insert("createdBy".into(), Value::String(created_by.to_string())); + row.insert("tablesCount".into(), json!(0)); + row.insert("status".into(), json!("creating")); + row.insert("backupType".into(), Value::String(kind.to_string())); + db::insert::(&state.db, &row).await?; + + let config = database_config(&state.config.database_url) + .ok_or_else(|| anyhow::anyhow!("DATABASE_URL illisible"))?; + run_pg_dump(&config, &filepath).await?; + + let size = tokio::fs::metadata(&filepath).await?.len() as i64; + let (tables_count,): (i32,) = sqlx::query_as( + "SELECT count(*)::int AS count + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema')", + ) + .fetch_one(&state.db) + .await?; + let updated: DatabaseBackupsRow = sqlx::query_as(&format!( + "UPDATE database_backups SET size = $1, tables_count = $2, status = 'completed' WHERE id = $3 RETURNING {}", + DatabaseBackupsRow::COLUMNS + )) + .bind(size) + .bind(tables_count) + .bind(&id) + .fetch_one(&state.db) + .await?; + tracing::info!(%filename, size, tables_count, "sauvegarde {kind} créée"); + + clean_old_backups(state, &dir).await; + Ok(updated) +} + +/// pg_dump -h … -p … -U … -d … --no-password --verbose --clean --if-exists +/// --create > fichier, sans shell ; mot de passe par PGPASSWORD +async fn run_pg_dump(config: &DatabaseConfig, filepath: &FsPath) -> anyhow::Result<()> { + let mut command = tokio::process::Command::new("pg_dump"); + command + .args(["-h", &config.host, "-p", &config.port, "-U", &config.username]) + .args(["-d", &config.database]) + .args(["--no-password", "--verbose", "--clean", "--if-exists", "--create"]) + .env("PGPASSWORD", &config.password); + run_to_file(command, filepath).await +} + +/// Lance la commande, sa sortie standard dans le fichier (créé ou vidé, comme +/// « > fichier »), et attend sa fin ; erreur si elle échoue +async fn run_to_file(mut command: tokio::process::Command, filepath: &FsPath) -> anyhow::Result<()> { + let file = tokio::fs::File::create(filepath).await?.into_std().await; + // spawn puis wait_with_output : output() de tokio remplacerait la sortie + // standard configurée par un tube + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::from(file)) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let result = child.wait_with_output().await?; + if !result.status.success() { + anyhow::bail!( + "{:?} a échoué ({}) : {}", + command.as_std().get_program(), + result.status, + String::from_utf8_lossy(&result.stderr) + .lines() + .last() + .unwrap_or("") + ); + } + Ok(()) +} + +/// cleanOldBackups : au-delà des dix plus récentes, fichier et ligne supprimés ; +/// les erreurs sont seulement journalisées +async fn clean_old_backups(state: &AppState, dir: &FsPath) { + // Toute la liste, triée comme Node, puis tout ce qui suit les dix premières + let all: Result, sqlx::Error> = + sqlx::query_as("SELECT filename FROM database_backups ORDER BY created_at DESC") + .fetch_all(&state.db) + .await; + match all { + Ok(all) => { + let old = all.get(MAX_BACKUPS as usize..).unwrap_or_default(); + for (filename,) in old { + if let Err(error) = delete_backup(state, dir, filename).await { + tracing::error!(%error, %filename, "suppression d'une ancienne sauvegarde"); + return; + } + } + if !old.is_empty() { + tracing::info!(count = old.len(), "anciennes sauvegardes supprimées"); + } + } + Err(error) => tracing::error!(%error, "ménage des sauvegardes"), + } +} + +/// backupService.deleteBackup : la ligne doit exister ; fichier puis lignes supprimés +async fn delete_backup(state: &AppState, dir: &FsPath, filename: &str) -> anyhow::Result<()> { + let found: Option<(String,)> = + sqlx::query_as("SELECT id FROM database_backups WHERE filename = $1 LIMIT 1") + .bind(filename) + .fetch_optional(&state.db) + .await?; + if found.is_none() { + anyhow::bail!("Backup record not found"); + } + // BACKUP-09 : jamais de fichier hors du dossier des sauvegardes + if is_plain_name(filename) { + let filepath = dir.join(filename); + // fs.existsSync suit les liens symboliques + if tokio::fs::metadata(&filepath).await.is_ok() { + tokio::fs::remove_file(&filepath).await?; + } + } + sqlx::query("DELETE FROM database_backups WHERE filename = $1") + .bind(filename) + .execute(&state.db) + .await?; + Ok(()) +} + +async fn remove(State(state): State, user: CurrentUser, Path(filename): Path) -> ApiResult { + const FAILED: &str = "Failed to delete backup"; + admin_only(&user)?; + if !is_plain_name(&filename) { + return Err(AppError::internal(FAILED)); + } + let dir = backup_dir(&state).await; + delete_backup(&state, &dir, &filename).await.or_500(FAILED)?; + Ok(Json(json!({ "message": "Backup deleted successfully" })).into_response()) +} + +/// res.download(fichier, nom) : fichier joint, type selon l'extension (paquet +/// send), ETag faible taille-date, 304 si le navigateur l'a déjà +async fn download( + State(state): State, + user: CurrentUser, + method: Method, + headers: HeaderMap, + Path(filename): Path, +) -> ApiResult { + const FAILED: &str = "Failed to download backup"; + admin_only(&user)?; + if !is_plain_name(&filename) { + return Err(AppError::internal(FAILED)); + } + let filepath = backup_dir(&state).await.join(&filename); + // fs.existsSync : absent → 500 ; dossier ou fichier caché refusé par send → 404 + let metadata = tokio::fs::metadata(&filepath) + .await + .map_err(|_| AppError::internal(FAILED))?; + let not_found = || AppError::not_found("Backup file not found"); + if !metadata.is_file() || filename.starts_with('.') { + return Err(not_found()); + } + let modified = metadata.modified().map_err(|_| not_found())?; + let mtime_ms = modified + .duration_since(UNIX_EPOCH) + .map_err(|_| not_found())? + .as_millis(); + let etag = format!("W/\"{:x}-{:x}\"", metadata.len(), mtime_ms); + let last_modified = DateTime::::from(modified) + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string(); + + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-cache"), + ); + let disposition = HeaderValue::from_bytes(&content_disposition(&filename)).map_err(|_| not_found())?; + response_headers.insert(header::CONTENT_DISPOSITION, disposition); + response_headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Ok(value) = HeaderValue::from_str(&last_modified) { + response_headers.insert(header::LAST_MODIFIED, value); + } + if let Ok(value) = HeaderValue::from_str(&etag) { + response_headers.insert(header::ETAG, value); + } + response_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(&filename)), + ); + if is_fresh(&headers, &etag, &modified) { + return Ok((StatusCode::NOT_MODIFIED, response_headers).into_response()); + } + response_headers.insert(header::CONTENT_LENGTH, HeaderValue::from(metadata.len())); + if method == Method::HEAD { + return Ok((StatusCode::OK, response_headers).into_response()); + } + let content = tokio::fs::read(&filepath).await.map_err(|_| not_found())?; + Ok((StatusCode::OK, response_headers, Body::from(content)).into_response()) +} + +/// Type du paquet mime 1.x utilisé par send (sauvegardes : .sql) +fn content_type(filename: &str) -> &'static str { + match filename + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .as_deref() + { + Some("sql") => "application/x-sql", + Some("gz") => "application/gzip", + Some("txt") => "text/plain; charset=UTF-8", + Some("json") => "application/json; charset=UTF-8", + _ => "application/octet-stream", + } +} + +/// Paquet « fresh » d'Express : If-None-Match, sinon If-Modified-Since +fn is_fresh(request: &HeaderMap, etag: &str, modified: &std::time::SystemTime) -> bool { + let cache_control = request + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if cache_control.contains("no-cache") { + return false; + } + if let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) { + let bare = etag.trim_start_matches("W/"); + return none_match.trim() == "*" + || none_match + .split(',') + .any(|candidate| candidate.trim().trim_start_matches("W/") == bare); + } + if let Some(since) = request + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + .and_then(|since| DateTime::parse_from_rfc2822(since).ok()) + { + return DateTime::::from(*modified).timestamp() <= since.timestamp(); + } + false +} + +/// Paquet content-disposition : attachment; filename="…" (texte latin-1, « ? » +/// pour le reste) et filename*=UTF-8''… quand le nom n'est pas en latin-1 ou +/// contient une séquence %XX. Octets latin-1 : la valeur de l'en-tête tel que +/// Node l'écrit. +fn content_disposition(filename: &str) -> Vec { + let is_latin1_text = filename + .chars() + .all(|c| matches!(c as u32, 0x20..=0x7e | 0x80..=0xff)); + // Une unité UTF-16 hors latin-1 devient « ? » (deux pour un caractère astral) + let fallback: String = filename + .chars() + .flat_map(|c| match c as u32 { + 0x20..=0x7e | 0xa0..=0xff => vec![c], + _ => vec!['?'; c.len_utf16()], + }) + .collect(); + let has_fallback = fallback != filename; + let has_hex_escape = filename + .as_bytes() + .windows(3) + .any(|w| w[0] == b'%' && w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit()); + + let mut out: Vec = b"attachment".to_vec(); + if is_latin1_text || has_fallback { + let name = if has_fallback { &fallback } else { filename }; + out.extend_from_slice(b"; filename=\""); + for c in name.chars() { + if c == '\\' || c == '"' { + out.push(b'\\'); + } + // Caractères latin-1 : un octet chacun + out.push(c as u32 as u8); + } + out.push(b'"'); + } + if has_fallback || !is_latin1_text || has_hex_escape { + out.extend_from_slice(b"; filename*=UTF-8''"); + for byte in filename.bytes() { + let keep = byte.is_ascii_alphanumeric() || b"-_.!~".contains(&byte); + if keep { + out.push(byte); + } else { + out.extend_from_slice(format!("%{byte:02X}").as_bytes()); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn noms_de_fichier_acceptes() { + assert!(is_plain_name("backup_manual_2026-10-15T08-30-00-123Z.sql")); + assert!(is_plain_name("été x.sql")); + assert!(!is_plain_name("../etc/passwd")); + assert!(!is_plain_name("..")); + assert!(!is_plain_name(".")); + assert!(!is_plain_name("a/b.sql")); + assert!(!is_plain_name("a\\b.sql")); + assert!(!is_plain_name("")); + } + + #[test] + fn nom_et_identifiant_comme_node() { + let now = Utc.timestamp_millis_opt(1_792_151_360_449).unwrap(); + assert_eq!( + backup_filename("manual", &now), + "backup_manual_2026-10-16T11-49-20-449Z.sql" + ); + let id = nanoid(); + assert_eq!(id.len(), 21); + assert!( + id.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + ); + } + + #[test] + fn parametres_de_connexion_comme_new_url() { + assert_eq!( + database_config("postgres://logiflow_admin:bench@localhost:5432/bench_lot5_droite"), + Some(DatabaseConfig { + host: "localhost".into(), + port: "5432".into(), + database: "bench_lot5_droite".into(), + username: "logiflow_admin".into(), + password: "bench".into(), + }) + ); + let config = database_config("postgresql://u:p%40ss@db/logiflow?sslmode=disable").unwrap(); + assert_eq!( + ( + config.host.as_str(), + config.port.as_str(), + config.database.as_str(), + config.password.as_str() + ), + ("db", "5432", "logiflow", "p%40ss") + ); + } + + #[tokio::test] + async fn sortie_de_la_commande_ecrite_dans_le_fichier() { + let dir = std::env::temp_dir().join(format!("logiflow-test-{}", nanoid())); + tokio::fs::create_dir_all(&dir).await.unwrap(); + let filepath = dir.join("sortie.sql"); + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "echo '-- dump'; echo 'journal' >&2"]); + run_to_file(command, &filepath).await.unwrap(); + assert_eq!(tokio::fs::read_to_string(&filepath).await.unwrap(), "-- dump\n"); + + let mut failing = tokio::process::Command::new("sh"); + failing.args(["-c", "echo 'connexion refusée' >&2; exit 1"]); + let error = run_to_file(failing, &filepath).await.unwrap_err(); + assert!(error.to_string().contains("connexion refusée")); + tokio::fs::remove_dir_all(&dir).await.unwrap(); + } + + #[test] + fn en_tete_de_piece_jointe_comme_node() { + // Valeurs du paquet content-disposition (Node) + let header = |name: &str| String::from_utf8_lossy(&content_disposition(name)).to_string(); + assert_eq!( + header("backup_banc_1.sql"), + "attachment; filename=\"backup_banc_1.sql\"" + ); + assert_eq!(header("a\"b.sql"), "attachment; filename=\"a\\\"b.sql\""); + assert_eq!( + header("日本.sql"), + "attachment; filename=\"??.sql\"; filename*=UTF-8''%E6%97%A5%E6%9C%AC.sql" + ); + assert_eq!( + header("100%25.sql"), + "attachment; filename=\"100%25.sql\"; filename*=UTF-8''100%2525.sql" + ); + // « é » part en un octet latin-1, comme Node + assert_eq!( + content_disposition("été.sql"), + b"attachment; filename=\"\xe9t\xe9.sql\"".to_vec() + ); + } +} diff --git a/server-rs/src/modules/bap.rs b/server-rs/src/modules/bap.rs new file mode 100644 index 0000000..2bf8d61 --- /dev/null +++ b/server-rs/src/modules/bap.rs @@ -0,0 +1,389 @@ +// Bon à payer (BAP) : configuration du webhook n8n (/api/webhook-bap-config, test +// compris) et envoi d'un PDF au webhook (/api/bap/send-webhook), réservés à l'admin +// (server/routes.ts). Les appels sortants reprennent les messages d'erreur de +// node-fetch, que le serveur Node utilise pour ces adresses. +// +// Non repris : la configuration par défaut renvoyée par Node quand la table +// webhook_bap_config n'existe pas (code 42P01) ; la table existe partout. + +use std::sync::LazyLock; +use std::time::Duration; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::request::JsonBody; +use crate::schema::WebhookBapConfigRow; +use crate::services::nocodb::{database_message, truthy}; +use crate::zod::Object; + +pub fn routes() -> Router { + Router::new() + .route("/api/webhook-bap-config", get(get_config).post(save_config)) + .route("/api/webhook-bap-config/test", post(test_webhook)) + .route("/api/bap/send-webhook", post(send_webhook)) +} + +/// Délai maximal du test du webhook et de l'envoi d'un BAP +const TEST_TIMEOUT: Duration = Duration::from_secs(10); +const SEND_TIMEOUT: Duration = Duration::from_secs(60); + +static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("client HTTP des webhooks") +}); + +/// insertWebhookBapConfigSchema +fn insert_schema() -> Object { + WebhookBapConfigRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +fn error_body(status: StatusCode, error: &str) -> AppError { + AppError::with_body(status, json!({ "error": error })) +} + +/// Erreur 500 de Node : { error, details } +fn server_error(error: &str, details: &str) -> AppError { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": error, "details": details }), + ) +} + +fn admin_only(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(error_body( + StatusCode::FORBIDDEN, + "Accès refusé - Admin uniquement", + )) + } +} + +/// storage.getWebhookBapConfig : la première ligne, sans ordre (comme Node) +async fn load_config(db: &sqlx::PgPool) -> Result, sqlx::Error> { + sqlx::query_as(&WebhookBapConfigRow::select("LIMIT 1")) + .fetch_optional(db) + .await +} + +async fn get_config(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let config = load_config(&state.db) + .await + .map_err(|error| server_error("Erreur serveur", &database_message(&error)))?; + Ok(Json(config).into_response()) +} + +async fn save_config( + State(state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + admin_only(&user)?; + // Erreur de validation en 500, avec le message de ZodError (les problèmes en JSON indenté) + let data = insert_schema().parse(&body).map_err(|issues| { + let details = serde_json::to_string_pretty(&issues).unwrap_or_default(); + server_error("Erreur serveur", &details) + })?; + let database_error = |error: sqlx::Error| server_error("Erreur serveur", &database_message(&error)); + let config = match load_config(&state.db).await.map_err(database_error)? { + Some(existing) => db::update_by_id::(&state.db, existing.id, &data, true) + .await + .map_err(database_error)? + .ok_or_else(|| { + server_error( + "Erreur serveur", + "Cannot read properties of undefined (reading 'id')", + ) + })?, + None => db::insert::(&state.db, &data) + .await + .map_err(database_error)?, + }; + tracing::info!( + id = config.id, + name = config.name, + active = config.is_active, + "configuration du webhook BAP sauvegardée" + ); + Ok(Json(config).into_response()) +} + +// ---------- Appels sortants, au comportement de node-fetch ---------- +// Partagés avec les avoirs et l'envoi de facture du rapprochement, qui appellent +// aussi les webhooks avec node-fetch dans Node. + +/// Échec d'un appel : `aborted` pour un délai dépassé (AbortError) +pub(crate) struct FetchError { + pub(crate) aborted: bool, + pub(crate) message: String, +} + +impl FetchError { + fn new(message: String) -> Self { + Self { + aborted: false, + message, + } + } +} + +/// Cause d'un échec réseau telle que Node la décrit (connect ECONNREFUSED…) +async fn network_reason(url: &reqwest::Url, error: &reqwest::Error) -> String { + let host = url + .host_str() + .unwrap_or_default() + .trim_matches(['[', ']']) + .to_string(); + let port = url.port_or_known_default().unwrap_or(80); + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = source { + if let Some(io) = current.downcast_ref::() { + if io.kind() == std::io::ErrorKind::ConnectionRefused { + // Adresse réellement jointe : celle que le nom désigne + let address = tokio::net::lookup_host((host.as_str(), port)) + .await + .ok() + .and_then(|mut addresses| addresses.next()) + .map_or_else(|| host.clone(), |address| address.ip().to_string()); + return format!("connect ECONNREFUSED {address}:{port}"); + } + } + let text = current.to_string(); + if text.contains("dns error") || text.contains("failed to lookup address") { + return format!("getaddrinfo ENOTFOUND {host}"); + } + source = current.source(); + } + error.to_string() +} + +/// fetch(url, { method: 'POST', body: JSON }) de node-fetch : statut et corps de la +/// réponse, ou l'erreur que node-fetch lèverait +async fn post_json( + raw_url: &str, + body: &Value, + timeout: Duration, +) -> Result<(StatusCode, String), FetchError> { + node_fetch_post( + raw_url, + |request| { + request + .header("Content-Type", "application/json") + .body(body.to_string()) + }, + Some(timeout), + ) + .await +} + +/// fetch(url, { method: 'POST', … }) de node-fetch : `prepare` ajoute le corps et +/// ses en-têtes ; sans délai maximal si `timeout` est None (fetch sans signal). +/// Rend le statut et le corps de la réponse, ou l'erreur que node-fetch lèverait. +pub(crate) async fn node_fetch_post( + raw_url: &str, + prepare: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder, + timeout: Option, +) -> Result<(StatusCode, String), FetchError> { + let url = reqwest::Url::parse(raw_url).map_err(|_| FetchError::new("Invalid URL".to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(FetchError::new(format!( + "node-fetch cannot load {raw_url}. URL scheme \"{}\" is not supported.", + url.scheme() + ))); + } + let mut request = prepare(CLIENT.post(url.clone())); + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + let response = request.send().await; + let response = match response { + Ok(response) => response, + Err(error) if error.is_timeout() => { + return Err(FetchError { + aborted: true, + message: "The operation was aborted.".to_string(), + }); + } + Err(error) => { + let reason = network_reason(&url, &error).await; + return Err(FetchError::new(format!( + "request to {url} failed, reason: {reason}" + ))); + } + }; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| FetchError::new(error.to_string()))?; + Ok((status, text)) +} + +/// response.statusText +fn status_text(status: StatusCode) -> &'static str { + status.canonical_reason().unwrap_or_default() +} + +/// texte.substring(0, 200), en unités UTF-16 +fn first_200_units(text: &str) -> String { + let units: Vec = text.encode_utf16().take(200).collect(); + String::from_utf16_lossy(&units) +} + +async fn test_webhook( + State(_state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + admin_only(&user)?; + let Some(webhook_url) = body.get("webhookUrl").filter(|url| truthy(url)) else { + return Err(error_body(StatusCode::BAD_REQUEST, "URL webhook requise")); + }; + let payload = json!({ + "test": true, + "timestamp": crate::modules::now_iso(), + "message": "Test de connectivité depuis LogiFlow", + }); + match post_json(&js::to_js_string(webhook_url), &payload, TEST_TIMEOUT).await { + Ok((status, text)) if status.is_success() => Ok(Json(json!({ + "success": true, + "status": status.as_u16(), + "message": "Webhook accessible", + "response": first_200_units(&text), + })) + .into_response()), + Ok((status, _)) => { + let message = format!("HTTP {}: {}", status.as_u16(), status_text(status)); + Err(server_error(&message, &message)) + } + Err(error) if error.aborted => { + Err(server_error("Timeout - Le webhook ne répond pas", &error.message)) + } + Err(error) => Err(server_error(&error.message, &error.message)), + } +} + +/// Buffer.from(pdfBase64, 'base64').length, ou None si Node lève une TypeError +fn decoded_length(value: &Value) -> Option { + match value { + Value::String(text) => Some(js::buffer_from_base64(text).len()), + Value::Array(items) => Some(items.len()), + Value::Object(object) => match object.get("length") { + Some(Value::Number(length)) => Some(length.as_f64().map_or(0, |n| n.max(0.0) as usize)), + Some(_) => Some(0), + None => match (object.get("type"), object.get("data")) { + (Some(Value::String(kind)), Some(Value::Array(data))) if kind == "Buffer" => Some(data.len()), + _ => None, + }, + }, + _ => None, + } +} + +async fn send_webhook( + State(state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + admin_only(&user)?; + let field = |name: &str| body.get(name).filter(|value| truthy(value)); + let (Some(pdf_base64), Some(file_name), Some(recipient)) = + (field("pdfBase64"), field("fileName"), field("recipient")) + else { + return Err(error_body( + StatusCode::BAD_REQUEST, + "Données manquantes: pdfBase64, fileName ou recipient", + )); + }; + if !matches!(recipient.as_str(), Some("Laurie" | "Jeremy")) { + return Err(error_body(StatusCode::BAD_REQUEST, "Destinataire invalide")); + } + let Some(file_name_text) = file_name.as_str() else { + let message = "fileName.toLowerCase is not a function"; + return Err(server_error(message, message)); + }; + if !file_name_text.to_lowercase().ends_with(".pdf") { + return Err(error_body(StatusCode::BAD_REQUEST, "Le fichier doit être un PDF")); + } + let Some(file_size) = decoded_length(pdf_base64) else { + return Err(error_body(StatusCode::BAD_REQUEST, "Format base64 invalide")); + }; + + let config = load_config(&state.db).await.map_err(|error| { + let message = database_message(&error); + server_error(&message, &message) + })?; + let Some(config) = config.filter(|config| config.is_active) else { + return Err(error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Configuration webhook BAP non disponible", + )); + }; + let payload = json!({ + "recipient": recipient, + "fileName": file_name, + "fileSize": file_size, + "pdfBase64": pdf_base64, + "contentType": "application/pdf", + }); + tracing::info!( + recipient = recipient.as_str(), + file_size, + "envoi du BAP au webhook" + ); + match post_json(&config.webhook_url, &payload, SEND_TIMEOUT).await { + Ok((status, text)) if status.is_success() => Ok(Json(json!({ + "success": true, + "message": "Fichier envoyé avec succès", + "webhookResponse": text, + })) + .into_response()), + Ok((status, _)) => { + let message = format!("Erreur webhook: {} {}", status.as_u16(), status_text(status)); + Err(server_error(&message, &message)) + } + Err(error) if error.aborted => Err(server_error( + "Timeout - Le traitement a pris trop de temps", + &error.message, + )), + Err(error) => Err(server_error(&error.message, &error.message)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn taille_du_pdf_comme_buffer_from() { + assert_eq!(decoded_length(&json!("JVBERi0xLjQ=")), Some(8)); + assert_eq!(decoded_length(&json!([1, 2, 3])), Some(3)); + assert_eq!(decoded_length(&json!({ "length": 5 })), Some(5)); + assert_eq!(decoded_length(&json!({ "length": "x" })), Some(0)); + assert_eq!( + decoded_length(&json!({ "type": "Buffer", "data": [1, 2] })), + Some(2) + ); + assert_eq!(decoded_length(&json!({})), None); + assert_eq!(decoded_length(&json!(12)), None); + } + + #[test] + fn extrait_de_reponse_en_unites_utf16() { + assert_eq!(first_200_units(&"é".repeat(250)).chars().count(), 200); + } +} diff --git a/server-rs/src/modules/contacts.rs b/server-rs/src/modules/contacts.rs new file mode 100644 index 0000000..0c090ae --- /dev/null +++ b/server-rs/src/modules/contacts.rs @@ -0,0 +1,99 @@ +// Contacts des magasins : /api/contacts (server/routes.ts, « Contacts routes »). +// Comme Node aujourd'hui : un compte sans magasin voit tous les contacts (CONT-02) +// et un manager peut modifier ceux d'un autre magasin (CONT-03). + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::put}; +use serde_json::json; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::ContactsRow; +use crate::zod::Object; + +pub fn routes() -> Router { + Router::new() + .route("/api/contacts", get(list).post(create)) + .route("/api/contacts/{id}", put(update).delete(remove)) +} + +/// insertContactSchema +fn insert_schema() -> Object { + ContactsRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +fn can_write(user: &CurrentUser) -> Result<(), AppError> { + if ["admin", "directeur", "manager"].contains(&user.role()) { + Ok(()) + } else { + Err(AppError::forbidden("Insufficient permissions")) + } +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch contacts"; + // Hors admin : les magasins de l'utilisateur ; admin : ?groupId s'il est donné + let group_ids: Vec = if !user.is_admin() { + user.group_ids() + } else if let Some(group_id) = query.truthy("groupId") { + vec![int_id(group_id, FAILED)?] + } else { + Vec::new() + }; + let contacts: Vec = if group_ids.is_empty() { + sqlx::query_as(&ContactsRow::select("ORDER BY name")) + .fetch_all(&state.db) + .await + } else { + sqlx::query_as(&ContactsRow::select("WHERE group_id = ANY($1) ORDER BY name")) + .bind(&group_ids) + .fetch_all(&state.db) + .await + } + .or_500(FAILED)?; + Ok(Json(contacts).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + can_write(&user)?; + let data = insert_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Validation failed", issues))?; + let contact: ContactsRow = db::insert(&state.db, &data) + .await + .or_500("Failed to create contact")?; + Ok(Json(contact).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update contact"; + can_write(&user)?; + let id = int_id(&id, FAILED)?; + let data = insert_schema() + .partial() + .parse(&body) + .map_err(|_| AppError::internal(FAILED))?; + let contact = db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(contact)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete contact"; + can_write(&user)?; + let id = int_id(&id, FAILED)?; + db::delete_by_id::(&state.db, id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Contact deleted successfully" })).into_response()) +} diff --git a/server-rs/src/modules/customer_orders.rs b/server-rs/src/modules/customer_orders.rs new file mode 100644 index 0000000..00e4103 --- /dev/null +++ b/server-rs/src/modules/customer_orders.rs @@ -0,0 +1,242 @@ +// Commandes clients : /api/customer-orders (server/routes.ts, « Customer Orders +// routes » et « Client call tracking routes » ; storage.ts getCustomerOrders à +// markClientCalled). Comme Node aujourd'hui : +// - un compte sans magasin voit les commandes de tous les magasins ; +// - création et modification sans schéma zod (insertCustomerOrderSchema n'est pas +// appliqué) : les champs connus de la table sont écrits tels quels, les erreurs de +// la base deviennent des 500 ; +// - la modification ne contrôle que le magasin d'origine de la commande : elle peut +// la déplacer vers un autre magasin ; +// - les appels à passer ignorent les commandes dont customer_notified est NULL. + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::patch, routing::put}; +use serde_json::{Value, json}; +use sqlx::{Postgres, QueryBuilder}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, columns_of, drizzle_values, node_pg_value}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::json::spread; +use crate::modules::now_iso; +use crate::projections::GroupSummary; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{CustomerOrdersRow, SuppliersRow}; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/customer-orders", get(list).post(create)) + .route("/api/customer-orders/pending-calls", get(pending_calls)) + .route("/api/customer-orders/{id}", put(update).delete(remove)) + .route("/api/customer-orders/{id}/mark-called", patch(mark_called)) +} + +/// Statuts d'une commande arrivée en magasin, client à prévenir +const ARRIVED: [&str; 6] = ["Disponible", "disponible", "Arrivé", "arrivé", "Prêt", "prêt"]; + +/// Créateur joint par getCustomerOrders (jamais lu : toujours « unknown ») +fn unknown_creator(order: &CustomerOrdersRow) -> Value { + json!({ "id": order.created_by, "username": "unknown" }) +} + +/// Commandes de la requête principale (jointures et tri de Node), complétées comme +/// { ...commande, supplier, group, creator } +async fn with_relations( + state: &AppState, + sql: &mut QueryBuilder<'_, Postgres>, + creator: fn(&CustomerOrdersRow) -> Value, +) -> Result, sqlx::Error> { + let orders: Vec = sql.build_query_as().fetch_all(&state.db).await?; + let supplier_ids: Vec = orders.iter().map(|order| order.supplier_id).collect(); + let suppliers: HashMap = db::find_by_ids::(&state.db, &supplier_ids) + .await? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = orders.iter().map(|order| order.group_id).collect(); + let groups = GroupSummary::by_ids(&state.db, &group_ids).await?; + Ok(orders + .iter() + .map(|order| { + let mut entry = spread(order); + entry.insert("supplier".into(), json!(suppliers.get(&order.supplier_id))); + entry.insert("group".into(), json!(groups.get(&order.group_id))); + entry.insert("creator".into(), creator(order)); + Value::Object(entry) + }) + .collect()) +} + +fn select_with_joins() -> QueryBuilder<'static, Postgres> { + QueryBuilder::new(format!( + "SELECT {} FROM customer_orders + LEFT JOIN suppliers ON customer_orders.supplier_id = suppliers.id + LEFT JOIN groups ON customer_orders.group_id = groups.id", + columns_of::("customer_orders") + )) +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + // Toute erreur : 500 avec une liste vide + let failed = || AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!([])); + let store = query.truthy("storeId"); + // Admin : ?storeId ou tous ; hors admin : ?storeId s'il est l'un des siens, + // sinon tous les siens (aucun pour un compte sans magasin : toutes les commandes) + let group_ids: Vec = if user.is_admin() { + match store { + Some(store) => vec![int_id(store, "").map_err(|_| failed())?], + None => Vec::new(), + } + } else { + let mine = user.group_ids(); + match store.and_then(js::parse_int) { + Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => vec![requested as i32], + _ => mine, + } + }; + let mut sql = select_with_joins(); + if !group_ids.is_empty() { + sql.push(" WHERE customer_orders.group_id = ANY(") + .push_bind(group_ids) + .push(")"); + } + sql.push(" ORDER BY customer_orders.created_at DESC"); + let orders = with_relations(&state, &mut sql, unknown_creator) + .await + .map_err(|_| failed())?; + Ok(Json(orders).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create customer order"; + let mut data = body.as_object().cloned().unwrap_or_default(); + data.insert("createdBy".into(), json!(user.id())); + if !user.is_admin() { + // Magasin en texte converti par parseInt ; tout autre type ne correspond à aucun magasin + let requested = match data.get("groupId") { + Some(Value::String(text)) => js::parse_int(text), + Some(Value::Number(number)) => number.as_f64(), + _ => None, + }; + if !["manager", "directeur", "employee"].contains(&user.role()) { + return Err(AppError::forbidden( + "Insufficient permissions to create customer orders", + )); + } + if !requested.is_some_and(|requested| user.group_ids().iter().any(|id| f64::from(*id) == requested)) { + return Err(AppError::forbidden("Access denied to this group")); + } + } + let values = drizzle_values::(&data).map_err(|_| AppError::internal(FAILED))?; + let order: CustomerOrdersRow = db::insert(&state.db, &values).await.or_500(FAILED)?; + Ok(Json(order).into_response()) +} + +/// Commande existante et du magasin de l'utilisateur (hors admin) +async fn find_for( + state: &AppState, + user: &CurrentUser, + raw_id: &str, + failed: &str, + denied: &str, +) -> Result { + let id = int_id(raw_id, failed)?; + let order = db::find_by_id::(&state.db, id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Customer order not found"))?; + if !user.is_admin() && !user.group_ids().contains(&order.group_id) { + return Err(AppError::forbidden(denied)); + } + Ok(order) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update customer order"; + let order = find_for(&state, &user, &id, FAILED, "Access denied").await?; + let mut data = body.as_object().cloned().unwrap_or_default(); + // updatedAt du corps remplacé par new Date() + data.remove("updatedAt"); + let values = drizzle_values::(&data).map_err(|_| AppError::internal(FAILED))?; + let updated = db::update_by_id::(&state.db, order.id, &values, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(updated)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete customer order"; + let order = find_for(&state, &user, &id, FAILED, "Access denied").await?; + db::delete_by_id::(&state.db, order.id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Customer order deleted successfully" })).into_response()) +} + +async fn pending_calls(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch pending client calls"; + if user.role() == "employee" { + return Err(AppError::forbidden("Insufficient permissions")); + } + // Admin : ?storeId s'il est donné ; directeur et manager : leurs magasins + let group_ids: Vec = if user.is_admin() { + match query.truthy("storeId") { + Some(store) => vec![int_id(store, FAILED)?], + None => Vec::new(), + } + } else { + user.group_ids() + }; + let mut sql = select_with_joins(); + sql.push(" WHERE customer_orders.customer_notified = false AND customer_orders.status = ANY(") + .push_bind(ARRIVED.map(String::from).to_vec()) + .push(")"); + if !group_ids.is_empty() { + sql.push(" AND customer_orders.group_id = ANY(") + .push_bind(group_ids) + .push(")"); + } + sql.push(" ORDER BY customer_orders.created_at DESC"); + let orders = with_relations(&state, &mut sql, |_| Value::Null) + .await + .or_500(FAILED)?; + Ok(Json(orders).into_response()) +} + +async fn mark_called( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to mark client as called"; + if user.role() == "employee" { + return Err(AppError::forbidden("Insufficient permissions")); + } + let order = find_for(&state, &user, &id, FAILED, "Access denied to this customer order").await?; + // comment || null + let comment = body + .get("comment") + .filter(|comment| js::truthy(comment)) + .map_or(Value::Null, node_pg_value); + let mut values = Parsed::new(); + values.insert("customerNotified".into(), Value::Bool(true)); + values.insert("notifiedAt".into(), json!(now_iso())); + values.insert("notifiedComment".into(), comment); + let updated = db::update_by_id::(&state.db, order.id, &values, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(updated)) +} diff --git a/server-rs/src/modules/deliveries.rs b/server-rs/src/modules/deliveries.rs new file mode 100644 index 0000000..e51422e --- /dev/null +++ b/server-rs/src/modules/deliveries.rs @@ -0,0 +1,918 @@ +// Livraisons : /api/deliveries (server/routes.ts, « Deliveries routes », +// validation et contrôle ; storage.ts getDeliveries à markDeliveryControlValidated). +// +// Formes de Node reproduites : +// - liste : champs dans l'ordre du select de Drizzle, fournisseur complet, magasin +// réduit (groupReconciliationColumns), créateur réduit, commande associée (sans +// ses livraisons, absente si elle est d'un autre magasin) et nombre de +// commentaires de rapprochement ; +// - GET /:id : sans les champs du contrôle, magasin complet (mot de passe SMTP +// remplacé par smtpPasswordSet), commande complète (getOrder). +// +// Effets de bord de Node reproduits, dans le même ordre (mêmes requêtes) : +// - lier une commande « pending » la fait passer à « planned » (création et +// modification) ; valider une livraison passe sa commande à « delivered » ; +// - fournisseur en rapprochement automatique : livraison livrée avec un BL +// rapprochée d'office (seconde mise à jour, la réponse reste la première ligne) ; +// - changer la référence de facture relit l'échéance dans NocoDB (sans cache) ; +// - valider marque « rapprochées » les entrées du cache de l'ancienne référence et +// de l'ancien numéro de BL. +// +// Défauts de Node reproduits : +// - un directeur ou un manager sans ?storeId ne voit que son premier magasin ; +// - la validation ne contrôle le magasin que pour un manager (un directeur valide +// les livraisons de tout magasin) ; le contrôle (PUT /:id/control) ne contrôle +// jamais le magasin ; +// - PUT accepte groupId, createdBy, reconciled… (schéma d'insertion partiel) : une +// livraison peut changer de magasin ; un corps invalide donne un 500 ; +// - POST : erreurs de validation rendues en 500 (pas de 400 zod). + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use serde_json::{Map, Value, json}; +use sqlx::{PgPool, Postgres, QueryBuilder}; + +use crate::AppState; +use crate::access::has_permission; +use crate::auth::CurrentUser; +use crate::db::{self, columns_of}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::json::{spread, strip_smtp_password}; +use crate::modules::orders::{self, OrderItem, date_range, listed_groups, spread_body}; +use crate::projections::{GroupReconciliation, UserSummary}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{DeliveriesRow, GroupsRow, OrdersRow, SuppliersRow}; +use crate::services::dates; +use crate::services::invoice_verification as verification; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/deliveries", get(list).post(create)) + .route("/api/deliveries/{id}", get(show).put(update).delete(remove)) + .route("/api/deliveries/{id}/validate", post(validate)) + .route("/api/deliveries/{id}/control", put(control)) +} + +/// Champs d'une livraison dans l'ordre du select de getDeliveries +const DELIVERY_FIELDS: &[&str] = &[ + "id", + "orderId", + "supplierId", + "groupId", + "scheduledDate", + "deliveredDate", + "status", + "quantity", + "unit", + "blNumber", + "blAmount", + "invoiceReference", + "invoiceAmount", + "invoiceAmountTTC", + "dueDate", + "reconciled", + "validatedAt", + "controlValidated", + "controlValidatedBy", + "controlValidatedAt", + "notes", + "createdBy", + "createdAt", + "updatedAt", +]; + +/// Statuts acceptés par le filtre ?status de la liste +const LISTED_STATUSES: [&str; 3] = ["pending", "planned", "delivered"]; + +/// Les champs `keys` d'un objet, dans cet ordre (projection d'un select de Drizzle) +pub fn fields_in_order(fields: &Map, keys: &[&str]) -> Map { + keys.iter() + .filter_map(|key| fields.get(*key).map(|value| (key.to_string(), value.clone()))) + .collect() +} + +/// groupReconciliationColumns des magasins `ids`, par identifiant (seconde +/// moitié d'un leftJoin(groups)) +pub async fn group_reconciliations( + db: &PgPool, + ids: &[i32], +) -> Result, sqlx::Error> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {} FROM groups g WHERE g.id = ANY($1)", + GroupReconciliation::columns("g") + )) + .bind(ids) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|group| (group.id, group)).collect()) +} + +/// Jointures des livraisons (fournisseur, magasin, créateur) +const DELIVERY_JOINS: &str = "FROM deliveries + LEFT JOIN suppliers ON deliveries.supplier_id = suppliers.id + LEFT JOIN groups ON deliveries.group_id = groups.id + LEFT JOIN users ON deliveries.created_by = users.id"; + +/// Début de la requête jointe de Drizzle : les colonnes de la livraison, et celles +/// des tables jointes qui gardent les jointures (même plan que Node, donc même +/// ordre des lignes quand il n'y a pas de tri) +fn delivery_select() -> String { + format!( + "SELECT {}, suppliers.id AS joined_supplier_id, groups.id AS joined_group_id, users.id AS joined_user_id {DELIVERY_JOINS}", + columns_of::("deliveries") + ) +} + +/// Livraison d'une liste, avec ses relations (fournisseur, magasin réduit, créateur) +#[derive(Debug, Clone)] +pub struct DeliveryItem { + pub row: DeliveriesRow, + pub supplier: Option, + pub group: Option, + pub creator: Option, +} + +impl DeliveryItem { + pub fn to_json(&self) -> Map { + let mut out = fields_in_order(&spread(&self.row), DELIVERY_FIELDS); + out.insert("supplier".into(), json!(self.supplier)); + out.insert("group".into(), json!(self.group)); + out.insert("creator".into(), json!(self.creator)); + out + } +} + +/// Lignes de la requête jointe, complétées par leurs relations (lues par identifiant) +async fn joined_deliveries( + db: &PgPool, + mut sql: QueryBuilder<'_, Postgres>, +) -> Result, sqlx::Error> { + let rows: Vec = sql.build_query_as().fetch_all(db).await?; + let supplier_ids: Vec = rows.iter().map(|row| row.supplier_id).collect(); + let suppliers: HashMap = db::find_by_ids::(db, &supplier_ids) + .await? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = rows.iter().map(|row| row.group_id).collect(); + let groups = group_reconciliations(db, &group_ids).await?; + let creator_ids: Vec = rows.iter().map(|row| row.created_by.clone()).collect(); + let creators = UserSummary::by_ids(db, &creator_ids).await?; + Ok(rows + .into_iter() + .map(|row| DeliveryItem { + supplier: suppliers.get(&row.supplier_id).cloned(), + group: groups.get(&row.group_id).cloned(), + creator: creators.get(&row.created_by).cloned(), + row, + }) + .collect()) +} + +/// loadDeliveriesByOrderIds : livraisons jointes, par commande (dans l'ordre de la requête) +pub async fn deliveries_by_order_ids( + db: &PgPool, + order_ids: &[i32], +) -> Result>, sqlx::Error> { + let mut by_order: HashMap> = HashMap::new(); + if order_ids.is_empty() { + return Ok(by_order); + } + let mut sql = QueryBuilder::new(delivery_select()); + sql.push(" WHERE deliveries.order_id = ANY(") + .push_bind(order_ids.to_vec()) + .push(")"); + for item in joined_deliveries(db, sql).await? { + if let Some(order_id) = item.row.order_id { + by_order.entry(order_id).or_default().push(item); + } + } + Ok(by_order) +} + +/// Livraisons jointes d'une commande (getOrder) +pub async fn deliveries_of_order(db: &PgPool, order_id: i32) -> Result, sqlx::Error> { + let mut sql = QueryBuilder::new(delivery_select()); + sql.push(" WHERE deliveries.order_id = ").push_bind(order_id); + joined_deliveries(db, sql).await +} + +/// Livraison de getDeliveries : avec sa commande (même magasin) et son nombre de commentaires +#[derive(Debug, Clone)] +pub struct ListedDelivery { + pub item: DeliveryItem, + pub order: Option, + pub comments: i64, +} + +impl ListedDelivery { + pub fn to_json(&self) -> Value { + let mut out = self.item.to_json(); + if let Some(order) = &self.order { + out.insert("order".into(), Value::Object(order.to_json())); + } + out.insert("reconciliationCommentsCount".into(), json!(self.comments)); + Value::Object(out) + } +} + +/// loadReconciliationCommentCounts +async fn comment_counts(db: &PgPool, delivery_ids: &[i32]) -> Result, sqlx::Error> { + if delivery_ids.is_empty() { + return Ok(HashMap::new()); + } + let rows: Vec<(i32, i64)> = sqlx::query_as( + "SELECT delivery_id, count(*) FROM reconciliation_comments WHERE delivery_id = ANY($1) GROUP BY delivery_id", + ) + .bind(delivery_ids) + .fetch_all(db) + .await?; + Ok(rows.into_iter().collect()) +} + +/// attachOrdersAndCommentCounts : une commande d'un autre magasin n'est jamais jointe +async fn attach_orders_and_counts( + db: &PgPool, + items: Vec, +) -> Result, sqlx::Error> { + let mut order_ids: Vec = items.iter().filter_map(|item| item.row.order_id).collect(); + order_ids.sort_unstable(); + order_ids.dedup(); + let delivery_ids: Vec = items.iter().map(|item| item.row.id).collect(); + let orders = orders::orders_by_ids(db, &order_ids).await?; + let counts = comment_counts(db, &delivery_ids).await?; + Ok(items + .into_iter() + .map(|item| { + let order = item + .row + .order_id + .filter(|id| *id != 0) + .and_then(|id| orders.get(&id)) + .filter(|order| { + let same_store = order.row.group_id == item.row.group_id; + if !same_store { + tracing::error!( + delivery = item.row.id, + order = order.row.id, + "livraison liée à une commande d'un autre magasin" + ); + } + same_store + }) + .cloned(); + let comments = counts.get(&item.row.id).copied().unwrap_or(0); + ListedDelivery { + item, + order, + comments, + } + }) + .collect()) +} + +/// storage.getDeliveries : livraisons (de ces magasins si la liste n'est pas vide, +/// de ce statut s'il est donné), les plus récentes d'abord +pub async fn get_deliveries( + db: &PgPool, + group_ids: &[i32], + status: Option<&str>, +) -> Result, sqlx::Error> { + let mut sql = QueryBuilder::new(delivery_select()); + let mut conditions = 0; + if !group_ids.is_empty() { + sql.push(" WHERE deliveries.group_id = ANY(") + .push_bind(group_ids.to_vec()) + .push(")"); + conditions += 1; + } + if let Some(status) = status { + sql.push(if conditions == 0 { " WHERE " } else { " AND " }) + .push("deliveries.status = ") + .push_bind(status.to_string()); + } + sql.push(" ORDER BY deliveries.created_at DESC"); + let items = joined_deliveries(db, sql).await?; + attach_orders_and_counts(db, items).await +} + +/// storage.getDeliveriesByDateRange : date prévue dans la période, la plus tardive d'abord +async fn get_deliveries_by_date_range( + db: &PgPool, + start: &str, + end: &str, + group_ids: &[i32], +) -> Result, sqlx::Error> { + let mut sql = QueryBuilder::new(delivery_select()); + sql.push(" WHERE deliveries.scheduled_date >= ") + .push_bind(start.to_string()) + .push("::date AND deliveries.scheduled_date <= ") + .push_bind(end.to_string()) + .push("::date"); + if !group_ids.is_empty() { + sql.push(" AND deliveries.group_id = ANY(") + .push_bind(group_ids.to_vec()) + .push(")"); + } + sql.push(" ORDER BY deliveries.scheduled_date DESC"); + let items = joined_deliveries(db, sql).await?; + attach_orders_and_counts(db, items).await +} + +/// Champs de getDelivery : ceux de la liste sans le contrôle +const DETAIL_FIELDS: &[&str] = &[ + "id", + "orderId", + "supplierId", + "groupId", + "scheduledDate", + "deliveredDate", + "status", + "quantity", + "unit", + "blNumber", + "blAmount", + "invoiceReference", + "invoiceAmount", + "invoiceAmountTTC", + "dueDate", + "reconciled", + "validatedAt", + "notes", + "createdBy", + "createdAt", + "updatedAt", +]; + +/// Livraison de getDelivery : magasin complet, créateur, commande complète +/// (même magasin) et nombre de commentaires +#[derive(Debug, Clone)] +pub struct DeliveryDetail { + pub row: DeliveriesRow, + pub supplier: Option, + pub group: Option, + creator: Option, + order: Option, + comments: i64, +} + +impl DeliveryDetail { + /// Réponse de Node (mot de passe SMTP masqué) + pub fn to_json(&self) -> Value { + let mut out = fields_in_order(&spread(&self.row), DETAIL_FIELDS); + out.insert("supplier".into(), json!(self.supplier)); + out.insert("group".into(), json!(self.group)); + out.insert("creator".into(), json!(self.creator)); + if let Some(order) = &self.order { + out.insert("order".into(), order.to_json()); + } + out.insert("reconciliationCommentsCount".into(), json!(self.comments)); + strip_smtp_password(Value::Object(out)) + } +} + +/// storage.getDelivery. Créateur, commande et commentaires sont lus chacun avec +/// son repli de Node (null, absente, 0) en cas d'erreur +pub async fn get_delivery(db: &PgPool, id: i32) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, DeliveriesRow>(&format!( + "SELECT {}, suppliers.id AS joined_supplier_id, groups.id AS joined_group_id + FROM deliveries + LEFT JOIN suppliers ON deliveries.supplier_id = suppliers.id + LEFT JOIN groups ON deliveries.group_id = groups.id + WHERE deliveries.id = $1", + columns_of::("deliveries") + )) + .bind(id) + .fetch_optional(db) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let supplier = db::find_by_id::(db, row.supplier_id).await?; + let group = db::find_by_id::(db, row.group_id).await?; + let creator = UserSummary::by_ids(db, std::slice::from_ref(&row.created_by)) + .await + .ok() + .and_then(|mut found| found.remove(&row.created_by)); + let order = match row.order_id.filter(|order_id| *order_id != 0) { + None => None, + Some(order_id) => orders::get_order(db, order_id) + .await + .ok() + .flatten() + .filter(|order| { + let same_store = order.row.group_id == row.group_id; + if !same_store { + tracing::error!(delivery = id, order = order_id, "commande d'un autre magasin"); + } + same_store + }), + }; + let comments = comment_counts(db, &[id]) + .await + .ok() + .and_then(|counts| counts.get(&id).copied()) + .unwrap_or(0); + Ok(Some(DeliveryDetail { + row, + supplier, + group, + creator, + order, + comments, + })) +} + +/// updateDelivery : mise à jour (avec updatedAt) ; une commande liée encore +/// « pending » passe à « planned » +async fn update_delivery(db: &PgPool, id: i32, data: &Parsed) -> Result, sqlx::Error> { + let updated = db::update_by_id::(db, id, data, true).await?; + if let Some(order_id) = data.get("orderId").filter(|value| js::truthy(value)) { + plan_linked_order(db, order_id).await; + } + Ok(updated) +} + +/// Commande liée à une livraison créée ou modifiée : « pending » devient +/// « planned ». Les erreurs sont journalisées, comme Node +async fn plan_linked_order(db: &PgPool, order_id: &Value) { + let Some(order_id) = order_id.as_i64().and_then(|id| i32::try_from(id).ok()) else { + return; + }; + match db::find_by_id::(db, order_id).await { + Ok(Some(order)) if order.status == "pending" => { + if let Err(error) = orders::set_order_status(db, order_id, "planned").await { + tracing::error!(%error, order_id, "commande liée : passage à planned"); + } + } + Ok(_) => {} + Err(error) => tracing::error!(%error, order_id, "commande liée : lecture"), + } +} + +/// { reconciled: true, validatedAt: new Date() } : rapprochement automatique +async fn auto_reconcile(db: &PgPool, id: i32) { + let mut data = Map::new(); + data.insert("reconciled".into(), Value::Bool(true)); + data.insert("validatedAt".into(), Value::String(crate::modules::now_iso())); + if let Err(error) = update_delivery(db, id, &data).await { + tracing::error!(%error, delivery = id, "rapprochement automatique"); + } +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch deliveries"; + let status = query + .get("status") + .filter(|status| LISTED_STATUSES.contains(status)); + let Some(group_ids) = listed_groups(&user, &query).map_err(|_| AppError::internal(FAILED))? else { + return Ok(Json(json!([])).into_response()); + }; + let range = date_range(&query); + let mut deliveries = match range { + Some((start, end)) => get_deliveries_by_date_range(&state.db, start, end, &group_ids).await, + None => get_deliveries(&state.db, &group_ids, status).await, + } + .or_500(FAILED)?; + // La lecture par période ne filtre pas le statut en base + if let (Some(status), Some(_)) = (status, range) { + deliveries.retain(|delivery| delivery.item.row.status == status); + } + if query.get("withBL") == Some("true") { + deliveries.retain(|delivery| { + delivery + .item + .row + .bl_number + .as_deref() + .is_some_and(|bl| !bl.is_empty()) + && delivery.item.row.status == "delivered" + }); + } + let out: Vec = deliveries.iter().map(ListedDelivery::to_json).collect(); + Ok(Json(out).into_response()) +} + +/// Hors admin : la livraison doit être d'un magasin de l'utilisateur +fn check_group(user: &CurrentUser, group_id: i32) -> Result<(), AppError> { + if user.is_admin() || user.group_ids().contains(&group_id) { + Ok(()) + } else { + Err(AppError::forbidden("Access denied")) + } +} + +/// Livraison existante, ou 404 ; erreur de lecture (identifiant illisible) : 500 +async fn existing_delivery(db: &PgPool, id: i32, failed: &str) -> Result { + get_delivery(db, id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Delivery not found")) +} + +async fn show(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to fetch delivery"; + let delivery = existing_delivery(&state.db, int_id(&id, FAILED)?, FAILED).await?; + check_group(&user, delivery.row.group_id)?; + Ok(Json(delivery.to_json()).into_response()) +} + +/// insertDeliverySchema : createInsertSchema(deliveries) sans id, createdAt, updatedAt +fn delivery_schema() -> crate::zod::Object { + DeliveriesRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +/// Montant converti par le PUT : null ou '' → null, sinon valeur.toString() +fn amount_text(value: &Value) -> Value { + match value { + Value::Null => Value::Null, + Value::String(text) if text.is_empty() => Value::Null, + other => Value::String(js::string_of(other)), + } +} + +/// Horodatage converti par le PUT : null ou '' → null, sinon new Date(valeur). +/// Err : date invalide (refusée par z.date(), donc 500) +fn timestamp_value(value: &Value) -> Result { + match value { + Value::Null => Ok(Value::Null), + Value::String(text) if text.is_empty() => Ok(Value::Null), + other => dates::js_date(other) + .map(|date| Value::String(js::iso(&date))) + .ok_or(()), + } +} + +/// Transformations du PUT avant la validation, puis insertDeliverySchema.partial(). +/// Les dates converties en Date contournent z.date() (que du JSON ne satisfait jamais) +fn parse_update(body: &Value) -> Result { + if !body.is_object() { + // { ...tableau } reste un objet : seuls ses indices s'y ajoutent + return delivery_schema() + .partial() + .parse(&Value::Object(spread_body(body))) + .map_err(|_| ()); + } + let mut input = spread_body(body); + for key in ["blAmount", "invoiceAmount"] { + if let Some(value) = input.get_mut(key) { + *value = amount_text(value); + } + } + for key in ["invoiceReference", "blNumber"] { + if let Some(value) = input.get_mut(key).filter(|value| value.as_str() == Some("")) { + *value = Value::Null; + } + } + let mut dates = Vec::new(); + for key in ["validatedAt", "dueDate"] { + if let Some(value) = input.remove(key) { + dates.push((key, timestamp_value(&value)?)); + } + } + let mut data = delivery_schema() + .partial() + .parse(&Value::Object(input)) + .map_err(|_| ())?; + for (key, value) in dates { + data.insert(key.to_string(), value); + } + Ok(data) +} + +/// Commande liée : elle doit exister et être du même magasin que la livraison +async fn check_linked_order(db: &PgPool, order_id: i32, group_id: i32, failed: &str) -> Result<(), AppError> { + let order = db::find_by_id::(db, order_id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::bad_request("La commande liée n'existe pas"))?; + if order.group_id != group_id { + return Err(AppError::bad_request(&format!( + "Impossible de lier une livraison du magasin {group_id} avec une commande du magasin {}", + order.group_id + ))); + } + Ok(()) +} + +/// Échéance d'une nouvelle référence de facture, relue dans NocoDB (sans cache) : +/// date normalisée, ou null. Err : échéance normalisée que new Date() ne lit pas +/// (toISOString échoue côté Node, d'où un 500). Sert aussi à l'API externe +/// (lookupDueDate) +pub async fn due_date_from_nocodb( + state: &AppState, + reference: &str, + group_id: i32, + reconciled: bool, +) -> Result { + let result = verification::verify_invoice(state, reference, group_id, true, reconciled).await; + if !result.exists { + return Ok(Value::Null); + } + match result.normalized_due_date() { + None => Ok(Value::Null), + Some(normalized) => dates::parse_js_date(&normalized) + .map(|date| Value::String(js::iso(&date))) + .ok_or(()), + } +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update delivery"; + let id = int_id(&id, FAILED)?; + let delivery = existing_delivery(&state.db, id, FAILED).await?; + if !has_permission(user.role(), "deliveries", "edit") { + return Err(AppError::forbidden("Insufficient permissions to edit deliveries")); + } + check_group(&user, delivery.row.group_id)?; + let mut data = parse_update(&body).map_err(|_| AppError::internal(FAILED))?; + + if let Some(order_id) = data.get("orderId").and_then(Value::as_i64) { + let order_id = i32::try_from(order_id).map_err(|_| AppError::internal(FAILED))?; + check_linked_order(&state.db, order_id, delivery.row.group_id, FAILED).await?; + } + + // Référence de facture changée : échéance relue dans NocoDB, ou vidée + if let Some(reference) = data.get("invoiceReference").cloned() { + let current = delivery + .row + .invoice_reference + .clone() + .map_or(Value::Null, Value::String); + if reference != current { + let due_date = match reference.as_str().filter(|text| !js::trim(text).is_empty()) { + Some(text) => due_date_from_nocodb( + &state, + text, + delivery.row.group_id, + delivery.row.reconciled == Some(true), + ) + .await + .map_err(|_| AppError::internal(FAILED))?, + None => Value::Null, + }; + data.insert("dueDate".into(), due_date); + } + } + + let updated = update_delivery(&state.db, id, &data).await.or_500(FAILED)?; + + // Rapprochement automatique : livraison livrée avec un BL, fournisseur en mode automatique + if data.get("status").and_then(Value::as_str) == Some("delivered") + || data.get("blNumber").is_some_and(js::truthy) + { + if let Some(updated) = &updated { + let automatic = if updated.supplier_id == delivery.row.supplier_id && delivery.supplier.is_some() + { + delivery + .supplier + .as_ref() + .and_then(|supplier| supplier.automatic_reconciliation) + } else { + db::find_by_id::(&state.db, updated.supplier_id) + .await + .ok() + .flatten() + .and_then(|supplier| supplier.automatic_reconciliation) + }; + let has_bl = updated.bl_number.as_deref().is_some_and(|bl| !bl.is_empty()); + if automatic == Some(true) && updated.status == "delivered" && has_bl { + auto_reconcile(&state.db, id).await; + } + } + } + Ok(json_or_empty(updated)) +} + +/// parseInt(orderId) de la création : chiffres de tête du texte de la valeur, NaN sinon +fn parse_order_id(value: &Value) -> Value { + match js::parse_int(&js::string_of(value)) { + Some(number) => js::number_value(number), + // NaN : refusé par z.number() (« received nan »), comme une valeur non numérique + None => Value::String("NaN".into()), + } +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create delivery"; + let mut input = spread_body(&body); + if let Some(order_id) = input.get_mut("orderId") { + if matches!(order_id.as_str(), Some("none" | "")) { + *order_id = Value::Null; + } else if js::truthy(order_id) { + *order_id = parse_order_id(order_id); + } + } + input.insert("createdBy".into(), Value::String(user.id().to_string())); + let mut data = delivery_schema() + .parse(&Value::Object(input)) + .map_err(|_| AppError::internal(FAILED))?; + let group_id = data.get("groupId").and_then(Value::as_i64); + if !user.is_admin() && !user.group_ids().iter().any(|id| Some(i64::from(*id)) == group_id) { + return Err(AppError::forbidden("Access denied to this group")); + } + let order_id = data.get("orderId").filter(|value| js::truthy(value)).cloned(); + if let Some(order_id) = order_id.as_ref().and_then(Value::as_i64) { + let order_id = i32::try_from(order_id).map_err(|_| AppError::internal(FAILED))?; + let group_id = group_id + .and_then(|id| i32::try_from(id).ok()) + .ok_or_else(|| AppError::internal(FAILED))?; + check_linked_order(&state.db, order_id, group_id, FAILED).await?; + } + // Statut « planned » par défaut (status || 'planned') + if !data.get("status").is_some_and(js::truthy) { + data.insert("status".into(), Value::String("planned".into())); + } + let delivery = db::insert_values_first::(&state.db, &data) + .await + .or_500(FAILED)?; + if let Some(order_id) = &order_id { + plan_linked_order(&state.db, order_id).await; + } + Ok(Json(delivery).into_response()) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete delivery"; + let delivery = existing_delivery(&state.db, int_id(&id, FAILED)?, FAILED).await?; + if !has_permission(user.role(), "deliveries", "delete") { + return Err(AppError::forbidden( + "Insufficient permissions to delete deliveries", + )); + } + check_group(&user, delivery.row.group_id)?; + db::delete_by_id::(&state.db, delivery.row.id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Delivery deleted successfully" })).into_response()) +} + +/// Texte non vide après trim, comme `valeur && valeur.trim()` +fn filled(text: &Option) -> Option<&str> { + text.as_deref().filter(|text| !js::trim(text).is_empty()) +} + +async fn validate( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to validate delivery"; + if !has_permission(user.role(), "deliveries", "validate") { + return Err(AppError::forbidden( + "Insufficient permissions to validate deliveries", + )); + } + let id = int_id(&id, FAILED)?; + let delivery = existing_delivery(&state.db, id, FAILED).await?; + // Seul le manager est limité à ses magasins + if user.role() == "manager" && !user.group_ids().contains(&delivery.row.group_id) { + return Err(AppError::forbidden("Access denied")); + } + + let field = |name: &str| body.as_object().and_then(|fields| fields.get(name)); + // blNumber.trim() sur une valeur « vraie » qui n'est pas une chaîne : TypeError, 500 + let bl_number = match field("blNumber") { + Some(value) if js::truthy(value) => value.as_str().ok_or_else(|| AppError::internal(FAILED))?, + _ => "", + }; + let bl_number = js::trim(bl_number); + if bl_number.is_empty() { + return Err(AppError::bad_request( + "Le numéro de bon de livraison est obligatoire pour valider une livraison", + )); + } + + // validateDelivery : statut, date de livraison, BL et montant (toString), sans returning + let now = crate::modules::now_iso(); + let mut sql = + QueryBuilder::::new("UPDATE deliveries SET status = 'delivered', delivered_date = "); + sql.push_bind(now.clone()) + .push("::timestamp, updated_at = ") + .push_bind(now) + .push("::timestamp, bl_number = ") + .push_bind(bl_number.to_string()); + if let Some(amount) = field("blAmount").filter(|amount| !amount.is_null() && amount.as_str() != Some("")) + { + sql.push(", bl_amount = ") + .push_bind(js::string_of(amount)) + .push("::numeric"); + } + sql.push(" WHERE id = ").push_bind(id); + sql.build().execute(&state.db).await.or_500(FAILED)?; + + // Fournisseur en rapprochement automatique : rapprochement validé d'office + if delivery + .supplier + .as_ref() + .and_then(|supplier| supplier.automatic_reconciliation) + == Some(true) + { + auto_reconcile(&state.db, id).await; + } + + // Cache des factures : entrées de l'ancienne référence et de l'ancien BL rendues permanentes + if let Some(reference) = filled(&delivery.row.invoice_reference) { + verification::update_cache_as_reconciled(&state.db, reference, delivery.row.group_id).await; + } + if let Some(bl) = filled(&delivery.row.bl_number) { + verification::update_cache_as_reconciled(&state.db, bl, delivery.row.group_id).await; + } + + // La commande associée passe à « delivered » + if let Some(order_id) = delivery.row.order_id.filter(|order_id| *order_id != 0) { + if let Err(error) = orders::set_order_status(&state.db, order_id, "delivered").await { + tracing::error!(%error, order_id, "commande de la livraison validée"); + } + } + Ok(Json(json!({ "message": "Delivery validated successfully" })).into_response()) +} + +async fn control(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to validate delivery control"; + let Some(id) = js::parse_int(&id) else { + return Err(AppError::bad_request("Invalid delivery ID")); + }; + let id = db::js_int4(id).ok_or_else(|| AppError::internal(FAILED))?; + existing_delivery(&state.db, id, FAILED).await?; + if !has_permission(user.role(), "deliveries", "edit") { + return Err(AppError::forbidden("Insufficient permissions")); + } + // Contrôle effectué : sans contrôle du magasin, comme Node + let now = crate::modules::now_iso(); + sqlx::query( + "UPDATE deliveries SET control_validated = true, control_validated_by = $1, control_validated_at = $2::timestamp, updated_at = $3::timestamp WHERE id = $4", + ) + .bind(user.id()) + .bind(&now) + .bind(&now) + .bind(id) + .execute(&state.db) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Delivery control validated successfully" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn champs_dans_l_ordre_du_select() { + let fields = json!({ "b": 2, "a": 1, "c": 3 }); + let ordered = fields_in_order(fields.as_object().unwrap(), &["a", "c", "absent", "b"]); + assert_eq!(serde_json::to_string(&ordered).unwrap(), r#"{"a":1,"c":3,"b":2}"#); + } + + #[test] + fn transformations_du_put() { + let data = parse_update(&json!({ + "blAmount": 12.5, "invoiceAmount": "", "invoiceReference": "", "blNumber": "BL-1", + "dueDate": "2026-10-15", "validatedAt": null, "inconnu": 1 + })) + .unwrap(); + assert_eq!( + Value::Object(data), + json!({ + "blNumber": "BL-1", "blAmount": "12.5", "invoiceReference": null, "invoiceAmount": null, + "validatedAt": null, "dueDate": "2026-10-15T00:00:00.000Z" + }) + ); + // Montant TTC non converti : z.string() refuse un nombre + assert!(parse_update(&json!({ "invoiceAmountTTC": 5 })).is_err()); + // Date que new Date() ne lit pas, horodatage resté en texte + assert!(parse_update(&json!({ "dueDate": "pas une date" })).is_err()); + assert!(parse_update(&json!({ "deliveredDate": "2026-10-15" })).is_err()); + // Tableau étalé : objet sans champ du schéma + assert!(parse_update(&json!([1])).unwrap().is_empty()); + } + + #[test] + fn montants_en_texte_comme_tostring() { + assert_eq!(amount_text(&json!(1250.5)), json!("1250.5")); + assert_eq!(amount_text(&json!(true)), json!("true")); + assert_eq!(amount_text(&json!([1, 2])), json!("1,2")); + assert_eq!(amount_text(&json!({})), json!("[object Object]")); + assert_eq!(amount_text(&json!("")), Value::Null); + } + + #[test] + fn identifiant_de_commande_comme_parseint() { + assert_eq!(parse_order_id(&json!("12abc")), json!(12)); + assert_eq!(parse_order_id(&json!(5.7)), json!(5)); + assert_eq!(parse_order_id(&json!([3])), json!(3)); + assert_eq!(parse_order_id(&json!(true)), json!("NaN")); + } +} diff --git a/server-rs/src/modules/dlc.rs b/server-rs/src/modules/dlc.rs new file mode 100644 index 0000000..55c1905 --- /dev/null +++ b/server-rs/src/modules/dlc.rs @@ -0,0 +1,423 @@ +// Produits DLC : /api/dlc-products (server/routes.ts, « DLC Products routes » ; +// storage.ts getDlcProducts à getDlcStats). Comme Node aujourd'hui : +// - un compte sans magasin voit les produits et les compteurs de tous les magasins ; +// - création et modification sans schéma zod : les champs connus de la table sont +// écrits tels quels, les erreurs de la base deviennent des 500 ; +// - la modification ne contrôle que le magasin d'origine du produit : elle peut le +// déplacer vers un magasin de l'utilisateur ou non, et changer son créateur ; +// - GET /:id joint la ligne complète du magasin (adresse, logo, configuration +// NocoDB et SMTP ; le mot de passe SMTP remplacé par smtpPasswordSet) ; +// - les compteurs sont rendus en texte ("3"), tels que node-pg lit un COUNT. + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::post, routing::put}; +use chrono::{Days, NaiveDate}; +use serde_json::{Value, json}; +use sqlx::{Postgres, QueryBuilder}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, columns_of, drizzle_values}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::json::{spread, strip_smtp_password}; +use crate::modules::now_iso; +use crate::projections::GroupSummary; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{DlcProductsRow, GroupsRow, SuppliersRow}; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/dlc-products", get(list).post(create)) + .route("/api/dlc-products/stats", get(stats)) + .route("/api/dlc-products/{id}", get(show).put(update).delete(remove)) + .route("/api/dlc-products/{id}/validate", post(validate)) + .route("/api/dlc-products/{id}/stock-epuise", put(stock_epuise)) + .route("/api/dlc-products/{id}/restore-stock", put(restore_stock)) + .route("/api/dlc-products/{id}/mark-processed", put(mark_processed)) + .route("/api/dlc-products/{id}/unmark-processed", put(unmark_processed)) +} + +const VALIDATORS: [&str; 3] = ["admin", "directeur", "manager"]; +const GROUP_DENIED: &str = "Access denied to this group's DLC products"; + +/// Jour courant et J+15 (dates UTC, heure du serveur Node) +fn today_and_alert() -> (NaiveDate, NaiveDate) { + let today = crate::clock::today(); + (today, today + Days::new(15)) +} + +/// Magasins lus (vide : tous). Admin : ?storeId s'il est donné ; hors admin : +/// ?storeId s'il est l'un des siens, sinon tous les siens (aucun pour un compte +/// sans magasin, donc tous les produits). Err : ?storeId illisible pour l'admin, +/// [NaN] fait échouer la requête de Node. +fn scope(user: &CurrentUser, query: &JsQuery) -> Result, ()> { + let store = query.truthy("storeId"); + if user.is_admin() { + return match store { + Some(store) => int_id(store, "").map(|id| vec![id]).map_err(|_| ()), + None => Ok(Vec::new()), + }; + } + let mine = user.group_ids(); + match store.and_then(js::parse_int) { + Some(requested) if mine.iter().any(|id| f64::from(*id) == requested) => Ok(vec![requested as i32]), + _ => Ok(mine), + } +} + +/// Ajoute « WHERE » ou « AND » avant une condition +fn and(sql: &mut QueryBuilder<'_, Postgres>, count: &mut usize) { + sql.push(if *count == 0 { " WHERE " } else { " AND " }); + *count += 1; +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + // Toute erreur : 500 avec une liste vide + let failed = || AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!([])); + let group_ids = scope(&user, &query).map_err(|_| failed())?; + let (today, alert) = today_and_alert(); + + let mut sql = QueryBuilder::::new(format!( + "SELECT {} FROM dlc_products + LEFT JOIN suppliers ON dlc_products.supplier_id = suppliers.id + LEFT JOIN groups ON dlc_products.group_id = groups.id", + columns_of::("dlc_products") + )); + let mut count = 0; + if !group_ids.is_empty() { + and(&mut sql, &mut count); + sql.push("dlc_products.group_id = ANY(") + .push_bind(group_ids) + .push(")"); + } + // Statut calculé d'après la date (les produits traités sortent des alertes) + const NOT_PROCESSED: &str = + "(dlc_products.processed_until_expiry IS NULL OR dlc_products.processed_until_expiry = false)"; + match query.truthy("status").filter(|status| *status != "all") { + Some("expires_soon") => { + and(&mut sql, &mut count); + sql.push("(dlc_products.expiry_date > ") + .push_bind(today) + .push(" AND dlc_products.expiry_date <= ") + .push_bind(alert) + .push(" AND dlc_products.status <> 'valides' AND ") + .push(NOT_PROCESSED) + .push(")"); + } + Some("expires") => { + and(&mut sql, &mut count); + sql.push("(dlc_products.expiry_date <= ") + .push_bind(today) + .push(" AND dlc_products.status <> 'valides' AND ") + .push(NOT_PROCESSED) + .push(")"); + } + Some("en_cours") => { + and(&mut sql, &mut count); + sql.push("(dlc_products.expiry_date > ") + .push_bind(alert) + .push(" AND dlc_products.status <> 'valides')"); + } + Some("valides") => { + and(&mut sql, &mut count); + sql.push("dlc_products.status = 'valides'"); + } + _ => {} + } + // parseInt(supplierId) : NaN et 0 ne filtrent pas, hors limites fait échouer la requête + if let Some(supplier) = query + .truthy("supplierId") + .filter(|supplier| *supplier != "all") + .filter(|supplier| js::parse_int(supplier).is_some_and(|n| n != 0.0)) + { + let supplier = int_id(supplier, "").map_err(|_| failed())?; + and(&mut sql, &mut count); + sql.push("dlc_products.supplier_id = ").push_bind(supplier); + } + if let Some(search) = query.truthy("search") { + let pattern = format!("%{search}%"); + and(&mut sql, &mut count); + sql.push("(LOWER(dlc_products.product_name) LIKE LOWER(") + .push_bind(pattern.clone()) + .push(") OR LOWER(suppliers.name) LIKE LOWER(") + .push_bind(pattern) + .push("))"); + } + // Actifs, puis traités, stock épuisé, validés ; à l'intérieur, date croissante + sql.push( + " ORDER BY CASE + WHEN dlc_products.status != 'valides' + AND (dlc_products.processed_until_expiry IS NULL OR dlc_products.processed_until_expiry = false) + AND (dlc_products.stock_epuise IS NULL OR dlc_products.stock_epuise = false) THEN 1 + WHEN dlc_products.processed_until_expiry = true THEN 2 + WHEN dlc_products.stock_epuise = true THEN 3 + WHEN dlc_products.status = 'valides' THEN 4 + ELSE 1 + END, dlc_products.expiry_date ASC", + ); + let products: Vec = sql + .build_query_as() + .fetch_all(&state.db) + .await + .map_err(|_| failed())?; + + let supplier_ids: Vec = products.iter().map(|p| p.supplier_id).collect(); + let suppliers: HashMap = db::find_by_ids::(&state.db, &supplier_ids) + .await + .map_err(|_| failed())? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = products.iter().map(|p| p.group_id).collect(); + let groups = GroupSummary::by_ids(&state.db, &group_ids) + .await + .map_err(|_| failed())?; + + let out: Vec = products + .iter() + .map(|product| { + with_relations( + product, + json!(suppliers.get(&product.supplier_id)), + json!(groups.get(&product.group_id)), + ) + }) + .collect(); + Ok(Json(out).into_response()) +} + +/// { ...produit, dlcDate: expiryDate, supplier, group, creator } de getDlcProducts / getDlcProduct +fn with_relations(product: &DlcProductsRow, supplier: Value, group: Value) -> Value { + let mut entry = spread(product); + let expiry = entry.get("expiryDate").cloned().unwrap_or(Value::Null); + entry.insert("dlcDate".into(), expiry); + entry.insert("supplier".into(), supplier); + entry.insert("group".into(), group); + entry.insert( + "creator".into(), + json!({ "id": product.created_by, "username": "unknown" }), + ); + Value::Object(entry) +} + +async fn stats(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let failed = || { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "active": 0, "expiringSoon": 0, "expired": 0 }), + ) + }; + let group_ids = scope(&user, &query).map_err(|_| failed())?; + let (today, alert) = today_and_alert(); + const ACTIVE_ROW: &str = "status != 'valides' + AND (processed_until_expiry IS NULL OR processed_until_expiry = false) + AND (stock_epuise IS NULL OR stock_epuise = false)"; + let mut sql = QueryBuilder::::new("SELECT COUNT(CASE WHEN expiry_date > "); + sql.push_bind(today) + .push(format!( + " AND {ACTIVE_ROW} THEN 1 END), COUNT(CASE WHEN expiry_date BETWEEN " + )) + .push_bind(today) + .push(" AND ") + .push_bind(alert) + .push(format!( + " AND {ACTIVE_ROW} THEN 1 END), COUNT(CASE WHEN expiry_date <= " + )) + .push_bind(today) + .push(format!(" AND {ACTIVE_ROW} THEN 1 END) FROM dlc_products WHERE ")); + if group_ids.is_empty() { + sql.push("1 = 1"); + } else { + sql.push("group_id = ANY(").push_bind(group_ids).push(")"); + } + let (active, expiring_soon, expired): (i64, i64, i64) = sql + .build_query_as() + .fetch_one(&state.db) + .await + .map_err(|_| failed())?; + // COUNT lu en texte par node-pg (bigint) + Ok(Json(json!({ + "active": active.to_string(), + "expiringSoon": expiring_soon.to_string(), + "expired": expired.to_string(), + })) + .into_response()) +} + +/// Produit existant et du magasin de l'utilisateur (hors admin) +async fn find_for( + state: &AppState, + user: &CurrentUser, + raw_id: &str, + failed: &str, + denied: &str, +) -> Result { + let id = int_id(raw_id, failed)?; + let product = db::find_by_id::(&state.db, id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("DLC Product not found"))?; + if !user.is_admin() && !user.group_ids().contains(&product.group_id) { + return Err(AppError::forbidden(denied)); + } + Ok(product) +} + +async fn show(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to fetch DLC product"; + let product = find_for(&state, &user, &id, FAILED, "Access denied").await?; + let supplier = db::find_by_id::(&state.db, product.supplier_id) + .await + .or_500(FAILED)?; + let group = db::find_by_id::(&state.db, product.group_id) + .await + .or_500(FAILED)?; + let body = with_relations(&product, json!(supplier), strip_smtp_password(json!(group))); + Ok(Json(body).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create DLC product"; + let mut data = body.as_object().cloned().unwrap_or_default(); + data.insert("createdBy".into(), json!(user.id())); + // userGroupIds.includes(data.groupId) : un nombre, jamais un texte + let allowed = data + .get("groupId") + .and_then(Value::as_f64) + .is_some_and(|requested| user.group_ids().iter().any(|id| f64::from(*id) == requested)); + if !user.is_admin() && !allowed { + return Err(AppError::forbidden("Access denied to this group")); + } + let values = drizzle_values::(&data).map_err(|_| AppError::internal(FAILED))?; + let product: DlcProductsRow = db::insert(&state.db, &values).await.or_500(FAILED)?; + // { ...produit, dlcDate: new Date(expiryDate) } : minuit UTC + let mut entry = spread(&product); + entry.insert( + "dlcDate".into(), + json!(format!( + "{}T00:00:00.000Z", + product.expiry_date.format("%Y-%m-%d") + )), + ); + Ok(Json(entry).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update DLC product"; + let product = find_for(&state, &user, &id, FAILED, "Access denied").await?; + let mut data = body.as_object().cloned().unwrap_or_default(); + // updatedAt du corps remplacé par new Date() + data.remove("updatedAt"); + let values = drizzle_values::(&data).map_err(|_| AppError::internal(FAILED))?; + let updated = db::update_by_id::(&state.db, product.id, &values, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(updated)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete DLC product"; + let product = find_for(&state, &user, &id, FAILED, "Access denied").await?; + db::delete_by_id::(&state.db, product.id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "DLC Product deleted successfully" })).into_response()) +} + +/// Réservé aux rôles qui valident (admin, directeur, manager) +fn require_validator(user: &CurrentUser, message: &str) -> Result<(), AppError> { + if VALIDATORS.contains(&user.role()) { + Ok(()) + } else { + Err(AppError::forbidden(message)) + } +} + +/// Mise à jour d'un produit, avec updatedAt ; ligne absente : réponse vide +async fn set_fields(state: &AppState, id: i32, fields: Value, failed: &str) -> ApiResult { + let values: Parsed = fields.as_object().cloned().unwrap_or_default(); + let updated = db::update_by_id::(&state.db, id, &values, true) + .await + .or_500(failed)?; + Ok(json_or_empty(updated)) +} + +async fn validate(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to validate DLC product"; + require_validator(&user, "Insufficient permissions to validate DLC products")?; + let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?; + let fields = json!({ "validatedBy": user.id(), "validatedAt": now_iso(), "status": "valides" }); + set_fields(&state, product.id, fields, FAILED).await +} + +async fn stock_epuise(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to mark DLC product as stock épuisé"; + let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?; + let fields = json!({ "stockEpuise": true, "stockEpuiseBy": user.id(), "stockEpuiseAt": now_iso() }); + set_fields(&state, product.id, fields, FAILED).await +} + +async fn restore_stock( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + const FAILED: &str = "Failed to restore DLC product stock"; + require_validator(&user, "Insufficient permissions to restore DLC product stock")?; + let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?; + let fields = json!({ "stockEpuise": false, "stockEpuiseBy": null, "stockEpuiseAt": null }); + set_fields(&state, product.id, fields, FAILED).await +} + +/// markDlcProductAsProcessed / unmark : si la mise à jour échoue, Node se replie sur +/// la seule date de modification +async fn set_processed(state: &AppState, id: i32, fields: Value, failed: &str) -> ApiResult { + let values: Parsed = fields.as_object().cloned().unwrap_or_default(); + let updated = match db::update_by_id::(&state.db, id, &values, true).await { + Ok(updated) => updated, + Err(error) => { + tracing::warn!(%error, "repli sur la seule date de modification"); + db::update_by_id::(&state.db, id, &Parsed::new(), true) + .await + .or_500(failed)? + } + }; + Ok(json_or_empty(updated)) +} + +async fn mark_processed( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + const FAILED: &str = "Failed to mark DLC product as processed"; + let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?; + let fields = json!({ "processedAt": now_iso(), "processedBy": user.id(), "processedUntilExpiry": true }); + set_processed(&state, product.id, fields, FAILED).await +} + +async fn unmark_processed( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + const FAILED: &str = "Failed to unmark DLC product as processed"; + require_validator( + &user, + "Insufficient permissions to unmark DLC product as processed", + )?; + let product = find_for(&state, &user, &id, FAILED, GROUP_DENIED).await?; + let fields = json!({ "processedAt": null, "processedBy": null, "processedUntilExpiry": false }); + set_processed(&state, product.id, fields, FAILED).await +} diff --git a/server-rs/src/modules/external_api.rs b/server-rs/src/modules/external_api.rs new file mode 100644 index 0000000..283291e --- /dev/null +++ b/server-rs/src/modules/external_api.rs @@ -0,0 +1,1343 @@ +// API externe de rapprochement (server/externalApi.ts, server/externalApiKeys.ts) : +// - /api/ext/v1/* (clé d'API, hors session) : magasins, fournisseurs, livraisons +// livrées (filtres et pagination en mémoire, comme Node), écriture de la facture +// d'une livraison ; toute autre adresse sous le préfixe : 404 JSON ; +// - gestion des clés depuis Paramètres > API externe (/api/external-api/keys, +// session admin) et outils de clés d'API partagés. +// +// Une clé « lf_… » n'est montrée qu'une fois, à sa création : seuls son empreinte +// SHA-256 et un préfixe lisible sont stockés (table external_api_keys). Les clés +// de la variable d'environnement EXTERNAL_API_KEYS restent acceptées. +// +// Comme Node, qui les enregistre avant son middleware /api, les réponses sous +// /api/ext/v1 n'ont pas d'en-tête Cache-Control (voir http.rs). Les variantes +// d'adresse qu'Express accepte aussi (casse, barre finale) passent par express_routes.rs. + +use axum::extract::{FromRequest, Path, RawQuery, Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{any, delete, get}; +use axum::{Json, Router}; +use base64::Engine; +use chrono::NaiveDateTime; +use rand::RngCore; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::{FromRow, PgPool}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::modules::deliveries; +use crate::request::JsonBody; +use crate::schema::{DeliveriesRow, SuppliersRow}; +use crate::services::dates; +use crate::services::nocodb::array_index; + +/// Préfixe des adresses de l'API externe +pub const EXTERNAL_API_PREFIX: &str = "/api/ext/v1"; +const KEY_PREFIX: &str = "lf_"; +/// Partie lisible gardée en base : « lf_ » + 7 caractères +const DISPLAY_PREFIX_LENGTH: usize = 10; +/// last_used_at n'est réécrit qu'une fois par minute au plus, par clé +const LAST_USED_THROTTLE_SECONDS: i64 = 60; + +/// Taille de page de GET /deliveries : par défaut, et au plus +const DEFAULT_LIMIT: f64 = 100.0; +const MAX_LIMIT: f64 = 500.0; + +pub fn routes() -> Router { + Router::new() + .route("/api/external-api/keys", get(list_keys).post(create_key)) + .route("/api/external-api/keys/{id}", delete(revoke_key)) + // Toute autre méthode sur ces adresses : la route inconnue de Node + .route("/api/ext/v1/stores", get(ext_stores).fallback(ext_unknown)) + .route("/api/ext/v1/suppliers", get(ext_suppliers).fallback(ext_unknown)) + .route( + "/api/ext/v1/deliveries", + get(ext_deliveries).fallback(ext_unknown), + ) + .route( + "/api/ext/v1/deliveries/{id}", + get(ext_delivery).patch(ext_update_delivery).fallback(ext_unknown), + ) + .route(EXTERNAL_API_PREFIX, any(ext_unknown)) + .route("/api/ext/v1/", any(ext_unknown)) + .route("/api/ext/v1/{*rest}", any(ext_unknown)) +} + +// ---------- Clés d'API (server/externalApiKeys.ts) ---------- + +/// Clé telle que l'interface la voit (PublicApiKey) : tout sauf l'empreinte +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PublicApiKey { + pub id: i32, + pub name: String, + #[serde(rename = "keyPrefix")] + pub key_prefix: String, + #[serde(rename = "createdBy")] + pub created_by: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "lastUsedAt", serialize_with = "crate::json::ts_opt")] + pub last_used_at: Option, + #[serde(rename = "revokedAt", serialize_with = "crate::json::ts_opt")] + pub revoked_at: Option, +} + +const PUBLIC_COLUMNS: &str = "id, name, key_prefix, created_by, created_at, last_used_at, revoked_at"; + +/// hashApiKey : empreinte SHA-256 en hexadécimal +pub fn hash_api_key(key: &str) -> String { + Sha256::digest(key.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Nouvelle clé : « lf_ » + 24 octets aléatoires en base64url sans remplissage +pub fn generate_api_key() -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + format!( + "{KEY_PREFIX}{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) +} + +/// Clés de la variable d'environnement EXTERNAL_API_KEYS (séparées par des +/// virgules), relues à chaque appel comme Node +pub fn env_api_keys() -> Vec { + std::env::var("EXTERNAL_API_KEYS") + .unwrap_or_default() + .split(',') + .map(|key| js::trim(key).to_string()) + .filter(|key| !key.is_empty()) + .collect() +} + +/// countEnvApiKeys +pub fn count_env_api_keys() -> usize { + std::env::var("EXTERNAL_API_KEYS") + .unwrap_or_default() + .split(',') + .filter(|key| !js::trim(key).is_empty()) + .count() +} + +/// listApiKeys : de la plus récente à la plus ancienne +pub async fn list_api_keys(db: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as(&format!( + "SELECT {PUBLIC_COLUMNS} FROM external_api_keys ORDER BY created_at DESC" + )) + .fetch_all(db) + .await +} + +/// createApiKey : rend la clé en clair (seule fois où elle est lisible) et sa ligne +pub async fn create_api_key( + db: &PgPool, + name: &str, + created_by: Option<&str>, +) -> Result<(String, PublicApiKey), sqlx::Error> { + let key = generate_api_key(); + let row = sqlx::query_as(&format!( + "INSERT INTO external_api_keys (name, key_prefix, key_hash, created_by) VALUES ($1, $2, $3, $4) RETURNING {PUBLIC_COLUMNS}" + )) + .bind(name) + .bind(&key[..DISPLAY_PREFIX_LENGTH]) + .bind(hash_api_key(&key)) + .bind(created_by) + .fetch_one(db) + .await?; + Ok((key, row)) +} + +/// revokeApiKey : false si la clé n'existe pas ou était déjà révoquée. L'identifiant +/// est envoyé en texte, comme node-pg (hors limites : erreur SQL) +pub async fn revoke_api_key(db: &PgPool, id: &str) -> Result { + let revoked: Option<(i32,)> = sqlx::query_as( + "UPDATE external_api_keys SET revoked_at = $2::timestamp WHERE id = $1::int4 AND revoked_at IS NULL RETURNING id", + ) + .bind(id) + .bind(crate::modules::now_iso()) + .fetch_optional(db) + .await?; + Ok(revoked.is_some()) +} + +/// countActiveApiKeys : clés non révoquées +pub async fn count_active_api_keys(db: &PgPool) -> Result { + let (count,): (i64,) = sqlx::query_as("SELECT count(*) FROM external_api_keys WHERE revoked_at IS NULL") + .fetch_one(db) + .await?; + Ok(count) +} + +/// verifyStoredApiKey : la clé est-elle active ? Sa date de dernière utilisation +/// est mise à jour au plus une fois par minute, sans attendre ni faire échouer +pub async fn verify_stored_api_key(db: &PgPool, key: &str) -> Result { + let row: Option<(i32, Option)> = sqlx::query_as( + "SELECT id, last_used_at FROM external_api_keys WHERE key_hash = $1 AND revoked_at IS NULL LIMIT 1", + ) + .bind(hash_api_key(key)) + .fetch_optional(db) + .await?; + let Some((id, last_used_at)) = row else { + return Ok(false); + }; + let stale = last_used_at.is_none_or(|last| { + (chrono::Utc::now().naive_utc() - last).num_seconds() > LAST_USED_THROTTLE_SECONDS + }); + if stale { + let db = db.clone(); + tokio::spawn(async move { + let result = + sqlx::query("UPDATE external_api_keys SET last_used_at = $2::timestamp WHERE id = $1") + .bind(id) + .bind(crate::modules::now_iso()) + .execute(&db) + .await; + if let Err(error) = result { + tracing::error!(%error, "[EXT-API] last_used_at"); + } + }); + } + Ok(true) +} + +/// Clé reçue dans « X-API-Key » ou « Authorization: Bearer » (extractKey) +pub fn extract_api_key(headers: &axum::http::HeaderMap) -> Option { + let header = |name| headers.get(name).and_then(|value| value.to_str().ok()); + if let Some(key) = header("x-api-key").map(js::trim).filter(|key| !key.is_empty()) { + return Some(key.to_string()); + } + let authorization = header("authorization")?; + let token = authorization + .get(..7) + .filter(|scheme| scheme.eq_ignore_ascii_case("bearer ")) + .map(|_| js::trim(&authorization[7..]))?; + (!token.is_empty()).then(|| token.to_string()) +} + +/// La clé est-elle une clé de l'environnement ? Comparaison à temps constant des empreintes +pub fn is_env_api_key(key: &str) -> bool { + use subtle::ConstantTimeEq; + let provided = Sha256::digest(key.as_bytes()); + env_api_keys() + .iter() + .any(|env_key| bool::from(Sha256::digest(env_key.as_bytes()).ct_eq(&provided))) +} + +// ---------- Gestion des clés depuis Paramètres (session admin) ---------- + +/// requireAdmin (server/permissions.ts) +fn require_admin(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(AppError::with_body( + StatusCode::FORBIDDEN, + json!({ + "message": "Accès réservé aux administrateurs", + "required": "admin role", + "userRole": user.role(), + }), + )) + } +} + +fn error_body(status: StatusCode, error: &str) -> AppError { + AppError::with_body(status, json!({ "error": error })) +} + +async fn list_keys(State(state): State, user: CurrentUser) -> ApiResult { + require_admin(&user)?; + let keys = list_api_keys(&state.db).await.map_err(|error| { + tracing::error!(%error, "[EXT-API] list keys"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la lecture des clés d'API", + ) + })?; + Ok(Json(json!({ + "basePath": EXTERNAL_API_PREFIX, + "envKeyCount": count_env_api_keys(), + "keys": keys, + })) + .into_response()) +} + +/// Type reçu selon zod, pour « Expected string, received … » +fn zod_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// z.object({ name: z.string().trim().min(1, "Nom requis").max(100) }) : le nom +/// sans blancs autour, ou le message du premier problème +fn parse_key_name(body: &Value) -> Result { + let Some(object) = body.as_object() else { + return Err(format!("Expected object, received {}", zod_type(body))); + }; + match object.get("name") { + None => Err("Required".to_string()), + Some(Value::String(name)) => { + let name = js::trim(name); + if js::length(name) < 1 { + Err("Nom requis".to_string()) + } else if js::length(name) > 100 { + Err("String must contain at most 100 character(s)".to_string()) + } else { + Ok(name.to_string()) + } + } + Some(other) => Err(format!("Expected string, received {}", zod_type(other))), + } +} + +async fn create_key(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + require_admin(&user)?; + let name = parse_key_name(&body).map_err(|message| error_body(StatusCode::BAD_REQUEST, &message))?; + let (key, api_key) = create_api_key(&state.db, &name, Some(user.id())) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] create key"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la création de la clé d'API", + ) + })?; + tracing::info!(name, by = user.user.username, "[EXT-API] clé créée"); + Ok(( + StatusCode::CREATED, + Json(json!({ "key": key, "apiKey": api_key })), + ) + .into_response()) +} + +async fn revoke_key(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + require_admin(&user)?; + // Number(id) : entier strictement positif + let id = js::to_number(&Value::String(id)); + if id.fract() != 0.0 || !id.is_finite() || id <= 0.0 { + return Err(error_body(StatusCode::BAD_REQUEST, "Identifiant invalide")); + } + let revoked = revoke_api_key(&state.db, &js::number_to_string(id)) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] revoke key"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la révocation de la clé d'API", + ) + })?; + if !revoked { + return Err(error_body( + StatusCode::NOT_FOUND, + "Clé introuvable ou déjà révoquée", + )); + } + tracing::info!(id, by = user.user.username, "[EXT-API] clé révoquée"); + Ok(Json(json!({ "success": true })).into_response()) +} + +// ---------- /api/ext/v1 : clé d'API ---------- + +/// requireApiKey : clé de l'environnement, puis clé enregistrée ; sans clé +/// valable, 503 si aucune clé n'est active, 401 sinon +async fn require_api_key(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> { + let failed = |error: sqlx::Error| { + tracing::error!(%error, "[EXT-API] vérification de la clé"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la vérification de la clé d'API", + ) + }; + if let Some(key) = extract_api_key(headers) { + if is_env_api_key(&key) || verify_stored_api_key(&state.db, &key).await.map_err(failed)? { + return Ok(()); + } + } + if count_env_api_keys() == 0 && count_active_api_keys(&state.db).await.map_err(failed)? == 0 { + return Err(error_body( + StatusCode::SERVICE_UNAVAILABLE, + "API externe désactivée : aucune clé d'API active (Paramètres > API externe)", + )); + } + tracing::warn!("[EXT-API] clé d'API invalide ou absente"); + Err(error_body( + StatusCode::UNAUTHORIZED, + "Clé d'API invalide ou absente", + )) +} + +/// Toute autre adresse sous le préfixe : clé d'API, puis 404 JSON. Le corps est +/// lu d'abord, comme express.json() (JSON illisible : 400) +async fn ext_unknown(State(state): State, request: Request) -> Response { + // Variante d'une route existante (casse, « / » final) : servie comme Express + let request = match crate::express_routes::redispatch(request).await { + Ok(response) => return response, + Err(request) => request, + }; + let headers = request.headers().clone(); + if let Err(error) = JsonBody::from_request(request, &state).await { + return error.into_response(); + } + match require_api_key(&state, &headers).await { + Ok(()) => error_body(StatusCode::NOT_FOUND, "Route inconnue").into_response(), + Err(error) => error.into_response(), + } +} + +async fn ext_stores(State(state): State, headers: HeaderMap) -> ApiResult { + require_api_key(&state, &headers).await?; + // storage.getGroups : par nom + let groups: Vec<(i32, String)> = sqlx::query_as("SELECT id, name FROM groups ORDER BY name") + .fetch_all(&state.db) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] stores"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la lecture des magasins", + ) + })?; + let out: Vec = groups + .into_iter() + .map(|(id, name)| json!({ "id": id, "name": name })) + .collect(); + Ok(Json(out).into_response()) +} + +async fn ext_suppliers(State(state): State, headers: HeaderMap) -> ApiResult { + require_api_key(&state, &headers).await?; + // storage.getSuppliers : par nom + let suppliers: Vec = + sqlx::query_as(&::select("ORDER BY name")) + .fetch_all(&state.db) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] suppliers"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la lecture des fournisseurs", + ) + })?; + let out: Vec = suppliers + .into_iter() + .map(|supplier| { + json!({ + "id": supplier.id, + "name": supplier.name, + "code": supplier.codefou, + "paymentMethod": supplier.payment_method, + "automaticReconciliation": supplier.automatic_reconciliation == Some(true), + }) + }) + .collect(); + Ok(Json(out).into_response()) +} + +/// Montant décimal lu en base, en nombre (toNumber : Number(texte)) +fn amount_number(amount: &Option) -> Value { + amount + .map(|amount| js::to_number(&Value::String(amount.to_string()))) + .filter(|number| number.is_finite()) + .map_or(Value::Null, js::number_value) +} + +/// Jour d'une date (toIsoDate) et instant d'un horodatage (toIsoDateTime) +fn iso_day(date: &chrono::NaiveDate) -> String { + date.format("%Y-%m-%d").to_string() +} + +fn iso_datetime(value: &Option) -> Option { + value.as_ref().map(crate::json::format_ts) +} + +/// Date retenue pour les bornes from / to : jour de livraison, sinon jour prévu +fn delivery_day(row: &DeliveriesRow) -> String { + iso_datetime(&row.delivered_date) + .map(|instant| instant[..10].to_string()) + .unwrap_or_else(|| iso_day(&row.scheduled_date)) +} + +/// Clé de tri : instant de livraison, sinon jour prévu +fn sort_key(row: &DeliveriesRow) -> String { + iso_datetime(&row.delivered_date).unwrap_or_else(|| iso_day(&row.scheduled_date)) +} + +/// toApiDelivery : champs utiles au rapprochement +fn api_delivery(row: &DeliveriesRow, supplier: Option<&SuppliersRow>, store_name: Option<&str>) -> Value { + json!({ + "id": row.id, + "storeId": row.group_id, + "storeName": store_name, + "supplierId": row.supplier_id, + "supplierName": supplier.map(|supplier| &supplier.name), + "supplierCode": supplier.and_then(|supplier| supplier.codefou.as_ref()), + "automaticReconciliation": supplier.and_then(|supplier| supplier.automatic_reconciliation) == Some(true), + "status": row.status, + "scheduledDate": iso_day(&row.scheduled_date), + "deliveredDate": iso_datetime(&row.delivered_date), + "blNumber": row.bl_number, + "blAmount": amount_number(&row.bl_amount), + "invoiceReference": row.invoice_reference, + "invoiceAmount": amount_number(&row.invoice_amount), + "invoiceAmountTTC": amount_number(&row.invoice_amount_ttc), + "dueDate": row.due_date.as_ref().map(|date| iso_day(&date.date())), + "reconciled": row.reconciled == Some(true), + "validatedAt": iso_datetime(&row.validated_at), + "updatedAt": iso_datetime(&row.updated_at), + }) +} + +fn detail_json(delivery: &deliveries::DeliveryDetail) -> Value { + api_delivery( + &delivery.row, + delivery.supplier.as_ref(), + delivery.group.as_ref().map(|group| group.name.as_str()), + ) +} + +/// Paramètre de req.query : absent, une valeur, ou un tableau (paramètre répété) +#[derive(Debug, Clone, PartialEq)] +enum Param { + Absent, + One(String), + Many, +} + +impl Param { + /// undefined pour absent ou "", sinon la valeur + fn given(&self) -> Option> { + match self { + Param::Absent => None, + Param::One(text) if text.is_empty() => None, + Param::One(text) => Some(Some(text)), + Param::Many => Some(None), + } + } +} + +/// req.query lu comme qs : un paramètre répété devient un tableau +fn query_params(raw: Option<&str>) -> Map { + let mut params = Map::new(); + for (key, value) in form_urlencoded::parse(raw.unwrap_or("").as_bytes()) { + let key = key.into_owned(); + match params.get_mut(&key) { + Some(Value::Array(values)) => values.push(Value::String(value.into_owned())), + Some(existing) => { + *existing = Value::Array(vec![existing.take(), Value::String(value.into_owned())]) + } + None => { + params.insert(key, Value::String(value.into_owned())); + } + } + } + params +} + +fn param(params: &Map, name: &str) -> Param { + match params.get(name) { + None => Param::Absent, + Some(Value::String(text)) => Param::One(text.clone()), + Some(_) => Param::Many, + } +} + +/// parseIntParam : entier strictement positif ; Err si invalide +fn parse_int_param(value: &Param) -> Result, ()> { + match value.given() { + None => Ok(None), + Some(Some(text)) => { + let number = js::to_number(&Value::String(text.to_string())); + if number.is_finite() && number.fract() == 0.0 && number > 0.0 { + Ok(Some(number)) + } else { + Err(()) + } + } + // Number(tableau de plusieurs valeurs) : NaN + Some(None) => Err(()), + } +} + +/// parseBoolParam : "true" ou "false" +fn parse_bool_param(value: &Param) -> Result, ()> { + match value.given() { + None => Ok(None), + Some(Some("true")) => Ok(Some(true)), + Some(Some("false")) => Ok(Some(false)), + Some(_) => Err(()), + } +} + +/// /^\d{4}-\d{2}-\d{2}$/ +fn is_iso_day(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes.len() == 10 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 4 | 7 => *byte == b'-', + _ => byte.is_ascii_digit(), + }) +} + +/// parseDateParam : AAAA-MM-JJ +fn parse_date_param(value: &Param) -> Result, ()> { + match value.given() { + None => Ok(None), + Some(Some(text)) if is_iso_day(text) => Ok(Some(text.to_string())), + Some(_) => Err(()), + } +} + +/// Filtres de GET /deliveries +#[derive(Debug, Default)] +struct DeliveryFilters { + store_id: Option, + supplier_id: Option, + reconciled: Option, + has_bl: Option, + has_invoice: Option, + from: Option, + to: Option, + limit: Option, + offset: f64, + bl_number: String, +} + +/// Paramètres lus comme Node ; Err : noms des paramètres invalides, dans l'ordre de Node +fn delivery_filters(params: &Map) -> Result> { + let mut invalid = Vec::new(); + let mut check = |name: &'static str, ok: bool| { + if !ok { + invalid.push(name); + } + }; + let store_id = parse_int_param(¶m(params, "storeId")); + check("storeId", store_id.is_ok()); + let supplier_id = parse_int_param(¶m(params, "supplierId")); + check("supplierId", supplier_id.is_ok()); + let reconciled = parse_bool_param(¶m(params, "reconciled")); + check("reconciled", reconciled.is_ok()); + let has_bl = parse_bool_param(¶m(params, "hasBl")); + check("hasBl", has_bl.is_ok()); + let has_invoice = parse_bool_param(¶m(params, "hasInvoice")); + check("hasInvoice", has_invoice.is_ok()); + let from = parse_date_param(¶m(params, "from")); + check("from", from.is_ok()); + let to = parse_date_param(¶m(params, "to")); + check("to", to.is_ok()); + let limit = parse_int_param(¶m(params, "limit")); + check("limit", limit.is_ok()); + // offset : Number(valeur), 0 si absent ou vide ; entier positif ou nul + let offset = match params.get("offset") { + None => 0.0, + Some(Value::String(text)) if text.is_empty() => 0.0, + Some(value) => js::to_number(value), + }; + check( + "offset", + offset.is_finite() && offset.fract() == 0.0 && offset >= 0.0, + ); + if !invalid.is_empty() { + return Err(invalid); + } + let bl_number = match params.get("blNumber") { + Some(Value::String(text)) => js::trim(text).to_lowercase(), + _ => String::new(), + }; + Ok(DeliveryFilters { + store_id: store_id.unwrap_or_default(), + supplier_id: supplier_id.unwrap_or_default(), + reconciled: reconciled.unwrap_or_default(), + has_bl: has_bl.unwrap_or_default(), + has_invoice: has_invoice.unwrap_or_default(), + from: from.unwrap_or_default(), + to: to.unwrap_or_default(), + limit: limit.unwrap_or_default(), + offset, + bl_number, + }) +} + +/// Texte non vide après trim (`!!valeur?.trim()`) +fn has_text(value: &Option) -> bool { + value.as_deref().is_some_and(|text| !js::trim(text).is_empty()) +} + +impl DeliveryFilters { + fn keeps(&self, row: &DeliveriesRow) -> bool { + if self + .supplier_id + .is_some_and(|id| f64::from(row.supplier_id) != id) + { + return false; + } + if self + .reconciled + .is_some_and(|wanted| (row.reconciled == Some(true)) != wanted) + { + return false; + } + if self + .has_bl + .is_some_and(|wanted| has_text(&row.bl_number) != wanted) + { + return false; + } + if self + .has_invoice + .is_some_and(|wanted| has_text(&row.invoice_reference) != wanted) + { + return false; + } + if !self.bl_number.is_empty() + && js::trim(row.bl_number.as_deref().unwrap_or("")).to_lowercase() != self.bl_number + { + return false; + } + if self.from.is_some() || self.to.is_some() { + let day = delivery_day(row); + if self.from.as_ref().is_some_and(|from| day < *from) { + return false; + } + if self.to.as_ref().is_some_and(|to| day > *to) { + return false; + } + } + true + } +} + +async fn ext_deliveries( + State(state): State, + headers: HeaderMap, + RawQuery(raw): RawQuery, +) -> ApiResult { + require_api_key(&state, &headers).await?; + let filters = delivery_filters(&query_params(raw.as_deref())).map_err(|invalid| { + error_body( + StatusCode::BAD_REQUEST, + &format!("Paramètre(s) invalide(s) : {}", invalid.join(", ")), + ) + })?; + let failed = || { + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la lecture des livraisons", + ) + }; + // Magasin hors des entiers de PostgreSQL : erreur de la requête + let stores = match filters.store_id { + Some(id) => vec![crate::db::js_int4(id).ok_or_else(failed)?], + None => Vec::new(), + }; + let mut rows = deliveries::get_deliveries(&state.db, &stores, Some("delivered")) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] deliveries"); + failed() + })?; + rows.retain(|delivery| filters.keeps(&delivery.item.row)); + // De la plus récente à la plus ancienne, puis par identifiant décroissant + rows.sort_by(|a, b| { + sort_key(&b.item.row) + .cmp(&sort_key(&a.item.row)) + .then(b.item.row.id.cmp(&a.item.row.id)) + }); + let page_size = filters.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let start = if filters.offset >= rows.len() as f64 { + rows.len() + } else { + filters.offset as usize + }; + let end = start.saturating_add(page_size as usize).min(rows.len()); + let items: Vec = rows[start..end] + .iter() + .map(|delivery| { + let item = &delivery.item; + api_delivery( + &item.row, + item.supplier.as_ref(), + item.group.as_ref().map(|group| group.name.as_str()), + ) + }) + .collect(); + Ok(Json(json!({ + "total": rows.len(), + "limit": js::number_value(page_size), + "offset": js::number_value(filters.offset), + "items": items, + })) + .into_response()) +} + +/// Identifiant d'adresse (parseIntParam) : 400 s'il n'est pas un entier positif +fn delivery_id(raw: &str) -> Result { + parse_int_param(&Param::One(raw.to_string())) + .ok() + .flatten() + .ok_or_else(|| error_body(StatusCode::BAD_REQUEST, "Identifiant invalide")) +} + +async fn ext_delivery( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> ApiResult { + require_api_key(&state, &headers).await?; + let id = delivery_id(&id)?; + let failed = || { + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la lecture de la livraison", + ) + }; + let id = crate::db::js_int4(id).ok_or_else(failed)?; + let delivery = deliveries::get_delivery(&state.db, id) + .await + .map_err(|error| { + tracing::error!(%error, "[EXT-API] delivery"); + failed() + })? + .ok_or_else(|| error_body(StatusCode::NOT_FOUND, "Livraison introuvable"))?; + Ok(Json(detail_json(&delivery)).into_response()) +} + +/// Champ du corps : absent, null, ou une valeur +type Change = Option>; + +/// Corps validé de PATCH /deliveries/:id (updateInvoiceSchema) +#[derive(Debug, Default, PartialEq)] +struct InvoiceUpdate { + invoice_reference: Change, + invoice_amount: Change, + invoice_amount_ttc: Change, + due_date: Change, + reconciled: Option, +} + +/// Problème de validation : { field, message } +type Issue = (String, String); + +/// number.toFixed(2) de JavaScript : valeur binaire exacte arrondie au plus +/// proche, l'égalité vers le haut ; « - » pour tout nombre négatif (-0 exclu). +/// Valable pour |nombre| < 1e21 +fn to_fixed_2(number: f64) -> String { + // Développement décimal exact (une fraction de double a au plus 1074 chiffres) + let exact = format!("{:.1100}", number.abs()); + let (integer, fraction) = exact.split_once('.').unwrap_or((&exact, "000")); + let mut cents: u128 = + integer.parse::().unwrap_or(0) * 100 + fraction[..2].parse::().unwrap_or(0); + if fraction.as_bytes()[2] >= b'5' { + cents += 1; + } + let sign = if number < 0.0 { "-" } else { "" }; + format!("{sign}{}.{:02}", cents / 100, cents % 100) +} + +/// Caractère retiré par /[\s ]/g (blancs de JavaScript) +fn is_js_space(c: char) -> bool { + let mut buffer = [0u8; 4]; + js::trim(c.encode_utf8(&mut buffer)).is_empty() +} + +/// amountSchema : nombre, ou texte « 1234.56 », « 1234,56 », « 1 234,56 » ; +/// rendu en texte à deux décimales, ou null. Err(true) : type refusé (le +/// schéma s'arrête), Err(false) : « Montant invalide » +fn parse_amount(value: &Value) -> Result, bool> { + let number = match value { + Value::Null => return Ok(None), + Value::Number(number) => number.as_f64().unwrap_or(f64::NAN), + Value::String(text) => { + let raw: String = text.chars().filter(|c| !is_js_space(*c)).collect(); + let raw = raw.replacen(',', ".", 1); + if raw.is_empty() { + return Ok(None); + } + js::to_number(&Value::String(raw)) + } + _ => return Err(true), + }; + if !number.is_finite() || number.abs() >= 1e8 { + return Err(false); + } + Ok(Some(to_fixed_2(number))) +} + +/// Clés d'un objet JavaScript dans leur ordre d'énumération : indices de tableau +/// croissants, puis les autres dans l'ordre d'insertion +fn js_key_order(object: &Map) -> Vec<&str> { + let (mut indexes, others): (Vec<&str>, Vec<&str>) = object + .keys() + .map(String::as_str) + .partition(|key| array_index(key).is_some()); + indexes.sort_by_key(|key| array_index(key)); + indexes.into_iter().chain(others).collect() +} + +const INVOICE_KEYS: [&str; 5] = [ + "invoiceReference", + "invoiceAmount", + "invoiceAmountTTC", + "dueDate", + "reconciled", +]; + +/// updateInvoiceSchema.safeParse : mêmes contrôles et messages que zod, dans le +/// même ordre (champs, clés inconnues, puis « au moins un champ » si aucun type +/// n'est refusé) +fn parse_invoice_update(body: &Value) -> Result> { + let Some(object) = body.as_object() else { + return Err(vec![( + String::new(), + format!("Expected object, received {}", zod_type(body)), + )]); + }; + let mut issues: Vec = Vec::new(); + let mut aborted = false; + let mut update = InvoiceUpdate::default(); + let mut issue = |field: &str, message: String| issues.push((field.to_string(), message)); + + match object.get("invoiceReference") { + None => {} + Some(Value::Null) => update.invoice_reference = Some(None), + Some(Value::String(text)) => { + let trimmed = js::trim(text); + if js::length(trimmed) > 100 { + issue( + "invoiceReference", + "String must contain at most 100 character(s)".into(), + ); + } + update.invoice_reference = Some(Some(trimmed.to_string())); + } + Some(other) => { + aborted = true; + issue( + "invoiceReference", + format!("Expected string, received {}", zod_type(other)), + ); + } + } + for (key, slot) in [ + ("invoiceAmount", &mut update.invoice_amount), + ("invoiceAmountTTC", &mut update.invoice_amount_ttc), + ] { + let Some(value) = object.get(key) else { + continue; + }; + match parse_amount(value) { + Ok(amount) => *slot = Some(amount), + Err(type_refused) => { + aborted |= type_refused; + issue( + key, + if type_refused { + "Invalid input" + } else { + "Montant invalide" + } + .into(), + ); + } + } + } + match object.get("dueDate") { + None => {} + Some(Value::Null) => update.due_date = Some(None), + Some(Value::String(text)) => { + if !is_iso_day(text) { + issue("dueDate", "Format attendu : YYYY-MM-DD".into()); + } + update.due_date = Some(Some(text.clone())); + } + Some(other) => { + aborted = true; + issue( + "dueDate", + format!("Expected string, received {}", zod_type(other)), + ); + } + } + match object.get("reconciled") { + None => {} + Some(Value::Bool(flag)) => update.reconciled = Some(*flag), + Some(other) => { + aborted = true; + issue( + "reconciled", + format!("Expected boolean, received {}", zod_type(other)), + ); + } + } + // .strict() + let unknown: Vec = js_key_order(object) + .into_iter() + .filter(|key| !INVOICE_KEYS.contains(key)) + .map(|key| format!("'{key}'")) + .collect(); + if !unknown.is_empty() { + issue( + "", + format!("Unrecognized key(s) in object: {}", unknown.join(", ")), + ); + } + // .refine() : au moins un champ du schéma (sauf si un type a été refusé) + if !aborted && !INVOICE_KEYS.iter().any(|key| object.contains_key(*key)) { + issue("", "Aucun champ à mettre à jour".into()); + } + if issues.is_empty() { + Ok(update) + } else { + Err(issues) + } +} + +async fn ext_update_delivery( + State(state): State, + headers: HeaderMap, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + require_api_key(&state, &headers).await?; + let id = delivery_id(&id)?; + let body = parse_invoice_update(&body).map_err(|issues| { + let details: Vec = issues + .into_iter() + .map(|(field, message)| json!({ "field": field, "message": message })) + .collect(); + AppError::with_body( + StatusCode::BAD_REQUEST, + json!({ "error": "Corps de requête invalide", "details": details }), + ) + })?; + let failed = |error: &dyn std::fmt::Display| { + tracing::error!(%error, "[EXT-API] update delivery"); + error_body( + StatusCode::INTERNAL_SERVER_ERROR, + "Erreur lors de la mise à jour de la livraison", + ) + }; + let id = crate::db::js_int4(id).ok_or_else(|| failed(&"identifiant hors limites"))?; + let delivery = deliveries::get_delivery(&state.db, id) + .await + .map_err(|error| failed(&error))? + .ok_or_else(|| error_body(StatusCode::NOT_FOUND, "Livraison introuvable"))?; + if delivery.row.status != "delivered" { + return Err(error_body( + StatusCode::CONFLICT, + "Seules les livraisons livrées peuvent être rapprochées", + )); + } + // Livraison rapprochée figée, sauf fournisseur en rapprochement automatique + let touches_invoice = body.invoice_reference.is_some() + || body.invoice_amount.is_some() + || body.invoice_amount_ttc.is_some() + || body.due_date.is_some(); + let automatic = delivery + .supplier + .as_ref() + .and_then(|supplier| supplier.automatic_reconciliation) + == Some(true); + if delivery.row.reconciled == Some(true) + && !automatic + && touches_invoice + && body.reconciled != Some(false) + { + return Err(error_body( + StatusCode::CONFLICT, + "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture", + )); + } + + let text = |value: Option| value.map_or(Value::Null, Value::String); + let mut update = Map::new(); + let reference = body + .invoice_reference + .map(|reference| reference.filter(|text| !text.is_empty())); + if let Some(reference) = &reference { + update.insert("invoiceReference".into(), text(reference.clone())); + } + if let Some(amount) = body.invoice_amount { + update.insert("invoiceAmount".into(), text(amount)); + } + if let Some(amount) = body.invoice_amount_ttc { + update.insert("invoiceAmountTTC".into(), text(amount)); + } + match (body.due_date, &reference) { + // new Date(AAAA-MM-JJ) ; une date impossible fait échouer l'écriture + (Some(Some(day)), _) => { + let date = dates::parse_js_date(&day).ok_or_else(|| failed(&"échéance illisible"))?; + update.insert("dueDate".into(), Value::String(js::iso(&date))); + } + (Some(None), _) => { + update.insert("dueDate".into(), Value::Null); + } + // Échéance non fournie : reprise de NocoDB si la référence change, ou vidée avec elle + (None, Some(reference)) if *reference != delivery.row.invoice_reference => { + let due_date = match reference { + Some(reference) => deliveries::due_date_from_nocodb( + &state, + reference, + delivery.row.group_id, + delivery.row.reconciled == Some(true), + ) + .await + .map_err(|_| failed(&"échéance de NocoDB illisible"))?, + None => Value::Null, + }; + update.insert("dueDate".into(), due_date); + } + (None, _) => {} + } + if let Some(reconciled) = body.reconciled { + update.insert("reconciled".into(), Value::Bool(reconciled)); + update.insert( + "validatedAt".into(), + if reconciled { + Value::String(crate::modules::now_iso()) + } else { + Value::Null + }, + ); + } + crate::db::update_by_id::(&state.db, id, &update, true) + .await + .map_err(|error| failed(&error))?; + tracing::info!(id, "[EXT-API] livraison mise à jour"); + let updated = deliveries::get_delivery(&state.db, id) + .await + .map_err(|error| failed(&error))? + .ok_or_else(|| failed(&"livraison disparue"))?; + Ok(Json(detail_json(&updated)).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empreinte_comme_node() { + // createHash('sha256').update(clé).digest('hex') dans Node 22 + assert_eq!( + hash_api_key("lf_banc_cle_de_test_0123456789"), + "e88dfa10d39cb50b09adfd4bedafd2abb41e16c3f8dc2f7df8695719d3a8a3f4" + ); + assert_eq!( + hash_api_key("lf_cle_revoquee"), + "42e7a01ed97247a07de4b835551e6664128ba513452c94161b4a34e2e616ebab" + ); + let key = generate_api_key(); + assert!(key.starts_with("lf_")); + assert_eq!(key.len(), 35); + } + + #[test] + fn nom_de_cle_comme_zod() { + assert_eq!( + parse_key_name(&json!({ "name": " Outil " })), + Ok("Outil".to_string()) + ); + assert_eq!(parse_key_name(&json!({})), Err("Required".to_string())); + assert_eq!( + parse_key_name(&json!({ "name": " " })), + Err("Nom requis".to_string()) + ); + assert_eq!( + parse_key_name(&json!({ "name": 3 })), + Err("Expected string, received number".to_string()) + ); + assert_eq!( + parse_key_name(&json!({ "name": "x".repeat(101) })), + Err("String must contain at most 100 character(s)".to_string()) + ); + assert_eq!( + parse_key_name(&json!([])), + Err("Expected object, received array".to_string()) + ); + } + + #[test] + fn cle_dans_les_en_tetes() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert("authorization", "Bearer lf_abc ".parse().unwrap()); + assert_eq!(extract_api_key(&headers).as_deref(), Some("lf_abc")); + headers.insert("x-api-key", " lf_x ".parse().unwrap()); + assert_eq!(extract_api_key(&headers).as_deref(), Some("lf_x")); + } + + #[test] + fn routes_sans_conflit() { + let _ = Router::::new().merge(routes()); + } + + #[test] + fn to_fixed_comme_javascript() { + // (n).toFixed(2) dans Node 22 + for (number, expected) in [ + (1234.5, "1234.50"), + (-45.9, "-45.90"), + (1.005, "1.00"), + (1.125, "1.13"), + (0.045, "0.04"), + (8.345, "8.35"), + (12.345, "12.35"), + (1234.565, "1234.57"), + (99999999.995, "100000000.00"), + (-0.0, "0.00"), + (-1e-7, "-0.00"), + (-0.005, "-0.01"), + (1e-7, "0.00"), + ] { + assert_eq!(to_fixed_2(number), expected, "{number}"); + } + } + + #[test] + fn montants_francais() { + assert_eq!(parse_amount(&json!(" 1 234,565 ")), Ok(Some("1234.57".into()))); + assert_eq!(parse_amount(&json!("1\u{202f}234,5")), Ok(Some("1234.50".into()))); + assert_eq!(parse_amount(&json!(1500.6)), Ok(Some("1500.60".into()))); + assert_eq!(parse_amount(&json!("0x10")), Ok(Some("16.00".into()))); + assert_eq!(parse_amount(&json!(" ")), Ok(None)); + assert_eq!(parse_amount(&json!(null)), Ok(None)); + assert_eq!(parse_amount(&json!("1.234,56")), Err(false)); + assert_eq!(parse_amount(&json!("1e9")), Err(false)); + assert_eq!(parse_amount(&json!("Infinity")), Err(false)); + assert_eq!(parse_amount(&json!(true)), Err(true)); + } + + fn details(body: Value) -> Value { + match parse_invoice_update(&body) { + Ok(update) => json!(format!("{update:?}")), + Err(issues) => Value::Array( + issues + .into_iter() + .map(|(field, message)| json!({ "field": field, "message": message })) + .collect(), + ), + } + } + + #[test] + fn corps_de_facture_comme_zod() { + // updateInvoiceSchema.safeParse(corps) dans Node + assert_eq!( + details(json!({})), + json!([{ "field": "", "message": "Aucun champ à mettre à jour" }]) + ); + assert_eq!( + details(json!([])), + json!([{ "field": "", "message": "Expected object, received array" }]) + ); + assert_eq!( + details(serde_json::from_str(r#"{"foo":1,"2":3,"bar":2}"#).unwrap()), + json!([ + { "field": "", "message": "Unrecognized key(s) in object: '2', 'foo', 'bar'" }, + { "field": "", "message": "Aucun champ à mettre à jour" } + ]) + ); + assert_eq!( + details(json!({ "invoiceReference": 5, "foo": 1 })), + json!([ + { "field": "invoiceReference", "message": "Expected string, received number" }, + { "field": "", "message": "Unrecognized key(s) in object: 'foo'" } + ]) + ); + assert_eq!( + details( + json!({ "invoiceReference": "x".repeat(101), "invoiceAmount": "abc", "dueDate": "15/10/2026", "zzz": 1 }) + ), + json!([ + { "field": "invoiceReference", "message": "String must contain at most 100 character(s)" }, + { "field": "invoiceAmount", "message": "Montant invalide" }, + { "field": "dueDate", "message": "Format attendu : YYYY-MM-DD" }, + { "field": "", "message": "Unrecognized key(s) in object: 'zzz'" } + ]) + ); + assert_eq!( + details(json!({ "invoiceAmount": true, "invoiceAmountTTC": "1e9" })), + json!([ + { "field": "invoiceAmount", "message": "Invalid input" }, + { "field": "invoiceAmountTTC", "message": "Montant invalide" } + ]) + ); + assert_eq!( + details(json!({ "reconciled": null })), + json!([{ "field": "reconciled", "message": "Expected boolean, received null" }]) + ); + assert_eq!( + parse_invoice_update(&json!({ + "invoiceAmount": " 1 234,565 ", "invoiceAmountTTC": 1500.6, "invoiceReference": " FAC-1 ", "dueDate": null + })), + Ok(InvoiceUpdate { + invoice_reference: Some(Some("FAC-1".into())), + invoice_amount: Some(Some("1234.57".into())), + invoice_amount_ttc: Some(Some("1500.60".into())), + due_date: Some(None), + reconciled: None, + }) + ); + } + + fn filters(query: &str) -> Result> { + delivery_filters(&query_params(Some(query))) + } + + #[test] + fn parametres_de_la_liste() { + let ok = filters("storeId=1&supplierId=0x2&reconciled=false&hasBl=&from=2026-10-01&limit=1e3&offset=5&blNumber=%20BL-1002%20").unwrap(); + assert_eq!(ok.store_id, Some(1.0)); + assert_eq!(ok.supplier_id, Some(2.0)); + assert_eq!(ok.reconciled, Some(false)); + assert_eq!(ok.has_bl, None); + assert_eq!(ok.from.as_deref(), Some("2026-10-01")); + assert_eq!(ok.limit, Some(1000.0)); + assert_eq!(ok.offset, 5.0); + assert_eq!(ok.bl_number, "bl-1002"); + assert_eq!( + filters( + "storeId=0&supplierId=1.5&reconciled=yes&hasInvoice=1&from=2026-1-1&to=x&limit=-1&offset=-1" + ) + .unwrap_err(), + [ + "storeId", + "supplierId", + "reconciled", + "hasInvoice", + "from", + "to", + "limit", + "offset" + ] + ); + // Paramètre répété : tableau (invalide, ou ignoré pour blNumber) + assert_eq!(filters("storeId=1&storeId=1").unwrap_err(), ["storeId"]); + assert_eq!(filters("blNumber=a&blNumber=b").unwrap().bl_number, ""); + assert_eq!(filters("offset=").unwrap().offset, 0.0); + } + + #[test] + fn echeance_lue_comme_new_date() { + // new Date('2026-02-30').toISOString() dans Node + assert_eq!( + dates::parse_js_date("2026-02-30") + .map(|date| js::iso(&date)) + .as_deref(), + Some("2026-03-02T00:00:00.000Z") + ); + assert!(dates::parse_js_date("2026-13-45").is_none()); + } +} diff --git a/server-rs/src/modules/ffnancy.rs b/server-rs/src/modules/ffnancy.rs new file mode 100644 index 0000000..cef618b --- /dev/null +++ b/server-rs/src/modules/ffnancy.rs @@ -0,0 +1,226 @@ +// Relais ffnancy : /api/ffnancy/* (server/routes.ts, « Proxy API ffnancy »). +// Le navigateur ne peut pas appeler https://api.ffnancy.fr (CORS) : le serveur +// relaie la recherche d'articles et les mouvements d'entrée, avec les seuls +// paramètres autorisés (renseignés, dans l'ordre de la liste). +// +// Comme Node (node-fetch) : en-têtes Accept « */* » et User-Agent « node-fetch », +// sans mandataire ; réponse en erreur : son code et { error: 'API ffnancy error' } ; +// réponse illisible ou injoignable : 500. Le JSON reçu est relu puis réécrit +// comme JSON.parse / JSON.stringify (nombres au format de JavaScript, clés +// numériques en tête), mot de passe SMTP masqué comme toute réponse /api. +// +// Écarts sans effet sur la réponse : pas d'Accept-Encoding (le client HTTP ne +// décompresse pas), et un délai maximal de 30 s (node-fetch n'en a pas). + +use std::sync::LazyLock; +use std::time::Duration; + +use axum::Router; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::json::strip_smtp_password; +use crate::request::JsQuery; +use crate::services::nocodb::array_index; + +const FFNANCY_API: &str = "https://api.ffnancy.fr/api"; +const ARTICLE_PARAMS: [&str; 7] = ["search", "codein", "ean", "codefou", "actif", "page", "limit"]; +const ENTRY_PARAMS: [&str; 6] = ["artNoId", "dateDebut", "dateFin", "site", "page", "limit"]; +const TIMEOUT: Duration = Duration::from_secs(30); + +static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .no_proxy() + .user_agent("node-fetch") + .timeout(TIMEOUT) + .build() + .expect("client HTTP ffnancy") +}); + +pub fn routes() -> Router { + Router::new() + .route("/api/ffnancy/articles", get(articles)) + .route("/api/ffnancy/mouvements/entrees", get(entries)) +} + +/// Adresse relayée : `${base}/${chemin}?${new URLSearchParams(autorisés renseignés)}` +pub fn relay_url(path: &str, allowed: &[&str], query: &JsQuery) -> String { + let mut params = form_urlencoded::Serializer::new(String::new()); + for key in allowed { + if let Some(value) = query.truthy(key) { + params.append_pair(key, value); + } + } + format!("{FFNANCY_API}/{path}?{}", params.finish()) +} + +async fn articles(_user: CurrentUser, query: JsQuery) -> ApiResult { + relay(relay_url("articles", &ARTICLE_PARAMS, &query)).await +} + +async fn entries(_user: CurrentUser, query: JsQuery) -> ApiResult { + relay(relay_url("mouvements/entrees", &ENTRY_PARAMS, &query)).await +} + +fn error_body(status: StatusCode, error: &str) -> AppError { + AppError::with_body(status, json!({ "error": error })) +} + +/// GET de l'adresse : code d'erreur relayé, ou le JSON reçu réécrit comme Node +async fn relay(url: String) -> ApiResult { + let unreachable = |error: &dyn std::fmt::Display| { + tracing::warn!(%error, "appel ffnancy"); + error_body(StatusCode::INTERNAL_SERVER_ERROR, "Failed to reach API ffnancy") + }; + let response = CLIENT + .get(&url) + .header(header::ACCEPT, "*/*") + .send() + .await + .map_err(|error| unreachable(&error))?; + let status = response.status(); + if !status.is_success() { + return Err(error_body(status, "API ffnancy error")); + } + let text = response.text().await.map_err(|error| unreachable(&error))?; + // TextDecoder de node-fetch : marque d'ordre des octets retirée + let value: Value = serde_json::from_str(text.strip_prefix('\u{feff}').unwrap_or(&text)) + .map_err(|error| unreachable(&error))?; + Ok(json_response(js_stringify(&strip_smtp_password(value)))) +} + +fn json_response(body: String) -> Response { + let mut response = body.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + response +} + +/// JSON.stringify d'une valeur lue par JSON.parse : nombres au format de +/// JavaScript (10.0 → 10, 1e21 → 1e+21), clés « indices de tableau » en tête +/// par ordre croissant +pub fn js_stringify(value: &Value) -> String { + let mut out = String::new(); + write_js(value, &mut out); + out +} + +fn write_js(value: &Value, out: &mut String) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }), + Value::Number(number) => { + let number = number.as_f64().unwrap_or(f64::NAN); + if number.is_finite() { + out.push_str(&js::number_string(number)); + } else { + out.push_str("null"); + } + } + Value::String(text) => out.push_str(&Value::String(text.clone()).to_string()), + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_js(item, out); + } + out.push(']'); + } + Value::Object(fields) => { + let (mut indexes, others): (Vec<&String>, Vec<&String>) = + fields.keys().partition(|key| array_index(key).is_some()); + indexes.sort_by_key(|key| array_index(key)); + out.push('{'); + for (position, key) in indexes.into_iter().chain(others).enumerate() { + if position > 0 { + out.push(','); + } + out.push_str(&Value::String(key.clone()).to_string()); + out.push(':'); + write_js(&fields[key.as_str()], out); + } + out.push('}'); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn query(pairs: &[(&str, &str)]) -> JsQuery { + JsQuery( + pairs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(), + ) + } + + #[test] + fn parametres_autorises_dans_l_ordre_de_la_liste() { + // Valeurs de `${new URLSearchParams(…)}` dans Node + let q = query(&[ + ("limit", "1"), + ("ean", "3245678901234"), + ("groupId", "1"), + ("search", "pot de fleur & co/é*"), + ("codein", ""), + ]); + assert_eq!( + relay_url("articles", &ARTICLE_PARAMS, &q), + "https://api.ffnancy.fr/api/articles?search=pot+de+fleur+%26+co%2F%C3%A9*&ean=3245678901234&limit=1" + ); + let q = query(&[ + ("dateDebut", "2000-01-01"), + ("artNoId", "42"), + ("limit", "1"), + ("ean", "x"), + ]); + assert_eq!( + relay_url("mouvements/entrees", &ENTRY_PARAMS, &q), + "https://api.ffnancy.fr/api/mouvements/entrees?artNoId=42&dateDebut=2000-01-01&limit=1" + ); + } + + #[test] + fn sans_parametre() { + assert_eq!( + relay_url("articles", &ARTICLE_PARAMS, &query(&[])), + "https://api.ffnancy.fr/api/articles?" + ); + } + + #[test] + fn json_reecrit_comme_javascript() { + // JSON.stringify(JSON.parse(texte)) dans Node + let parsed: Value = serde_json::from_str( + r#"{"b":1.0,"10":"x","a":[1e21,0.00001,12345678901234567890,-0.0,"é\u001f"],"2":{"z":true,"0":null},"b":2.50}"#, + ) + .unwrap(); + assert_eq!( + js_stringify(&parsed), + r#"{"2":{"0":null,"z":true},"10":"x","b":2.5,"a":[1e+21,0.00001,12345678901234567000,0,"é\u001f"]}"# + ); + } + + #[test] + fn mot_de_passe_smtp_masque() { + let value = json!({ "group": { "smtpPassword": "secret" } }); + assert_eq!( + js_stringify(&strip_smtp_password(value)), + r#"{"group":{"smtpPasswordSet":true}}"# + ); + } +} diff --git a/server-rs/src/modules/groups.rs b/server-rs/src/modules/groups.rs new file mode 100644 index 0000000..fa29caa --- /dev/null +++ b/server-rs/src/modules/groups.rs @@ -0,0 +1,264 @@ +// Magasins : /api/groups (server/routes.ts, « Groups routes »). +// +// Le mot de passe SMTP ne sort jamais : comme stripSmtpPassword +// (server/sanitize.ts, appliqué par Node à toute réponse JSON de /api), il est +// remplacé par smtpPasswordSet, à la même place. Il est chiffré à l'écriture +// (crate::crypto, comme storage.createGroup / updateGroup). +// +// POST /api/groups/:id/test-smtp vérifie la configuration SMTP du magasin +// (connexion et authentification, sans envoi) : services::mail. +// +// Comme Node aujourd'hui : un manager peut créer, modifier et supprimer +// n'importe quel magasin, pas seulement le sien. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, routing::get, routing::post, routing::put}; +use serde_json::{Map, Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::crypto; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, empty_json}; +use crate::request::{JsonBody, int_id}; +use crate::schema::GroupsRow; +use crate::zod::{Field, Object, Parsed}; + +pub fn routes() -> Router { + Router::new() + .route("/api/groups", get(list).post(create)) + .route("/api/groups/{id}", put(update).delete(remove)) + .route("/api/groups/{id}/test-smtp", post(test_smtp)) +} + +/// insertGroupSchema (shared/schema.ts) : le formulaire envoie "" pour les +/// champs vides, d'où les champs texte élargis +fn insert_schema() -> Object { + GroupsRow::insert_schema() + .omit(&["id", "createdAt", "updatedAt"]) + .extend(vec![ + Field::string("address").optional().nullable(), + Field::string("phone").optional().nullable(), + Field::string("logo").optional().nullable(), + Field::string("smtpHost").optional().nullable(), + Field::coerce_number("smtpPort") + .int() + .min(1.0) + .max(65535.0) + .optional() + .nullable(), + Field::string("smtpUser").optional().nullable(), + Field::string("smtpPassword").optional().nullable(), + Field::union( + "smtpSenderEmail", + vec![Field::string("").email(), Field::literal("", json!(""))], + ) + .optional() + .nullable(), + Field::string("smtpSenderName").optional().nullable(), + ]) +} + +/// stripSmtpPassword : smtpPassword (ou smtp_password) remplacé par +/// smtpPasswordSet, à la même place, dans tout l'objet (8 niveaux au plus) +pub fn strip_smtp_password(value: Value) -> Value { + fn strip(value: Value, depth: usize) -> Value { + if depth > 8 { + return value; + } + match value { + Value::Array(items) => { + Value::Array(items.into_iter().map(|item| strip(item, depth + 1)).collect()) + } + Value::Object(object) => { + let mut out = Map::with_capacity(object.len()); + for (key, entry) in object { + if key == "smtpPassword" || key == "smtp_password" { + out.insert("smtpPasswordSet".into(), Value::Bool(js_truthy(&entry))); + } else { + out.insert(key, strip(entry, depth + 1)); + } + } + Value::Object(out) + } + other => other, + } + } + strip(value, 0) +} + +/// Boolean(valeur) de JavaScript +fn js_truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(flag) => *flag, + Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0), + Value::String(text) => !text.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +/// Réponse JSON d'un magasin complet, sans son mot de passe SMTP +fn group_json(group: T) -> Response { + match serde_json::to_value(group) { + Ok(value) => Json(strip_smtp_password(value)).into_response(), + Err(_) => AppError::internal("Internal Server Error").into_response(), + } +} + +/// encryptSecret sur le mot de passe SMTP fourni (vide ou déjà chiffré : inchangé) +fn encrypt_smtp_password(data: &mut Parsed, secret: &str) { + if let Some(Value::String(password)) = data.get("smtpPassword") { + let sealed = crypto::encrypt(password, secret); + data.insert("smtpPassword".into(), Value::String(sealed)); + } +} + +fn can_write(user: &CurrentUser) -> Result<(), AppError> { + if ["admin", "manager"].contains(&user.role()) { + Ok(()) + } else { + Err(AppError::forbidden("Insufficient permissions")) + } +} + +/// Admin : tous les magasins (fiches complètes, triées par nom) ; les autres : +/// leurs magasins, tels que chargés avec l'utilisateur +async fn list(State(state): State, user: CurrentUser) -> ApiResult { + if user.is_admin() { + let groups: Vec = sqlx::query_as(&GroupsRow::select("ORDER BY name")) + .fetch_all(&state.db) + .await + .or_500("Failed to fetch groups")?; + return Ok(group_json(groups)); + } + let groups: Vec<_> = user + .user_groups + .iter() + .map(|membership| &membership.group) + .collect(); + Ok(Json(groups).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + can_write(&user)?; + let mut data = insert_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Validation failed", issues))?; + encrypt_smtp_password(&mut data, &state.config.encryption_key); + let group: GroupsRow = db::insert(&state.db, &data) + .await + .or_500("Failed to create group")?; + Ok(group_json(group)) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update group"; + can_write(&user)?; + let id = int_id(&id, FAILED)?; + let mut data = insert_schema() + .partial() + .parse(&body) + .map_err(|_| AppError::internal(FAILED))?; + // Le mot de passe n'est jamais renvoyé au client : vide, il reste inchangé + if !data.get("smtpPassword").is_some_and(js_truthy) { + data.remove("smtpPassword"); + } + encrypt_smtp_password(&mut data, &state.config.encryption_key); + let group = db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(match group { + Some(group) => group_json(group), + None => empty_json(), + }) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete group"; + can_write(&user)?; + let id = int_id(&id, FAILED)?; + // Un magasin encore utilisé (commandes, livraisons…) fait échouer la suppression + db::delete_by_id::(&state.db, id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Group deleted successfully" })).into_response()) +} + +/// Test de la configuration SMTP d'un magasin, sans envoi : { success, message }, +/// toute erreur (base comprise) en 400 avec son message +async fn test_smtp(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + can_write(&user)?; + let failure = |message: &str| { + let message = if message.is_empty() { + "Impossible de joindre le serveur SMTP" + } else { + message + }; + AppError::with_body( + StatusCode::BAD_REQUEST, + json!({ "success": false, "message": message }), + ) + }; + // parseInt(id) envoyé en texte : un identifiant illisible fait échouer la requête + let id = crate::js::parse_int(&id).map_or_else(|| "NaN".to_string(), crate::js::number_to_string); + let group = crate::services::nocodb::group_by_text_id(&state.db, Some(&id)) + .await + .map_err(|message| failure(&message))? + .ok_or_else(|| AppError::not_found("Magasin introuvable"))?; + let missing = crate::services::mail::missing_smtp_fields(&group); + if !missing.is_empty() { + return Err(failure(&format!( + "Configuration incomplète : {}", + missing.join(", ") + ))); + } + crate::services::mail::verify_smtp_config(&group, &state.config.encryption_key) + .await + .map_err(|message| failure(&message))?; + Ok(Json(json!({ "success": true, "message": "Connexion au serveur SMTP réussie" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mot_de_passe_smtp_remplace_a_sa_place() { + let value = json!([{ "id": 1, "smtpUser": "u", "smtpPassword": "enc:v1:x", "smtpSenderName": "n" }, + { "group": { "smtp_password": "" } }]); + assert_eq!( + strip_smtp_password(value).to_string(), + r#"[{"id":1,"smtpUser":"u","smtpPasswordSet":true,"smtpSenderName":"n"},{"group":{"smtpPasswordSet":false}}]"# + ); + } + + #[test] + fn courriel_d_expediteur_vide_ou_valide() { + let schema = insert_schema(); + assert!( + schema + .parse(&json!({ "name": "X", "smtpSenderEmail": "" })) + .is_ok() + ); + assert!( + schema + .parse(&json!({ "name": "X", "smtpSenderEmail": null, "smtpPort": "587" })) + .is_ok_and(|data| data["smtpPort"] == json!(587)) + ); + let issues = schema + .parse(&json!({ "name": "X", "smtpSenderEmail": "abc", "smtpPort": "0" })) + .unwrap_err(); + assert_eq!( + serde_json::to_string(&issues).unwrap(), + r#"[{"code":"too_small","minimum":1,"type":"number","inclusive":true,"exact":false,"message":"Number must be greater than or equal to 1","path":["smtpPort"]},{"validation":"email","code":"invalid_string","message":"Invalid email","path":["smtpSenderEmail"]}]"# + ); + } +} diff --git a/server-rs/src/modules/health.rs b/server-rs/src/modules/health.rs new file mode 100644 index 0000000..d6cdfc3 --- /dev/null +++ b/server-rs/src/modules/health.rs @@ -0,0 +1,16 @@ +// GET /api/health (server/routes.ts:118), utilisé par le contrôle de santé Docker. + +use axum::Json; +use axum::extract::State; +use serde_json::{Value, json}; + +use crate::AppState; + +pub async fn health(State(state): State) -> Json { + Json(json!({ + "status": "healthy", + "timestamp": crate::modules::now_iso(), + "environment": state.config.environment, + "database": "connected", + })) +} diff --git a/server-rs/src/modules/mod.rs b/server-rs/src/modules/mod.rs new file mode 100644 index 0000000..cb8a06c --- /dev/null +++ b/server-rs/src/modules/mod.rs @@ -0,0 +1,33 @@ +// Un module par domaine de l'application (lots de docs/PLAN-MIGRATION-RUST.md). +pub mod admin; +pub mod analytics; +pub mod announcements; +pub mod avoirs; +pub mod backups; +pub mod bap; +pub mod contacts; +pub mod customer_orders; +pub mod deliveries; +pub mod dlc; +pub mod external_api; +pub mod ffnancy; +pub mod groups; +pub mod health; +pub mod nocodb_config; +pub mod orders; +pub mod payment_schedule; +pub mod publicities; +pub mod reconciliation; +pub mod sav; +pub mod session_routes; +pub mod stats; +pub mod suppliers; +pub mod tasks; +pub mod users; +pub mod utilities; +pub mod weather; + +/// Instant présent au format de JavaScript : 2026-10-15T08:30:00.000Z +pub fn now_iso() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} diff --git a/server-rs/src/modules/nocodb_config.rs b/server-rs/src/modules/nocodb_config.rs new file mode 100644 index 0000000..62eb2ce --- /dev/null +++ b/server-rs/src/modules/nocodb_config.rs @@ -0,0 +1,136 @@ +// Configuration NocoDB : /api/nocodb-config (server/routes.ts, « NocoDB Configuration +// routes »). Le jeton API est chiffré en base et rendu en clair dans les réponses, +// comme Node (storage.decryptNocodbConfig). +// +// Défaut de Node reproduit : GET /api/nocodb-config/active rend la configuration +// active, jeton API en clair compris, à tout compte connecté (les autres adresses +// sont réservées à l'admin). + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, json_or_empty}; +use crate::request::{JsonBody, int_id}; +use crate::schema::NocodbConfigRow; +use crate::services::nocodb; +use crate::zod::{Object, Parsed}; + +pub fn routes() -> Router { + Router::new() + .route("/api/nocodb-config", get(list).post(create)) + .route("/api/nocodb-config/active", get(active)) + .route("/api/nocodb-config/{id}", put(update).delete(remove)) +} + +/// insertNocodbConfigSchema +fn insert_schema() -> Object { + NocodbConfigRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +fn admin_only(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(AppError::forbidden( + "Accès refusé. Seuls les administrateurs peuvent gérer les configurations NocoDB.", + )) + } +} + +/// Erreur 500 de ces adresses : { error } au lieu de { message } +fn failure(message: &str) -> AppError { + AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": message })) +} + +/// encryptSecret(apiToken) || '' : le jeton n'est jamais écrit en clair +fn encrypt_token(data: &mut Parsed, encryption_key: &str) { + if let Some(Value::String(token)) = data.get("apiToken") { + let sealed = crate::crypto::encrypt(token, encryption_key); + data.insert("apiToken".into(), Value::String(sealed)); + } +} + +async fn list(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let configs: Vec = sqlx::query_as(&NocodbConfigRow::select("ORDER BY created_at DESC")) + .fetch_all(&state.db) + .await + .map_err(|error| { + tracing::error!(%error, "lecture des configurations NocoDB"); + failure("Failed to fetch configurations") + })?; + let key = &state.config.encryption_key; + let configs: Vec = configs + .into_iter() + .map(|config| nocodb::decrypt_config(config, key)) + .collect(); + Ok(Json(configs).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create configuration"; + admin_only(&user)?; + let mut input = body.as_object().cloned().unwrap_or_default(); + input.insert("createdBy".into(), json!(user.id())); + let mut data = insert_schema() + .parse(&Value::Object(input)) + .map_err(|_| failure(FAILED))?; + encrypt_token(&mut data, &state.config.encryption_key); + let config: NocodbConfigRow = db::insert(&state.db, &data).await.map_err(|error| { + tracing::error!(%error, "{FAILED}"); + failure(FAILED) + })?; + let config = nocodb::decrypt_config(config, &state.config.encryption_key); + Ok((StatusCode::CREATED, Json(config)).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update configuration"; + admin_only(&user)?; + let mut data = insert_schema() + .partial() + .parse(&body) + .map_err(|_| failure(FAILED))?; + let id = int_id(&id, FAILED).map_err(|_| failure(FAILED))?; + encrypt_token(&mut data, &state.config.encryption_key); + let config = db::update_by_id::(&state.db, id, &data, true) + .await + .map_err(|error| { + tracing::error!(%error, "{FAILED}"); + failure(FAILED) + })?; + let key = &state.config.encryption_key; + Ok(json_or_empty( + config.map(|config| nocodb::decrypt_config(config, key)), + )) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete configuration"; + admin_only(&user)?; + let id = int_id(&id, FAILED).map_err(|_| failure(FAILED))?; + db::delete_by_id::(&state.db, id) + .await + .map_err(|error| { + tracing::error!(%error, "{FAILED}"); + failure(FAILED) + })?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +async fn active(State(state): State, _user: CurrentUser) -> ApiResult { + let config = nocodb::active_config(&state).await; + Ok(Json(config).into_response()) +} diff --git a/server-rs/src/modules/orders.rs b/server-rs/src/modules/orders.rs new file mode 100644 index 0000000..e47bbed --- /dev/null +++ b/server-rs/src/modules/orders.rs @@ -0,0 +1,467 @@ +// Commandes fournisseurs : /api/orders, /api/sync-order-delivery-status +// (server/routes.ts, « Orders routes » ; storage.ts getOrders à deleteOrder). +// +// Formes de Node reproduites : +// - liste : champs dans l'ordre du select de Drizzle (statut avant quantité), +// fournisseur complet, magasin réduit (groupReconciliationColumns), créateur +// réduit, puis les livraisons de chaque commande ; +// - GET /:id : magasin complet (adresse, logo, configuration NocoDB et SMTP, le +// mot de passe SMTP remplacé par smtpPasswordSet). +// +// Défauts de Node reproduits : +// - un directeur ou un manager sans ?storeId ne voit que son premier magasin ; +// un employé sans ?storeId ne voit rien ; +// - PUT accepte groupId et createdBy (schéma d'insertion partiel) : une commande +// peut être déplacée vers un magasin de l'utilisateur ou non, et changer de +// créateur ; un corps invalide donne un 500 (l'erreur zod n'est pas renvoyée) ; +// - POST /api/sync-order-delivery-status lit les commandes de tous les magasins. + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{Map, Value, json}; +use sqlx::{PgPool, Postgres, QueryBuilder}; + +use crate::AppState; +use crate::access::has_permission; +use crate::auth::CurrentUser; +use crate::db::{self, columns_of}; +use crate::error::{ApiResult, AppError, OrInternal}; +use crate::js; +use crate::json::{spread, strip_smtp_password}; +use crate::modules::deliveries::{self, DeliveryItem, fields_in_order, group_reconciliations}; +use crate::projections::{GroupReconciliation, UserSummary}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{GroupsRow, OrdersRow, SuppliersRow}; + +pub fn routes() -> Router { + Router::new() + .route("/api/orders", get(list).post(create)) + .route("/api/orders/{id}", get(show).put(update).delete(remove)) + .route("/api/sync-order-delivery-status", post(sync_statuses)) +} + +/// Champs d'une commande dans l'ordre du select de getOrders / getOrder +const ORDER_FIELDS: &[&str] = &[ + "id", + "supplierId", + "groupId", + "plannedDate", + "status", + "quantity", + "unit", + "notes", + "createdBy", + "createdAt", + "updatedAt", +]; + +/// Jointures de getOrders, loadOrdersByIds et getOrder +const ORDER_JOINS: &str = "FROM orders + LEFT JOIN suppliers ON orders.supplier_id = suppliers.id + LEFT JOIN groups ON orders.group_id = groups.id + LEFT JOIN users ON orders.created_by = users.id"; + +/// Début de la requête jointe de Drizzle : les colonnes de la commande, et +/// celles des tables jointes qui gardent les jointures (même plan que Node, +/// donc même ordre des lignes à égalité de tri) +fn order_select() -> String { + format!( + "SELECT {}, suppliers.id AS joined_supplier_id, groups.id AS joined_group_id, users.id AS joined_user_id {ORDER_JOINS}", + columns_of::("orders") + ) +} + +/// Commande d'une liste, avec ses relations (fournisseur, magasin réduit, créateur) +#[derive(Debug, Clone)] +pub struct OrderItem { + pub row: OrdersRow, + pub supplier: Option, + pub group: Option, + pub creator: Option, +} + +impl OrderItem { + pub fn to_json(&self) -> Map { + let mut out = fields_in_order(&spread(&self.row), ORDER_FIELDS); + out.insert("supplier".into(), json!(self.supplier)); + out.insert("group".into(), json!(self.group)); + out.insert("creator".into(), json!(self.creator)); + out + } +} + +/// Lignes de la requête jointe, complétées par leurs relations (lues par identifiant) +async fn joined_orders( + db: &PgPool, + mut sql: QueryBuilder<'_, Postgres>, +) -> Result, sqlx::Error> { + let rows: Vec = sql.build_query_as().fetch_all(db).await?; + let supplier_ids: Vec = rows.iter().map(|row| row.supplier_id).collect(); + let suppliers: HashMap = db::find_by_ids::(db, &supplier_ids) + .await? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = rows.iter().map(|row| row.group_id).collect(); + let groups = group_reconciliations(db, &group_ids).await?; + let creator_ids: Vec = rows.iter().map(|row| row.created_by.clone()).collect(); + let creators = UserSummary::by_ids(db, &creator_ids).await?; + Ok(rows + .into_iter() + .map(|row| OrderItem { + supplier: suppliers.get(&row.supplier_id).cloned(), + group: groups.get(&row.group_id).cloned(), + creator: creators.get(&row.created_by).cloned(), + row, + }) + .collect()) +} + +/// loadOrdersByIds : commandes jointes, par identifiant +pub async fn orders_by_ids(db: &PgPool, ids: &[i32]) -> Result, sqlx::Error> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + let mut sql = QueryBuilder::new(order_select()); + sql.push(" WHERE orders.id = ANY(") + .push_bind(ids.to_vec()) + .push(")"); + Ok(joined_orders(db, sql) + .await? + .into_iter() + .map(|order| (order.row.id, order)) + .collect()) +} + +/// Période demandée (startDate, endDate), comparée à la date prévue +type DateRange<'a> = Option<(&'a str, &'a str)>; + +/// getOrders / getOrdersByDateRange : commandes (de ces magasins si la liste +/// n'est pas vide) et leurs livraisons +async fn get_orders( + db: &PgPool, + group_ids: &[i32], + range: DateRange<'_>, +) -> Result)>, sqlx::Error> { + let mut sql = QueryBuilder::new(order_select()); + let mut conditions = 0; + if let Some((start, end)) = range { + sql.push(" WHERE orders.planned_date >= ") + .push_bind(start.to_string()) + .push("::date AND orders.planned_date <= ") + .push_bind(end.to_string()) + .push("::date"); + conditions += 1; + } + if !group_ids.is_empty() { + sql.push(if conditions == 0 { " WHERE " } else { " AND " }) + .push("orders.group_id = ANY(") + .push_bind(group_ids.to_vec()) + .push(")"); + } + sql.push(if range.is_some() { + " ORDER BY orders.planned_date DESC" + } else { + " ORDER BY orders.created_at DESC" + }); + let orders = joined_orders(db, sql).await?; + let ids: Vec = orders.iter().map(|order| order.row.id).collect(); + let mut deliveries = deliveries::deliveries_by_order_ids(db, &ids).await?; + Ok(orders + .into_iter() + .map(|order| { + let list = deliveries.remove(&order.row.id).unwrap_or_default(); + (order, list) + }) + .collect()) +} + +/// Commande de getOrder : magasin complet et livraisons associées +#[derive(Debug, Clone)] +pub struct OrderDetail { + pub row: OrdersRow, + supplier: Option, + group: Option, + creator: Option, + deliveries: Vec, +} + +impl OrderDetail { + /// Forme de Node, avant le masquage du mot de passe SMTP + pub fn to_json(&self) -> Value { + let mut out = fields_in_order(&spread(&self.row), ORDER_FIELDS); + out.insert("supplier".into(), json!(self.supplier)); + out.insert("group".into(), json!(self.group)); + out.insert("creator".into(), json!(self.creator)); + out.insert( + "deliveries".into(), + Value::Array( + self.deliveries + .iter() + .map(|item| Value::Object(item.to_json())) + .collect(), + ), + ); + Value::Object(out) + } +} + +/// storage.getOrder +pub async fn get_order(db: &PgPool, id: i32) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, OrdersRow>(&format!("{} WHERE orders.id = $1", order_select())) + .bind(id) + .fetch_optional(db) + .await?; + let deliveries = deliveries::deliveries_of_order(db, id).await?; + let Some(row) = row else { + return Ok(None); + }; + let supplier = db::find_by_id::(db, row.supplier_id).await?; + let group = db::find_by_id::(db, row.group_id).await?; + let creator = UserSummary::by_ids(db, std::slice::from_ref(&row.created_by)) + .await? + .remove(&row.created_by); + Ok(Some(OrderDetail { + row, + supplier, + group, + creator, + deliveries, + })) +} + +/// Magasins lus par GET /api/orders et GET /api/deliveries, ou None quand Node +/// renvoie directement une liste vide. Admin : ?storeId s'il est donné (parseInt, +/// un texte illisible fait échouer la requête : Err) ; sinon ?storeId s'il est +/// l'un des siens, ou le premier de ses magasins pour un directeur ou un manager. +pub fn listed_groups(user: &CurrentUser, query: &JsQuery) -> Result>, ()> { + let store = query.truthy("storeId"); + if user.is_admin() { + return match store { + Some(store) => db::parse_int4(store).map(|id| Some(vec![id])).ok_or(()), + None => Ok(Some(Vec::new())), + }; + } + let mine = user.group_ids(); + if let Some(store) = store { + let requested = js::parse_int(store); + return Ok(mine + .iter() + .find(|id| Some(f64::from(**id)) == requested) + .map(|id| vec![*id])); + } + if matches!(user.role(), "directeur" | "manager") { + return Ok(mine.first().map(|first| vec![*first])); + } + Ok(None) +} + +/// startDate et endDate, tous deux renseignés +pub fn date_range(query: &JsQuery) -> DateRange<'_> { + query.truthy("startDate").zip(query.truthy("endDate")) +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch orders"; + let Some(group_ids) = listed_groups(&user, &query).map_err(|_| AppError::internal(FAILED))? else { + return Ok(Json(json!([])).into_response()); + }; + let orders = get_orders(&state.db, &group_ids, date_range(&query)) + .await + .or_500(FAILED)?; + let out: Vec = orders + .into_iter() + .map(|(order, deliveries)| { + let mut entry = order.to_json(); + entry.insert( + "deliveries".into(), + Value::Array( + deliveries + .iter() + .map(|item| Value::Object(item.to_json())) + .collect(), + ), + ); + Value::Object(entry) + }) + .collect(); + Ok(Json(out).into_response()) +} + +/// Hors admin : la commande doit être d'un magasin de l'utilisateur +fn check_group(user: &CurrentUser, group_id: i32) -> Result<(), AppError> { + if user.is_admin() || user.group_ids().contains(&group_id) { + Ok(()) + } else { + Err(AppError::forbidden("Access denied")) + } +} + +/// Commande existante, ou 404 ; erreur de lecture (identifiant illisible) : 500 +async fn existing_order(db: &PgPool, raw_id: &str, failed: &str) -> Result { + let id = int_id(raw_id, failed)?; + get_order(db, id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Order not found")) +} + +async fn show(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + let order = existing_order(&state.db, &id, "Failed to fetch order").await?; + check_group(&user, order.row.group_id)?; + Ok(Json(strip_smtp_password(order.to_json())).into_response()) +} + +/// insertOrderSchema : createInsertSchema(orders) sans id, createdAt, updatedAt +fn order_schema() -> crate::zod::Object { + OrdersRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +/// { ...req.body, champs } : un tableau devient un objet à clés numériques (que +/// le schéma ignore), toute autre valeur un objet vide +pub fn spread_body(body: &Value) -> Map { + match body { + Value::Object(fields) => fields.clone(), + Value::Array(items) => items + .iter() + .enumerate() + .map(|(index, item)| (index.to_string(), item.clone())) + .collect(), + _ => Map::new(), + } +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create order"; + let mut input = spread_body(&body); + input.insert("createdBy".into(), Value::String(user.id().to_string())); + let data = order_schema() + .parse(&Value::Object(input)) + .map_err(|issues| AppError::validation("Validation failed", issues))?; + let group_id = data.get("groupId").and_then(Value::as_i64); + if !user.is_admin() && !user.group_ids().iter().any(|id| Some(i64::from(*id)) == group_id) { + return Err(AppError::forbidden("Access denied to this group")); + } + let order = db::insert_values_first::(&state.db, &data) + .await + .or_500(FAILED)?; + Ok(Json(order).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update order"; + let order = existing_order(&state.db, &id, FAILED).await?; + if !has_permission(user.role(), "orders", "edit") { + return Err(AppError::forbidden("Insufficient permissions to edit orders")); + } + check_group(&user, order.row.group_id)?; + // ZodError interceptée comme toute autre erreur : 500 + let data = order_schema() + .partial() + .parse(&body) + .map_err(|_| AppError::internal(FAILED))?; + let updated = db::update_by_id::(&state.db, order.row.id, &data, true) + .await + .or_500(FAILED)?; + Ok(crate::error::json_or_empty(updated)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete order"; + let order = existing_order(&state.db, &id, FAILED).await?; + if !has_permission(user.role(), "orders", "delete") { + return Err(AppError::forbidden("Insufficient permissions to delete orders")); + } + check_group(&user, order.row.group_id)?; + // Une commande qui a des livraisons ne peut pas être supprimée (clé étrangère) : 500 + db::delete_by_id::(&state.db, order.row.id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Order deleted successfully" })).into_response()) +} + +/// updateOrder(id, { status }) : la ligne mise à jour, si elle existe +pub async fn set_order_status(db: &PgPool, id: i32, status: &str) -> Result, sqlx::Error> { + let mut data = Map::new(); + data.insert("status".into(), Value::String(status.to_string())); + db::update_by_id::(db, id, &data, true).await +} + +/// Commandes qui ont une livraison livrée sans être elles-mêmes livrées : passées +/// à « delivered », une par une, dans l'ordre de la liste +async fn sync_statuses(State(state): State, user: CurrentUser) -> ApiResult { + if !user.is_admin() { + return Err(AppError::forbidden("Access denied")); + } + let orders = get_orders(&state.db, &[], None).await.map_err(|error| { + tracing::error!(%error, "synchronisation des statuts"); + AppError::with_body( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Failed to synchronize statuses", "error": error.to_string() }), + ) + })?; + let mut problematic = Vec::new(); + let mut fixed = Vec::new(); + for (order, deliveries) in &orders { + let delivered = deliveries + .iter() + .filter(|item| item.row.status == "delivered") + .count(); + if delivered == 0 || order.row.status == "delivered" { + continue; + } + problematic.push(json!({ + "orderId": order.row.id, + "currentStatus": order.row.status, + "deliveredDeliveries": delivered, + "totalDeliveries": deliveries.len(), + })); + match set_order_status(&state.db, order.row.id, "delivered").await { + Ok(_) => fixed.push(order.row.id), + Err(error) => tracing::error!(%error, order = order.row.id, "synchronisation du statut"), + } + } + Ok(Json(json!({ + "message": "Synchronization completed", + "diagnostics": { + "problematicOrdersFound": problematic.len(), + "ordersFixed": fixed.len(), + "problematicOrders": problematic, + "fixedOrders": fixed, + }, + })) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corps_etale_comme_javascript() { + assert_eq!( + Value::Object(spread_body(&json!(["a", 2]))), + json!({ "0": "a", "1": 2 }) + ); + assert_eq!(Value::Object(spread_body(&json!({ "x": 1 }))), json!({ "x": 1 })); + } + + #[test] + fn schema_de_commande() { + let issues = order_schema().parse(&json!({ "createdBy": "u" })).unwrap_err(); + let paths: Vec<&Value> = issues.iter().map(|issue| &issue["path"][0]).collect(); + assert_eq!( + paths, + [&json!("supplierId"), &json!("groupId"), &json!("plannedDate")] + ); + } +} diff --git a/server-rs/src/modules/payment_schedule.rs b/server-rs/src/modules/payment_schedule.rs new file mode 100644 index 0000000..1274362 --- /dev/null +++ b/server-rs/src/modules/payment_schedule.rs @@ -0,0 +1,656 @@ +// Échéancier des paiements fournisseurs : GET /api/payment-schedule et son export +// CSV, POST /api/payment-schedule/export (server/routes.ts), réservés à l'admin et +// au directeur (ses magasins seulement). +// +// La lecture complète les livraisons dans NocoDB (vérification des factures, avec +// son cache) : échéance absente → échéance et montant HT trouvés écrits en base ; +// montant TTC absent ou nul → montant HT (et échéance brute) réécrits. +// +// Défauts de Node reproduits : +// - une lecture (GET) écrit en base, une livraison à la fois (ECHE-01) ; +// - le repli « TTC » n'écrit jamais le TTC : il réécrit le HT et l'échéance de +// NocoDB, et recommence à chaque lecture ; la réponse garde l'échéance d'avant ; +// - l'export n'échappe pas les champs du CSV, et une erreur de validation des +// paramètres donne un 500 (message de ZodError). + +use std::collections::HashMap; + +use axum::extract::State; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use serde_json::{Value, json}; +use sqlx::PgPool; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db; +use crate::error::{ApiResult, AppError}; +use crate::http::express_etag; +use crate::js; +use crate::request::{JsQuery, JsonBody}; +use crate::schema::{DeliveriesRow, GroupsRow}; +use crate::services::invoice_verification::{self as verification, JsField, Verification, js_number_text}; +use crate::services::nocodb::{self, truthy}; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/payment-schedule", get(schedule)) + .route("/api/payment-schedule/export", post(export)) +} + +fn error_body(status: StatusCode, error: &str) -> AppError { + AppError::with_body(status, json!({ "error": error })) +} + +fn admin_or_directeur(user: &CurrentUser) -> Result<(), AppError> { + if ["admin", "directeur"].contains(&user.role()) { + Ok(()) + } else { + Err(error_body( + StatusCode::FORBIDDEN, + "Accès refusé - Admin ou Directeur uniquement", + )) + } +} + +/// Le directeur ne lit que ses magasins +fn check_directeur_store(user: &CurrentUser, group_id: f64) -> Result<(), AppError> { + if user.role() == "directeur" && !user.group_ids().iter().any(|id| f64::from(*id) == group_id) { + return Err(error_body( + StatusCode::FORBIDDEN, + "Accès refusé - Vous ne pouvez accéder qu'aux données de votre groupe", + )); + } + Ok(()) +} + +// ---------- Validation (zod, schémas isolés : chemin vide) ---------- + +/// Type d'une valeur selon zod (getParsedType) +fn received(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +/// invalid_type de zod : « Required » pour une valeur absente +fn invalid_type(expected: &str, value: Option<&Value>, path: Value) -> Value { + let received = received(value); + let message = if value.is_none() { + "Required".to_string() + } else { + format!("Expected {expected}, received {received}") + }; + json!({ "code": "invalid_type", "expected": expected, "received": received, "path": path, "message": message }) +} + +/// z.coerce.number().int().positive() : Number(valeur), entier strictement positif +fn coerce_positive_int(value: Option<&Value>) -> Result> { + let number = value.map_or(f64::NAN, js::to_number); + if number.is_nan() { + return Err(vec![json!({ + "code": "invalid_type", "expected": "number", "received": "nan", "path": [], + "message": "Expected number, received nan", + })]); + } + let mut issues = Vec::new(); + if number.fract() != 0.0 || !number.is_finite() { + issues.push(json!({ + "code": "invalid_type", "expected": "integer", "received": "float", + "message": "Expected integer, received float", "path": [], + })); + } + if number <= 0.0 { + issues.push(json!({ + "code": "too_small", "minimum": 0, "type": "number", "inclusive": false, "exact": false, + "message": "Number must be greater than 0", "path": [], + })); + } + if issues.is_empty() { + Ok(number) + } else { + Err(issues) + } +} + +/// z.string().regex(/^\d{4}-\d{2}$/) +fn month_string(value: Option<&Value>) -> Result> { + let Some(Value::String(text)) = value else { + return Err(vec![invalid_type("string", value, json!([]))]); + }; + let bytes = text.as_bytes(); + let matches = bytes.len() == 7 + && bytes[4] == b'-' + && bytes + .iter() + .enumerate() + .all(|(i, b)| i == 4 || b.is_ascii_digit()); + if matches { + Ok(text.clone()) + } else { + Err(vec![ + json!({ "validation": "regex", "code": "invalid_string", "message": "Invalid", "path": [] }), + ]) + } +} + +/// z.array(z.string()).min(1) : longueur contrôlée avant les éléments +fn payment_methods(value: Option<&Value>) -> Result, Vec> { + let Some(Value::Array(items)) = value else { + return Err(vec![invalid_type("array", value, json!([]))]); + }; + let mut issues = Vec::new(); + if items.is_empty() { + issues.push(json!({ + "code": "too_small", "minimum": 1, "type": "array", "inclusive": true, "exact": false, + "message": "Array must contain at least 1 element(s)", "path": [], + })); + } + let mut methods = Vec::new(); + for (index, item) in items.iter().enumerate() { + match item.as_str() { + Some(text) => methods.push(text.to_string()), + None => issues.push(invalid_type("string", Some(item), json!([index]))), + } + } + if issues.is_empty() { + Ok(methods) + } else { + Err(issues) + } +} + +// ---------- Livraisons lues (storage.getDeliveries([groupId])) ---------- + +/// Date JavaScript : valide (à la milliseconde) ou « Invalid Date » (vraie, écrite null) +#[derive(Debug, Clone, Copy, PartialEq)] +enum JsDate { + Valid(DateTime), + Invalid, +} + +impl JsDate { + fn of(date: Option>) -> Self { + date.map_or(JsDate::Invalid, JsDate::Valid) + } + + /// Colonne timestamp lue par node-pg : Date à la milliseconde + fn from_column(value: NaiveDateTime) -> Self { + let millis = Utc.from_utc_datetime(&value).timestamp_millis(); + JsDate::of(Utc.timestamp_millis_opt(millis).single()) + } + + fn to_json(self) -> Value { + match self { + JsDate::Valid(date) => Value::String(js::iso(&date)), + JsDate::Invalid => Value::Null, + } + } +} + +#[derive(Debug, sqlx::FromRow)] +struct DeliveryRow { + id: i32, + supplier_id: i32, + invoice_reference: Option, + /// Montants en texte, tels que node-pg les lit ("1250.50") + invoice_amount: Option, + invoice_amount_ttc: Option, + due_date: Option, + reconciled: Option, +} + +/// Livraison telle que le gestionnaire Node la garde en mémoire et la complète +struct Line { + id: i32, + supplier_id: i32, + invoice_reference: String, + invoice_amount: Option, + invoice_amount_ttc: Option, + due_date: Option, + reconciled: bool, +} + +/// Livraisons du magasin avec une référence de facture, par date de création +/// décroissante (mêmes jointures que Node) +async fn invoiced_deliveries(db: &PgPool, group_id: i32) -> Result, sqlx::Error> { + let rows: Vec = sqlx::query_as( + "SELECT d.id, d.supplier_id, d.invoice_reference, d.invoice_amount::text AS invoice_amount, + d.invoice_amount_ttc::text AS invoice_amount_ttc, d.due_date, d.reconciled + FROM deliveries d + LEFT JOIN suppliers s ON d.supplier_id = s.id + LEFT JOIN groups g ON d.group_id = g.id + LEFT JOIN users u ON d.created_by = u.id + WHERE d.group_id = $1 + ORDER BY d.created_at DESC", + ) + .bind(group_id) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| { + let reference = row.invoice_reference.filter(|reference| !reference.is_empty())?; + Some(Line { + id: row.id, + supplier_id: row.supplier_id, + invoice_reference: reference, + invoice_amount: row.invoice_amount, + invoice_amount_ttc: row.invoice_amount_ttc, + due_date: row.due_date.map(JsDate::from_column), + reconciled: row.reconciled == Some(true), + }) + }) + .collect()) +} + +/// Nom et mode de paiement des fournisseurs (storage.getSuppliers) +async fn suppliers(db: &PgPool) -> Result)>, sqlx::Error> { + let rows: Vec<(i32, String, Option)> = + sqlx::query_as("SELECT id, name, payment_method FROM suppliers ORDER BY name") + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map(|(id, name, method)| (id, (name, method))) + .collect()) +} + +/// Texte d'une valeur vraie (`valeur || repli`) +fn or_default(value: &Option) -> Option<&str> { + value.as_deref().filter(|text| !text.is_empty()) +} + +/// `montant ? parseFloat(montant) : 0` +fn amount(value: &Option) -> f64 { + or_default(value).map_or(0.0, js::parse_float) +} + +/// Montant trouvé « vrai » (ni absent, ni 0, ni NaN) +fn found_amount(result: &Verification) -> Option { + result + .invoice_amount + .get() + .copied() + .filter(|amount| *amount != 0.0 && !amount.is_nan()) +} + +/// storage.updateDelivery : erreur journalisée, comme le catch de Node +async fn update_delivery(db: &PgPool, id: i32, data: &Parsed) -> bool { + match db::update_by_id::(db, id, data, true).await { + Ok(_) => true, + Err(error) => { + tracing::error!(%error, id, "échéancier : mise à jour de la livraison"); + false + } + } +} + +/// Échéance absente : échéance (normalisée) et montant HT manquant pris dans NocoDB +async fn complete_due_date(state: &AppState, group_id: i32, line: &mut Line) { + let result = + verification::verify_invoice(state, &line.invoice_reference, group_id, false, line.reconciled).await; + let mut data = Parsed::new(); + // new Date(échéance invalide) : la mise à jour échoue (toISOString), la + // livraison garde en mémoire sa date invalide et son montant + let mut writable = true; + if result.exists { + if let Some(normalized) = result.normalized_due_date() { + let date = JsDate::of(crate::services::dates::parse_js_date(&normalized)); + match date { + JsDate::Valid(date) => { + data.insert("dueDate".into(), json!(js::iso(&date))); + } + JsDate::Invalid => writable = false, + } + line.due_date = Some(date); + } + } + if let Some(found) = found_amount(&result).filter(|_| result.exists) { + if or_default(&line.invoice_amount).is_none() { + let text = js_number_text(found); + data.insert("invoiceAmount".into(), json!(text)); + line.invoice_amount = Some(text); + } + } + if !data.is_empty() && writable { + update_delivery(&state.db, line.id, &data).await; + } +} + +/// Montant TTC absent ou nul : réécrit le HT (et l'échéance brute) de NocoDB, sans +/// jamais écrire le TTC +async fn complete_amount(state: &AppState, group_id: i32, line: &mut Line) { + let result = + verification::verify_invoice(state, &line.invoice_reference, group_id, false, line.reconciled).await; + let Some(found) = found_amount(&result).filter(|_| result.exists) else { + return; + }; + let text = js_number_text(found); + let mut data = Parsed::new(); + data.insert("invoiceAmount".into(), json!(text)); + if let JsField::Set(due) = &result.due_date { + if truthy(due) { + match crate::services::dates::js_date(due) { + Some(date) => { + data.insert("dueDate".into(), json!(js::iso(&date))); + } + // new Date(échéance) invalide : toISOString lève avant la requête + None => return, + } + } + } + if update_delivery(&state.db, line.id, &data).await { + line.invoice_amount = Some(text); + } +} + +async fn schedule(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let server_error = |details: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "Erreur serveur", "details": details }), + ) + }; + admin_or_directeur(&user)?; + let raw = query.get("groupId").map(|text| Value::String(text.to_string())); + let Ok(group_id) = coerce_positive_int(raw.as_ref()) else { + return Err(error_body( + StatusCode::BAD_REQUEST, + "groupId invalide - doit être un entier positif", + )); + }; + check_directeur_store(&user, group_id)?; + let Some(group) = load_group(&state.db, group_id).await.map_err(server_error)? else { + return Ok(Json(json!({ "schedules": [], "message": "Groupe non trouvé" })).into_response()); + }; + + let lines = invoiced_deliveries(&state.db, group.id) + .await + .map_err(|error| server_error(nocodb::database_message(&error)))?; + let (mut dated, mut undated): (Vec, Vec) = + lines.into_iter().partition(|line| line.due_date.is_some()); + for line in &mut undated { + complete_due_date(&state, group.id, line).await; + } + dated.extend(undated.into_iter().filter(|line| line.due_date.is_some())); + for line in dated + .iter_mut() + .filter(|line| or_default(&line.invoice_amount_ttc).is_none_or(|ttc| js::parse_float(ttc) == 0.0)) + { + complete_amount(&state, group.id, line).await; + } + + let suppliers = suppliers(&state.db) + .await + .map_err(|error| server_error(nocodb::database_message(&error)))?; + let schedules: Vec = dated + .iter() + .map(|line| { + let supplier = suppliers.get(&line.supplier_id); + json!({ + "id": line.id, + "invoiceReference": line.invoice_reference, + "dueDate": line.due_date.map_or(Value::Null, JsDate::to_json), + "amount": js::number_value(amount(&line.invoice_amount)), + "amountTTC": js::number_value(amount(&line.invoice_amount_ttc)), + "supplierName": supplier + .map(|(name, _)| name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Fournisseur inconnu"), + "paymentMethod": supplier.and_then(|(_, method)| or_default(method)), + "groupId": group.id, + "groupName": group.name, + }) + }) + .collect(); + Ok(Json(json!({ "schedules": schedules })).into_response()) +} + +/// storage.getGroup avec l'identifiant validé, envoyé en texte comme node-pg (un +/// entier hors limites fait échouer la requête) +async fn load_group(db: &PgPool, group_id: f64) -> Result, String> { + nocodb::group_by_text_id(db, Some(&js::number_to_string(group_id))).await +} + +// ---------- Export CSV ---------- + +/// Number.prototype.toFixed(2) : arrondi de la valeur binaire exacte, les égalités +/// parfaites (x,xx5 exactement représentable) vers le haut +fn to_fixed_2(number: f64) -> String { + if number.is_nan() { + return "NaN".into(); + } + if number.abs() >= 1e21 { + return js::number_string(number); + } + if number < 0.0 { + return format!("-{}", to_fixed_2(-number)); + } + // -0 n'est pas négatif pour toFixed + let number = number.abs(); + let eighths = number * 8.0; + if eighths.fract() == 0.0 && eighths % 2.0 == 1.0 { + // x = k/8 (k impair) : x × 100 se termine exactement par ,5 + let cents = (number * 100.0 + 0.5).floor() as u128; + return format!("{}.{:02}", cents / 100, cents % 100); + } + format!("{number:.2}") +} + +/// Premier et dernier instant du mois (parseISO puis endOfMonth de date-fns, en +/// heure locale : UTC), None pour un mois invalide (Invalid Date : aucune ligne) +fn month_bounds(month: &str) -> Option<(DateTime, DateTime)> { + let year: i32 = month[..4].parse().ok()?; + let number: u32 = month[5..].parse().ok()?; + let first = NaiveDate::from_ymd_opt(year, number, 1)?; + let next = if number == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1)? + } else { + NaiveDate::from_ymd_opt(year, number + 1, 1)? + }; + let start = Utc.from_utc_datetime(&first.and_hms_opt(0, 0, 0)?); + let end = Utc.from_utc_datetime(&next.and_hms_opt(0, 0, 0)?) - Duration::milliseconds(1); + Some((start, end)) +} + +struct ExportLine { + due_date: DateTime, + supplier_name: String, + invoice_reference: String, + payment_method: String, + amount_ht: f64, + amount_ttc: f64, +} + +async fn export(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + let failed = |details: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "Erreur lors de l'export", "details": details }), + ) + }; + let zod_failed = |issues: Vec| failed(serde_json::to_string_pretty(&issues).unwrap_or_default()); + admin_or_directeur(&user)?; + let group_id = coerce_positive_int(body.get("groupId")).map_err(zod_failed)?; + let month = month_string(body.get("month")).map_err(zod_failed)?; + let methods = payment_methods(body.get("paymentMethods")).map_err(zod_failed)?; + let include_ht = body.get("includeHT").is_some_and(truthy); + let include_ttc = body.get("includeTTC").is_some_and(truthy); + check_directeur_store(&user, group_id)?; + let Some(group) = load_group(&state.db, group_id).await.map_err(failed)? else { + return Err(error_body(StatusCode::NOT_FOUND, "Groupe non trouvé")); + }; + + let lines = invoiced_deliveries(&state.db, group.id) + .await + .map_err(|error| failed(nocodb::database_message(&error)))?; + let bounds = month_bounds(&month); + let suppliers = suppliers(&state.db) + .await + .map_err(|error| failed(nocodb::database_message(&error)))?; + let mut schedules: Vec = lines + .iter() + .filter_map(|line| match (line.due_date, bounds) { + (Some(JsDate::Valid(due)), Some((start, end))) if start <= due && due <= end => Some((line, due)), + _ => None, + }) + .map(|(line, due_date)| { + let supplier = suppliers.get(&line.supplier_id); + ExportLine { + due_date, + supplier_name: supplier + .map(|(name, _)| name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Fournisseur inconnu".into()), + invoice_reference: line.invoice_reference.clone(), + payment_method: supplier + .and_then(|(_, method)| or_default(method)) + .unwrap_or("Non défini") + .to_string(), + amount_ht: amount(&line.invoice_amount), + amount_ttc: amount(&line.invoice_amount_ttc), + } + }) + .filter(|line| methods.contains(&line.payment_method)) + .collect(); + // Tri stable par échéance + schedules.sort_by_key(|line| line.due_date); + + // CSV au point-virgule (Excel en français), champs non échappés + let mut headers = vec!["Date d'échéance", "Fournisseur", "Facture", "Mode de paiement"]; + if include_ht { + headers.push("Montant HT"); + } + if include_ttc { + headers.push("Montant TTC"); + } + let mut csv_lines = vec![headers.join(";")]; + for line in &schedules { + let mut row = vec![ + format!( + "{:02}/{:02}/{:04}", + line.due_date.day(), + line.due_date.month(), + line.due_date.year() + ), + line.supplier_name.clone(), + line.invoice_reference.clone(), + line.payment_method.clone(), + ]; + if include_ht { + row.push(to_fixed_2(line.amount_ht)); + } + if include_ttc { + row.push(to_fixed_2(line.amount_ttc)); + } + csv_lines.push(row.join(";")); + } + let mut total = vec![String::new(), String::new(), String::new(), "TOTAL".to_string()]; + if include_ht { + total.push(to_fixed_2( + schedules.iter().fold(0.0, |sum, line| sum + line.amount_ht), + )); + } + if include_ttc { + total.push(to_fixed_2( + schedules.iter().fold(0.0, |sum, line| sum + line.amount_ttc), + )); + } + csv_lines.push(total.join(";")); + // BOM UTF-8 pour Excel + let content = format!("\u{FEFF}{}", csv_lines.join("\n")); + tracing::info!( + count = schedules.len(), + group = group.name, + "export CSV de l'échéancier" + ); + + // res.send : ETag calculé sur le corps + let etag = express_etag(content.as_bytes()); + let mut response = content.into_response(); + let response_headers = response.headers_mut(); + response_headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/csv; charset=utf-8"), + ); + if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"echeancier_{month}.csv\"")) { + response_headers.insert(header::CONTENT_DISPOSITION, value); + } + if let Ok(value) = HeaderValue::from_str(&etag) { + response_headers.insert(header::ETAG, value); + } + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_fixed_comme_javascript() { + // Valeurs de Number.prototype.toFixed(2) dans Node + assert_eq!(to_fixed_2(0.125), "0.13"); + assert_eq!(to_fixed_2(0.375), "0.38"); + assert_eq!(to_fixed_2(2.5), "2.50"); + assert_eq!(to_fixed_2(1.005), "1.00"); + assert_eq!(to_fixed_2(0.1 + 0.2), "0.30"); + assert_eq!(to_fixed_2(1250.5), "1250.50"); + assert_eq!(to_fixed_2(-45.9), "-45.90"); + assert_eq!(to_fixed_2(-0.001), "-0.00"); + assert_eq!(to_fixed_2(-0.0), "0.00"); + assert_eq!(to_fixed_2(10.125), "10.13"); + } + + #[test] + fn identifiant_de_magasin_comme_zod() { + assert_eq!(coerce_positive_int(Some(&json!(" 2 "))), Ok(2.0)); + assert_eq!(coerce_positive_int(Some(&json!(true))), Ok(1.0)); + let issues = coerce_positive_int(Some(&json!("-1.5"))).unwrap_err(); + assert_eq!(issues.len(), 2); + assert_eq!( + serde_json::to_string(&coerce_positive_int(None).unwrap_err()).unwrap(), + r#"[{"code":"invalid_type","expected":"number","received":"nan","path":[],"message":"Expected number, received nan"}]"# + ); + } + + #[test] + fn parametres_d_export_comme_zod() { + assert_eq!(month_string(Some(&json!("2026-10"))), Ok("2026-10".to_string())); + assert_eq!(month_string(None).unwrap_err()[0]["message"], json!("Required")); + assert_eq!( + month_string(Some(&json!("2026-1"))).unwrap_err()[0]["validation"], + json!("regex") + ); + let issues = payment_methods(Some(&json!(["a", 5, null]))).unwrap_err(); + assert_eq!(issues[0]["path"], json!([1])); + assert_eq!(issues[1]["message"], json!("Expected string, received null")); + assert_eq!( + payment_methods(Some(&json!([]))).unwrap_err()[0]["code"], + json!("too_small") + ); + } + + #[test] + fn bornes_du_mois() { + let (start, end) = month_bounds("2026-02").unwrap(); + assert_eq!(js::iso(&start), "2026-02-01T00:00:00.000Z"); + assert_eq!(js::iso(&end), "2026-02-28T23:59:59.999Z"); + assert_eq!( + js::iso(&month_bounds("2026-12").unwrap().1), + "2026-12-31T23:59:59.999Z" + ); + assert!(month_bounds("2026-13").is_none()); + assert!(month_bounds("2026-00").is_none()); + } +} diff --git a/server-rs/src/modules/publicities.rs b/server-rs/src/modules/publicities.rs new file mode 100644 index 0000000..574e4f6 --- /dev/null +++ b/server-rs/src/modules/publicities.rs @@ -0,0 +1,472 @@ +// Publicités : /api/ad-campaigns (server/routes.ts, « Publicity routes » ; +// storage.getPublicities, getPublicity, createPublicity, updatePublicity, +// deletePublicity, setPublicityParticipations). +// Comme Node aujourd'hui : +// - la liste ignore le magasin et les magasins de l'utilisateur (PUB-01) ; +// - PUT écrit le corps tel quel, sans validation, et aucune écriture n'est +// transactionnelle : une participation refusée laisse la publicité créée ou +// ses anciennes participations effacées (PUB-17) ; +// - /api/ad-campaigns/debug reste exposée (PUB-06). +// Les identifiants et l'année passent à PostgreSQL sous la forme texte que +// node-pg leur donne (String(parseInt(…))), pour les mêmes refus et les mêmes +// messages d'erreur, renvoyés par la suppression et par /debug. + +use std::cmp::Ordering; +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::{Json, Router, routing::get, routing::post}; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use sqlx::PgPool; +use sqlx::postgres::PgArguments; +use sqlx::{Arguments, Postgres}; + +use crate::AppState; +use crate::access::has_permission; +use crate::auth::CurrentUser; +use crate::db::{self, Table, node_pg_text, parse_int4}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::projections::GroupSummary; +use crate::request::{JsQuery, JsonBody}; +use crate::schema::PublicitiesRow; +use crate::zod::Object; + +pub fn routes() -> Router { + Router::new() + .route("/api/ad-campaigns", get(list).post(create)) + .route("/api/ad-campaigns/debug", get(debug)) + .route("/api/ad-campaigns/{id}", get(get_one).put(update).delete(remove)) + .route("/api/ad-campaigns/{id}/delete", post(remove)) +} + +/// insertPublicitySchema +fn insert_schema() -> Object { + PublicitiesRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +/// PublicityWithRelations +#[derive(Serialize)] +struct Publicity { + #[serde(flatten)] + publicity: PublicitiesRow, + participations: Vec, +} + +#[derive(Serialize)] +struct Participation { + #[serde(rename = "publicityId")] + publicity_id: i32, + #[serde(rename = "groupId")] + group_id: i32, + group: Option, +} + +type ParticipationRow = (i32, i32, Option, Option, Option); + +impl From for Participation { + fn from((publicity_id, group_id, id, name, color): ParticipationRow) -> Self { + // Magasin joint par LEFT JOIN : null quand groups.id l'est (Drizzle) + let group = id.map(|id| GroupSummary { + id, + name: name.unwrap_or_default(), + color, + }); + Self { + publicity_id, + group_id, + group, + } + } +} + +const PARTICIPATIONS_SQL: &str = + "SELECT publicity_participations.publicity_id, publicity_participations.group_id, + groups.id, groups.name, groups.color + FROM publicity_participations + LEFT JOIN groups ON publicity_participations.group_id = groups.id"; + +/// Message d'erreur tel que node-pg le transmet (error.message) +fn error_message(error: &sqlx::Error) -> String { + match error { + sqlx::Error::Database(database) => database.message().to_string(), + other => other.to_string(), + } +} + +/// String(parseInt(texte)) : forme sous laquelle Node envoie l'identifiant +fn js_id(raw: &str) -> String { + js::number_string(js::parse_int(raw).unwrap_or(f64::NAN)) +} + +/// parseInt(pubNumber) || 0 : clé du tri numérique fait en JavaScript +fn number_key(pub_number: &str) -> f64 { + js::parse_int(pub_number) + .filter(|number| *number != 0.0) + .unwrap_or(0.0) +} + +/// storage.getPublicities : année facultative (texte de parseInt), tri par N° +/// en SQL puis tri numérique stable comme en JavaScript +async fn publicities(db: &PgPool, year: Option<&str>) -> Result, sqlx::Error> { + let mut rows: Vec = match year { + Some(year) => { + sqlx::query_as(&PublicitiesRow::select( + "WHERE year = $1::int4 ORDER BY pub_number", + )) + .bind(year) + .fetch_all(db) + .await? + } + None => { + sqlx::query_as(&PublicitiesRow::select("ORDER BY pub_number")) + .fetch_all(db) + .await? + } + }; + let participations: Vec = if rows.is_empty() { + Vec::new() + } else { + // inArray de Drizzle : un paramètre par identifiant, sans tri (ordre de la base) + let placeholders: Vec = (1..=rows.len()).map(|i| format!("${i}")).collect(); + let sql = format!( + "{PARTICIPATIONS_SQL} WHERE publicity_participations.publicity_id IN ({})", + placeholders.join(", ") + ); + let mut query = sqlx::query_as::(&sql).persistent(false); + for row in &rows { + query = query.bind(row.id); + } + query.fetch_all(db).await? + }; + rows.sort_by(|a, b| { + number_key(&a.pub_number) + .partial_cmp(&number_key(&b.pub_number)) + .unwrap_or(Ordering::Equal) + }); + // Participations regroupées par publicité, dans l'ordre de la base + let mut by_publicity: HashMap> = HashMap::new(); + for row in participations { + let participation = Participation::from(row); + by_publicity + .entry(participation.publicity_id) + .or_default() + .push(participation); + } + Ok(rows + .into_iter() + .map(|publicity| { + let participations = by_publicity.remove(&publicity.id).unwrap_or_default(); + Publicity { + publicity, + participations, + } + }) + .collect()) +} + +/// storage.getPublicity : identifiant sous sa forme texte JavaScript +async fn publicity(db: &PgPool, id: &str) -> Result, sqlx::Error> { + let Some(publicity) = sqlx::query_as::<_, PublicitiesRow>(&PublicitiesRow::select("WHERE id = $1::int4")) + .bind(id) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + let participations: Vec = sqlx::query_as(&format!( + "{PARTICIPATIONS_SQL} WHERE publicity_participations.publicity_id = $1" + )) + .bind(publicity.id) + .persistent(false) + .fetch_all(db) + .await?; + Ok(Some(Publicity { + publicity, + participations: participations.into_iter().map(Participation::from).collect(), + })) +} + +/// year ? parseInt(year) : undefined, puis if (year) dans getPublicities +fn year_filter(query: &JsQuery) -> Option { + let year = js::parse_int(query.truthy("year")?)?; + (year != 0.0).then(|| js::number_string(year)) +} + +async fn list(State(state): State, _user: CurrentUser, query: JsQuery) -> ApiResult { + // ?storeId et les magasins de l'utilisateur ne filtrent rien (PUB-01) + match publicities(&state.db, year_filter(&query).as_deref()).await { + Ok(list) => Ok(Json(list).into_response()), + Err(error) => { + tracing::error!(%error, "Error fetching publicities"); + Ok((StatusCode::INTERNAL_SERVER_ERROR, Json(json!([]))).into_response()) + } + } +} + +async fn debug(State(state): State, _user: CurrentUser, query: JsQuery) -> ApiResult { + match publicities(&state.db, year_filter(&query).as_deref()).await { + Ok(list) => Ok(Json(list).into_response()), + Err(error) => Err(AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Failed to fetch publicities", "error": error_message(&error) }), + )), + } +} + +async fn get_one(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + let publicity = publicity(&state.db, &js_id(&id)) + .await + .or_500("Failed to fetch publicity")? + .ok_or_else(|| AppError::not_found("Publicity not found"))?; + if !user.is_admin() { + let group_ids = user.group_ids(); + let has_access = publicity + .participations + .iter() + .any(|participation| group_ids.contains(&participation.group_id)); + if !has_access { + return Err(AppError::forbidden("Access denied")); + } + } + Ok(Json(publicity).into_response()) +} + +/// Échec d'une écriture : erreur SQL, ou TypeError de JavaScript sur un corps inattendu +#[derive(Debug)] +enum WriteError { + Sql(sqlx::Error), + TypeError(&'static str), +} + +impl From for WriteError { + fn from(error: sqlx::Error) -> Self { + Self::Sql(error) + } +} + +impl WriteError { + fn into_500(self, message: &str) -> AppError { + match self { + Self::Sql(error) => tracing::error!(%error, "{message}"), + Self::TypeError(reason) => tracing::error!(reason, "{message}"), + } + AppError::internal(message) + } +} + +/// valeur.length (TypeError sur null) +fn js_length(value: &Value) -> Result, WriteError> { + Ok(match value { + Value::Null => { + return Err(WriteError::TypeError( + "Cannot read properties of null (reading 'length')", + )); + } + Value::Array(items) => Some(json!(items.len())), + Value::String(text) => Some(json!(js::length(text))), + Value::Object(fields) => fields.get("length").cloned(), + Value::Bool(_) | Value::Number(_) => None, + }) +} + +/// valeur.length > 0 +fn has_items(value: &Value) -> Result { + Ok(js_length(value)?.is_some_and(|length| js::to_number(&length) > 0.0)) +} + +/// storage.setPublicityParticipations : effacement puis insertion, sans transaction +async fn set_participations(db: &PgPool, publicity_id: i32, group_ids: &Value) -> Result<(), WriteError> { + sqlx::query("DELETE FROM publicity_participations WHERE publicity_id = $1") + .bind(publicity_id) + .execute(db) + .await?; + if !has_items(group_ids)? { + return Ok(()); + } + let Value::Array(group_ids) = group_ids else { + return Err(WriteError::TypeError("groupIds.map is not a function")); + }; + // Une seule insertion de plusieurs lignes, valeurs envoyées comme node-pg + let mut arguments = PgArguments::default(); + let mut rows = Vec::new(); + for (index, group_id) in group_ids.iter().enumerate() { + arguments.add(publicity_id).map_err(sqlx::Error::Encode)?; + arguments + .add(node_pg_text(group_id)) + .map_err(sqlx::Error::Encode)?; + rows.push(format!("(${}, ${}::int4)", 2 * index + 1, 2 * index + 2)); + } + let sql = format!( + "INSERT INTO publicity_participations (publicity_id, group_id) VALUES {}", + rows.join(", ") + ); + sqlx::query_with(&sql, arguments).execute(db).await?; + Ok(()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create publicity"; + if !has_permission(user.role(), "publicity", "create") { + return Err(AppError::forbidden("Insufficient permissions")); + } + // { ...req.body, createdBy } ; une erreur de validation finit en 500 + let mut candidate = body.as_object().cloned().unwrap_or_default(); + candidate.insert("createdBy".into(), json!(user.id())); + let data = insert_schema() + .parse(&Value::Object(candidate)) + .map_err(|_| AppError::internal(FAILED))?; + let created: PublicitiesRow = db::insert(&state.db, &data).await.or_500(FAILED)?; + if let Some(groups) = body + .get("participatingGroups") + .filter(|groups| js::truthy(groups)) + { + let added = match has_items(groups) { + Ok(true) => set_participations(&state.db, created.id, groups).await, + Ok(false) => Ok(()), + Err(error) => Err(error), + }; + added.map_err(|error| error.into_500(FAILED))?; + } + let publicity = publicity(&state.db, &created.id.to_string()) + .await + .or_500(FAILED)?; + Ok(json_or_empty(publicity)) +} + +/// storage.updatePublicity : colonnes du corps telles quelles (sans validation), +/// updatedAt toujours remplacé ; Drizzle lève une TypeError sur createdAt non +/// nul (value.toISOString), avant toute requête +async fn update_columns(db: &PgPool, id: &str, fields: &Map) -> Result<(), WriteError> { + let mut arguments = PgArguments::default(); + let mut sets = Vec::new(); + for col in PublicitiesRow::FIELDS { + let value = if col.key == "updatedAt" { + Some(crate::modules::now_iso()) + } else { + match fields.get(col.key) { + None => continue, + Some(value) if col.key == "createdAt" && !value.is_null() => { + return Err(WriteError::TypeError("value.toISOString is not a function")); + } + Some(value) => node_pg_text(value), + } + }; + arguments.add(value).map_err(sqlx::Error::Encode)?; + sets.push(format!( + "\"{}\" = ${}::{}", + col.column, + sets.len() + 1, + col.sql_type + )); + } + arguments.add(id).map_err(sqlx::Error::Encode)?; + let sql = format!( + "UPDATE publicities SET {} WHERE id = ${}::int4", + sets.join(", "), + sets.len() + 1 + ); + sqlx::query_with(&sql, arguments).execute(db).await?; + Ok(()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update publicity"; + if !has_permission(user.role(), "publicity", "edit") { + return Err(AppError::forbidden("Insufficient permissions")); + } + let id_text = js_id(&id); + // { participatingGroups, ...publicityData } = req.body + let fields = body.as_object().cloned().unwrap_or_default(); + update_columns(&state.db, &id_text, &fields) + .await + .map_err(|error| error.into_500(FAILED))?; + if let Some(groups) = fields.get("participatingGroups") { + // L'identifiant a été accepté par la mise à jour : c'est un entier + let publicity_id = parse_int4(&id).ok_or_else(|| AppError::internal(FAILED))?; + set_participations(&state.db, publicity_id, groups) + .await + .map_err(|error| error.into_500(FAILED))?; + } + let publicity = publicity(&state.db, &id_text).await.or_500(FAILED)?; + Ok(json_or_empty(publicity)) +} + +/// DELETE /api/ad-campaigns/:id et POST /api/ad-campaigns/:id/delete +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + if !user.is_admin() { + return Err(AppError::forbidden("Insufficient permissions")); + } + let id_text = js_id(&id); + match delete_publicity(&state.db, &id_text).await { + Ok(()) => Ok(Json(json!({ "message": "Publicity deleted successfully" })).into_response()), + Err(message) => Ok(failed_delete(&message)), + } +} + +/// storage.deletePublicity : participations, puis la publicité ; erreur si absente +async fn delete_publicity(db: &PgPool, id: &str) -> Result<(), String> { + sqlx::query("DELETE FROM publicity_participations WHERE publicity_id = $1::int4") + .bind(id) + .execute(db) + .await + .map_err(|error| error_message(&error))?; + let deleted = sqlx::query("DELETE FROM publicities WHERE id = $1::int4 RETURNING id") + .bind(id) + .fetch_all(db) + .await + .map_err(|error| error_message(&error))?; + if deleted.is_empty() { + return Err(format!("Publicity with ID {id} not found")); + } + Ok(()) +} + +fn failed_delete(message: &str) -> Response { + tracing::error!(message, "Failed to delete publicity"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "message": "Failed to delete publicity", "error": message })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tri_numerique_des_numeros() { + assert_eq!(number_key("12"), 12.0); + assert_eq!(number_key("3-bis"), 3.0); + assert_eq!(number_key("PUB-01"), 0.0); + assert_eq!(number_key("0"), 0.0); + } + + #[test] + fn identifiants_et_annees_sous_forme_javascript() { + assert_eq!(js_id("12abc"), "12"); + assert_eq!(js_id("abc"), "NaN"); + assert_eq!(js_id("-0"), "0"); + assert_eq!(js_id("1234567890123456789012"), "1.2345678901234568e+21"); + } + + #[test] + fn longueur_des_participations_comme_javascript() { + assert!(has_items(&json!([1])).unwrap()); + assert!(!has_items(&json!([])).unwrap()); + assert!(has_items(&json!("12")).unwrap()); + assert!(!has_items(&json!(5)).unwrap()); + assert!(!has_items(&json!({})).unwrap()); + assert!(has_items(&json!({ "length": "2" })).unwrap()); + assert!(has_items(&json!(null)).is_err()); + } +} diff --git a/server-rs/src/modules/reconciliation.rs b/server-rs/src/modules/reconciliation.rs new file mode 100644 index 0000000..8a0c356 --- /dev/null +++ b/server-rs/src/modules/reconciliation.rs @@ -0,0 +1,795 @@ +// Rapprochement BL / factures (server/routes.ts) : commentaires de rapprochement, +// courriel de demande de documents au fournisseur et son historique, vérification +// d'une facture dans NocoDB, diagnostic et marquage du cache des factures, envoi +// d'une facture au webhook du magasin (send_invoice.rs, écart voulu RAPPRO-21). +// +// Défauts de Node reproduits : +// - un commentaire modifié peut changer de livraison, de magasin ou d'auteur +// (deliveryId, groupId, authorId acceptés par le PUT) ; +// - POST /api/verify-invoice, /api/verify-invoices et /api/cache/mark-reconciled +// ne contrôlent pas le magasin : tout compte connecté (directeur ou admin pour le +// marquage) agit sur n'importe quel magasin ; +// - /api/cache/diagnosis cherche des clés de cache au mauvais format +// (`référence_magasin` au lieu de `magasin_référence`) : il ne trouve rien, mais +// supprime au passage une entrée expirée qui aurait cette clé ; +// - /api/supplier-mail-logs ignore storeId pour un directeur. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use chrono::{NaiveDate, NaiveDateTime}; +use rust_decimal::Decimal; +use serde_json::{Map, Value, json}; +use sqlx::FromRow; + +use crate::AppState; +use crate::access::has_permission; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{GroupsRow, ReconciliationCommentsRow, SupplierMailLogsRow}; +use crate::services::invoice_verification::{self as verification, JsField, Verification}; +use crate::services::mail::{self, MailDelivery, MailLog}; +use crate::services::nocodb::{self, truthy}; +use crate::zod::{Field, Object}; + +mod send_invoice; + +pub fn routes() -> Router { + Router::new() + .route( + "/api/deliveries/{id}/reconciliation-comments", + get(list_comments).post(create_comment), + ) + .route( + "/api/reconciliation-comments/{id}", + put(update_comment).delete(delete_comment), + ) + .route( + "/api/deliveries/{id}/send-supplier-mail", + post(send_supplier_mail), + ) + .route("/api/supplier-mail-logs", get(supplier_mail_logs)) + .route( + "/api/deliveries/{id}/verify-invoice", + post(verify_delivery_invoice), + ) + .route("/api/verify-invoice", post(verify_invoice)) + .route("/api/verify-invoices", post(verify_invoices)) + .route("/api/cache/diagnosis", get(cache_diagnosis)) + .route("/api/cache/update-reconciled", post(cache_update_reconciled)) + .route("/api/cache/mark-reconciled", post(cache_mark_reconciled)) + .route( + "/api/reconciliation/send-invoice", + post(send_invoice::send_invoice), + ) +} + +const COMMENT_TYPES: &[&str] = &["info", "warning", "error", "success"]; + +/// insertReconciliationCommentSchema +fn comment_schema() -> Object { + ReconciliationCommentsRow::insert_schema() + .omit(&["id", "createdAt", "updatedAt"]) + .extend(vec![ + Field::enumeration("type", COMMENT_TYPES).default(json!("info")), + ]) +} + +/// Hors admin, l'utilisateur doit appartenir au magasin +fn check_group(user: &CurrentUser, group_id: i32) -> Result<(), AppError> { + if user.is_admin() || user.group_ids().contains(&group_id) { + Ok(()) + } else { + Err(AppError::forbidden("Access denied to this group")) + } +} + +// ---------- Livraison lue pour les contrôles (storage.getDelivery) ---------- + +/// Ce que ces adresses lisent de storage.getDelivery : la livraison, sa fiche +/// fournisseur et le nom de son magasin (jointures externes, comme Node) +#[derive(Debug, FromRow)] +struct DeliveryAccess { + id: i32, + group_id: i32, + supplier_id: i32, + reconciled: Option, + bl_number: Option, + bl_amount: Option, + invoice_reference: Option, + scheduled_date: NaiveDate, + delivered_date: Option, + supplier_row: Option, + supplier_name: Option, + supplier_email: Option, + group_row: Option, + group_name: Option, +} + +impl DeliveryAccess { + fn mail_delivery(&self) -> MailDelivery { + MailDelivery { + bl_number: self.bl_number.clone(), + bl_amount: self.bl_amount, + invoice_reference: self.invoice_reference.clone(), + scheduled_date: Some(self.scheduled_date), + delivered_date: self.delivered_date, + supplier_name: self.supplier_name.clone(), + group_name: self.group_name.clone(), + } + } +} + +/// Livraison par l'identifiant de l'adresse, converti comme parseInt puis envoyé +/// en texte à PostgreSQL : un identifiant illisible donne l'erreur SQL de Node +async fn load_delivery(db: &sqlx::PgPool, raw_id: &str) -> Result, sqlx::Error> { + let id = js::parse_int(raw_id).map_or_else(|| "NaN".to_string(), js::number_to_string); + sqlx::query_as( + "SELECT d.id, d.group_id, d.supplier_id, d.reconciled, d.bl_number, d.bl_amount, d.invoice_reference, + d.scheduled_date, d.delivered_date, s.id AS supplier_row, s.name AS supplier_name, + s.email AS supplier_email, g.id AS group_row, g.name AS group_name + FROM deliveries d + LEFT JOIN suppliers s ON d.supplier_id = s.id + LEFT JOIN groups g ON d.group_id = g.id + WHERE d.id = $1::int4", + ) + .bind(id) + .fetch_optional(db) + .await +} + +// ---------- Commentaires ---------- + +/// Commentaire avec son auteur, son magasin et une copie de sa livraison +/// (storage.getReconciliationComments) +#[derive(Debug, FromRow)] +struct CommentWithRelations { + id: i32, + delivery_id: i32, + group_id: i32, + content: String, + #[sqlx(rename = "type")] + type_: String, + author_id: String, + created_at: Option, + updated_at: Option, + a_id: Option, + a_first_name: Option, + a_last_name: Option, + a_username: Option, + a_email: Option, + a_role: Option, + g_id: Option, + g_name: Option, + g_color: Option, + d_id: Option, + d_order_id: Option, + d_supplier_id: Option, + d_group_id: Option, + d_scheduled_date: Option, + d_delivered_date: Option, + d_quantity: Option, + d_unit: Option, + d_status: Option, + d_notes: Option, + d_bl_number: Option, + d_bl_amount: Option, + d_invoice_reference: Option, + d_invoice_amount: Option, + d_reconciled: Option, + d_validated_at: Option, + d_created_by: Option, + d_created_at: Option, + d_updated_at: Option, +} + +fn ts(value: &Option) -> Value { + value + .as_ref() + .map_or(Value::Null, |date| Value::String(crate::json::format_ts(date))) +} + +impl CommentWithRelations { + /// Forme JSON de Node : objets joints à null quand la ligne jointe manque + /// (Drizzle), livraison complétée de supplier et creator vides + fn to_json(&self) -> Value { + let author = match &self.a_id { + Some(id) => json!({ + "id": id, + "firstName": self.a_first_name, + "lastName": self.a_last_name, + "username": self.a_username, + "email": self.a_email, + "role": self.a_role, + }), + None => Value::Null, + }; + let group = match self.g_id { + Some(id) => json!({ "id": id, "name": self.g_name, "color": self.g_color }), + None => Value::Null, + }; + let mut delivery = match self.d_id { + Some(id) => json!({ + "id": id, + "orderId": self.d_order_id, + "supplierId": self.d_supplier_id, + "groupId": self.d_group_id, + "scheduledDate": self.d_scheduled_date, + "deliveredDate": ts(&self.d_delivered_date), + "quantity": self.d_quantity, + "unit": self.d_unit, + "status": self.d_status, + "notes": self.d_notes, + "blNumber": self.d_bl_number, + "blAmount": self.d_bl_amount, + "invoiceReference": self.d_invoice_reference, + "invoiceAmount": self.d_invoice_amount, + "reconciled": self.d_reconciled, + "validatedAt": ts(&self.d_validated_at), + "createdBy": self.d_created_by, + "createdAt": ts(&self.d_created_at), + "updatedAt": ts(&self.d_updated_at), + }), + None => json!({}), + }; + if let Some(object) = delivery.as_object_mut() { + object.insert("supplier".into(), json!({})); + object.insert("group".into(), group.clone()); + object.insert("creator".into(), json!({})); + } + json!({ + "id": self.id, + "deliveryId": self.delivery_id, + "groupId": self.group_id, + "content": self.content, + "type": self.type_, + "authorId": self.author_id, + "createdAt": ts(&self.created_at), + "updatedAt": ts(&self.updated_at), + "author": author, + "group": group, + "delivery": delivery, + }) + } +} + +async fn list_comments( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + const FAILED: &str = "Failed to fetch reconciliation comments"; + let delivery_id = int_id(&id, FAILED)?; + let delivery = load_delivery(&state.db, &id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Delivery not found"))?; + if !has_permission(user.role(), "deliveries", "view") { + return Err(AppError::forbidden("Insufficient permissions")); + } + check_group(&user, delivery.group_id)?; + let comments: Vec = sqlx::query_as( + "SELECT c.id, c.delivery_id, c.group_id, c.content, c.type, c.author_id, c.created_at, c.updated_at, + u.id AS a_id, u.first_name AS a_first_name, u.last_name AS a_last_name, u.username AS a_username, + u.email AS a_email, u.role AS a_role, + g.id AS g_id, g.name AS g_name, g.color AS g_color, + d.id AS d_id, d.order_id AS d_order_id, d.supplier_id AS d_supplier_id, d.group_id AS d_group_id, + d.scheduled_date AS d_scheduled_date, d.delivered_date AS d_delivered_date, d.quantity AS d_quantity, + d.unit AS d_unit, d.status AS d_status, d.notes AS d_notes, d.bl_number AS d_bl_number, + d.bl_amount AS d_bl_amount, d.invoice_reference AS d_invoice_reference, + d.invoice_amount AS d_invoice_amount, d.reconciled AS d_reconciled, + d.validated_at AS d_validated_at, d.created_by AS d_created_by, + d.created_at AS d_created_at, d.updated_at AS d_updated_at + FROM reconciliation_comments c + LEFT JOIN users u ON c.author_id = u.id + LEFT JOIN groups g ON c.group_id = g.id + LEFT JOIN deliveries d ON c.delivery_id = d.id + WHERE c.delivery_id = $1 + ORDER BY c.created_at DESC", + ) + .bind(delivery_id) + .fetch_all(&state.db) + .await + .or_500(FAILED)?; + let comments: Vec = comments.iter().map(CommentWithRelations::to_json).collect(); + Ok(Json(comments).into_response()) +} + +async fn create_comment( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to create reconciliation comment"; + let delivery_id = int_id(&id, FAILED)?; + let delivery = load_delivery(&state.db, &id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Delivery not found"))?; + if !has_permission(user.role(), "deliveries", "edit") { + return Err(AppError::forbidden("Insufficient permissions to create comments")); + } + check_group(&user, delivery.group_id)?; + // { ...req.body, deliveryId, authorId, groupId } : ces trois champs ne viennent + // jamais du corps ; une erreur de validation donne un 500, comme Node + let mut input = body.as_object().cloned().unwrap_or_default(); + input.insert("deliveryId".into(), json!(delivery_id)); + input.insert("authorId".into(), json!(user.id())); + input.insert("groupId".into(), json!(delivery.group_id)); + let data = comment_schema() + .parse(&Value::Object(input)) + .map_err(|_| AppError::internal(FAILED))?; + let comment: ReconciliationCommentsRow = db::insert(&state.db, &data).await.or_500(FAILED)?; + Ok((StatusCode::CREATED, Json(comment)).into_response()) +} + +/// Auteur et magasin d'un commentaire (storage.getReconciliationCommentById) +async fn comment_owner(db: &sqlx::PgPool, id: i32, failed: &str) -> Result<(String, i32), AppError> { + let comment: Option<(String, i32)> = + sqlx::query_as("SELECT author_id, group_id FROM reconciliation_comments WHERE id = $1") + .bind(id) + .fetch_optional(db) + .await + .or_500(failed)?; + comment.ok_or_else(|| AppError::not_found("Comment not found")) +} + +/// Seuls l'auteur ou un admin agissent sur un commentaire, dans un magasin de l'utilisateur +fn check_comment_owner( + user: &CurrentUser, + author_id: &str, + group_id: i32, + action: &str, +) -> Result<(), AppError> { + if !user.is_admin() && author_id != user.id() { + return Err(AppError::forbidden(&format!( + "Only comment author or admin can {action} comments" + ))); + } + check_group(user, group_id) +} + +async fn update_comment( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update reconciliation comment"; + let id = int_id(&id, FAILED)?; + let (author_id, group_id) = comment_owner(&state.db, id, FAILED).await?; + check_comment_owner(&user, &author_id, group_id, "edit")?; + let data = comment_schema() + .partial() + .parse(&body) + .map_err(|_| AppError::internal(FAILED))?; + let comment = db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(comment)) +} + +async fn delete_comment( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + const FAILED: &str = "Failed to delete reconciliation comment"; + let id = int_id(&id, FAILED)?; + let (author_id, group_id) = comment_owner(&state.db, id, FAILED).await?; + check_comment_owner(&user, &author_id, group_id, "delete")?; + db::delete_by_id::(&state.db, id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Comment deleted successfully" })).into_response()) +} + +// ---------- Courriel au fournisseur ---------- + +/// Erreur imprévue de l'envoi : son message, à défaut celui de Node +fn mail_failure(message: &str) -> AppError { + let message = if message.is_empty() { + "Impossible d'envoyer le mail au fournisseur" + } else { + message + }; + AppError::internal(message) +} + +async fn send_supplier_mail( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> ApiResult { + let delivery = load_delivery(&state.db, &id) + .await + .map_err(|error| mail_failure(&nocodb::database_message(&error)))? + .ok_or_else(|| AppError::not_found("Livraison introuvable"))?; + if !has_permission(user.role(), "deliveries", "view") { + return Err(AppError::forbidden("Insufficient permissions")); + } + check_group(&user, delivery.group_id)?; + + // Adresse du fournisseur : celle de sa fiche + let supplier_email = delivery + .supplier_email + .as_deref() + .map(js::trim) + .unwrap_or_default() + .to_string(); + if supplier_email.is_empty() { + let name = delivery + .supplier_name + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "ce fournisseur".to_string()); + return Err(AppError::bad_request(&format!( + "Aucune adresse email renseignée pour {name}" + ))); + } + + // Configuration SMTP du magasin (mot de passe chiffré : lu ici seulement) + let group: GroupsRow = db::find_by_id(&state.db, delivery.group_id) + .await + .map_err(|error| mail_failure(&nocodb::database_message(&error)))? + .ok_or_else(|| AppError::not_found("Magasin de la livraison introuvable"))?; + if group.smtp_enabled != Some(true) { + return Err(AppError::bad_request(&format!( + "L'envoi de mails n'est pas activé pour le magasin {}. Renseignez la configuration SMTP sur sa fiche.", + group.name + ))); + } + let missing = mail::missing_smtp_fields(&group); + if !missing.is_empty() { + return Err(AppError::bad_request(&format!( + "Configuration SMTP incomplète pour {} : {}", + group.name, + missing.join(", ") + ))); + } + + // Tentative journalisée, réussie ou non ; un échec du journal n'annule pas l'envoi + let account = &user.user; + let sender_name = [account.first_name.as_deref(), account.last_name.as_deref()] + .into_iter() + .flatten() + .filter(|name| !name.is_empty()) + .collect::>() + .join(" "); + let sender_name = Some(js::trim(&sender_name).to_string()) + .filter(|name| !name.is_empty()) + .or_else(|| Some(account.username.clone()).filter(|name| !name.is_empty())) + .unwrap_or_else(|| account.id.clone()); + let mail_delivery = delivery.mail_delivery(); + let supplier_name = delivery.supplier_name.clone().filter(|name| !name.is_empty()); + let sent = mail::send_supplier_document_request( + &group, + &mail_delivery, + &supplier_email, + &state.config.encryption_key, + ) + .await; + + let (status, message_id, error_message) = match &sent { + Ok(message_id) => ("sent", Some(message_id.clone()), None), + Err(message) if message.is_empty() => ("failed", None, Some("Erreur inconnue".to_string())), + Err(message) => ("failed", None, Some(message.clone())), + }; + let entry = MailLog { + delivery_id: delivery.id, + group_id: delivery.group_id, + supplier_id: Some(delivery.supplier_id), + supplier_name: supplier_name.clone(), + sent_to: supplier_email.clone(), + subject: mail::subject(&mail_delivery), + status, + error_message, + message_id, + sent_by: account.id.clone(), + sent_by_name: sender_name, + }; + if let Err(error) = mail::record_mail_log(&state.db, &entry).await { + tracing::error!(%error, "historisation du mail fournisseur impossible"); + } + + match sent { + Ok(message_id) => Ok(Json(json!({ + "success": true, + "sentTo": supplier_email, + "supplierName": supplier_name, + "messageId": message_id, + })) + .into_response()), + Err(message) => Err(mail_failure(&message)), + } +} + +async fn supplier_mail_logs(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch supplier mail logs"; + // Un identifiant illisible ne fait échouer que la requête SQL, après le contrôle des magasins + let group_ids: Option> = if !user.is_admin() { + let ids = user.group_ids(); + if ids.is_empty() { + return Ok(Json(json!([])).into_response()); + } + Some(ids) + } else if let Some(store_id) = query.truthy("storeId") { + Some(vec![int_id(store_id, FAILED)?]) + } else { + None + }; + let delivery_id = query + .truthy("deliveryId") + .map(|id| int_id(id, FAILED)) + .transpose()?; + let logs: Vec = sqlx::query_as(&SupplierMailLogsRow::select( + "WHERE ($1::int4[] IS NULL OR group_id = ANY($1)) AND ($2::int4 IS NULL OR delivery_id = $2) + ORDER BY created_at DESC LIMIT 500", + )) + .bind(group_ids) + .bind(delivery_id) + .fetch_all(&state.db) + .await + .or_500(FAILED)?; + Ok(Json(logs).into_response()) +} + +// ---------- Vérification des factures ---------- + +/// TypeError de `valeur.trim()` sur une valeur qui n'est pas une chaîne +fn blank(name: &str, value: Option<&Value>) -> Result { + match value { + Some(value) if truthy(value) => match value.as_str() { + Some(text) => Ok(js::trim(text).is_empty()), + None => Err(nocodb::not_a_string_error(name, "trim", Some(value))), + }, + _ => Ok(true), + } +} + +/// Après une vérification réussie, Node recopie sur la livraison la référence, +/// les montants et l'échéance trouvés ; une échéance que new Date() ne lit pas fait +/// échouer toute la mise à jour (erreur journalisée seulement) +async fn save_verification(db: &sqlx::PgPool, delivery_id: i32, result: &Verification) { + let found_something = result.invoice_amount != JsField::Absent + || result.invoice_amount_ttc != JsField::Absent + || result.due_date != JsField::Absent + || result.invoice_reference != JsField::Absent; + if !result.exists || !found_something { + return; + } + let mut data = Map::new(); + if let Some(reference) = result.invoice_reference.get().filter(|value| truthy(value)) { + data.insert("invoiceReference".into(), reference.clone()); + } + if let Some(amount) = result.invoice_amount.get() { + data.insert( + "invoiceAmount".into(), + Value::String(verification::js_number_text(*amount)), + ); + } + if let Some(amount) = result.invoice_amount_ttc.get() { + data.insert( + "invoiceAmountTTC".into(), + Value::String(verification::js_number_text(*amount)), + ); + } + if let Some(normalized) = result.normalized_due_date() { + match crate::services::dates::parse_js_date(&normalized) { + Some(date) => { + data.insert("dueDate".into(), Value::String(js::iso(&date))); + } + None => { + tracing::error!(delivery_id, "échéance illisible : livraison non mise à jour"); + return; + } + } + } + if data.is_empty() { + return; + } + if let Err(error) = db::update_by_id::(db, delivery_id, &data, true).await { + tracing::error!(%error, delivery_id, "sauvegarde des données de vérification"); + } +} + +async fn verify_delivery_invoice( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + let failed = |message: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Failed to verify invoice", "error": message }), + ) + }; + let delivery = load_delivery(&state.db, &id) + .await + .map_err(|error| failed(nocodb::database_message(&error)))? + .ok_or_else(|| AppError::not_found("Delivery not found"))?; + if !has_permission(user.role(), "deliveries", "view") { + return Err(AppError::forbidden("Insufficient permissions")); + } + check_group(&user, delivery.group_id)?; + if delivery.supplier_row.is_none() || delivery.group_row.is_none() { + return Err(AppError::bad_request( + "Delivery missing supplier or group information", + )); + } + + // Une référence de facture ou un numéro de BL non vide + let invoice_reference = body.get("invoiceReference"); + let bl_number = body.get("blNumber"); + let reference_blank = blank("invoiceReference", invoice_reference).map_err(failed)?; + if reference_blank && blank("blNumber", bl_number).map_err(failed)? { + return Err(AppError::bad_request("Référence de facture ou numéro BL requis")); + } + let force_refresh = body.get("forceRefresh").is_some_and(truthy); + let reconciled = delivery.reconciled == Some(true); + let result = match ( + invoice_reference.and_then(Value::as_str), + bl_number.and_then(Value::as_str), + ) { + (Some(reference), _) if !reference_blank => { + verification::verify_invoice(&state, reference, delivery.group_id, force_refresh, reconciled) + .await + } + (_, Some(bl)) => { + let supplier = delivery.supplier_name.clone().unwrap_or_default(); + verification::verify_invoice_by_bl( + &state, + bl, + &supplier, + delivery.group_id, + force_refresh, + reconciled, + ) + .await + } + _ => Verification::failure("Aucune référence de facture ou numéro BL fourni"), + }; + save_verification(&state.db, delivery.id, &result).await; + Ok(Json(result).into_response()) +} + +async fn verify_invoice( + State(state): State, + _user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + let group_id = body.get("groupId").filter(|value| truthy(value)); + let invoice_reference = body.get("invoiceReference").filter(|value| truthy(value)); + let (Some(group_id), Some(invoice_reference)) = (group_id, invoice_reference) else { + return Err(AppError::bad_request("groupId and invoiceReference are required")); + }; + let result = verification::verify_invoice_raw(&state, invoice_reference, group_id, false, false).await; + Ok(Json(result).into_response()) +} + +async fn verify_invoices( + State(state): State, + _user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + let Some(references) = body.get("invoiceReferences").and_then(Value::as_array) else { + return Err(AppError::bad_request("invoiceReferences must be an array")); + }; + let results = nocodb::verify_multiple_invoice_references(&state, references) + .await + .ok_or_else(|| AppError::internal("Failed to verify invoices"))?; + Ok(Json(results).into_response()) +} + +// ---------- Cache des factures ---------- + +fn admin_only(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(AppError::forbidden( + "Seuls les admins peuvent exécuter cette opération", + )) + } +} + +async fn cache_diagnosis(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let failed = |message: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Erreur lors du diagnostic", "error": message }), + ) + }; + // storage.getDeliveries() : mêmes jointures et même ordre que Node + let deliveries: Vec<(i32, i32, Option, Option)> = sqlx::query_as( + "SELECT deliveries.id, deliveries.group_id, deliveries.invoice_reference, deliveries.reconciled + FROM deliveries + LEFT JOIN suppliers ON deliveries.supplier_id = suppliers.id + LEFT JOIN groups ON deliveries.group_id = groups.id + LEFT JOIN users ON deliveries.created_by = users.id + ORDER BY deliveries.created_at DESC", + ) + .fetch_all(&state.db) + .await + .map_err(|error| failed(nocodb::database_message(&error)))?; + let reconciled: Vec<_> = deliveries + .iter() + .filter(|(_, _, _, reconciled)| *reconciled == Some(true)) + .collect(); + let mut samples = Vec::new(); + for (id, group_id, reference, _) in reconciled.iter().take(5) { + let Some(reference) = reference.as_deref().filter(|r| !r.is_empty()) else { + continue; + }; + // Clé au format inversé, comme Node (défaut reproduit) + let key = format!("{}_{group_id}", reference.to_lowercase()); + let cached = verification::read_cache(&state.db, &key) + .await + .map_err(|error| failed(nocodb::database_message(&error)))?; + let mut sample = json!({ + "deliveryId": id, + "invoiceRef": reference, + "groupId": group_id, + "reconciled": true, + "cacheExists": cached.is_some(), + "cacheReconciled": cached.as_ref().is_some_and(|c| c.is_reconciled == Some(true)), + }); + if let Some(cached) = cached { + sample["cacheExpires"] = Value::String(crate::json::format_ts(&cached.expires_at)); + } + samples.push(sample); + } + let (total, count) = (deliveries.len(), reconciled.len()); + // Math.round(x / 0 * 100) : NaN, écrit null + let percentage = if total == 0 { + Value::Null + } else { + js::number_value((count as f64 / total as f64 * 100.0 + 0.5).floor()) + }; + Ok(Json(json!({ + "statistics": { + "totalDeliveries": total, + "reconciledDeliveries": count, + "percentageReconciled": percentage, + }, + "sampleCaches": samples, + "message": "Diagnostic du cache terminé", + })) + .into_response()) +} + +async fn cache_update_reconciled(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let updated = verification::update_existing_reconciled_caches(&state.db).await; + tracing::info!(updated, "caches des livraisons validées marqués permanents"); + Ok(Json(json!({ + "message": "Mise à jour des caches permanents terminée avec succès", + "success": true, + })) + .into_response()) +} + +async fn cache_mark_reconciled( + State(state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + let invoice_reference = body.get("invoiceReference").filter(|value| truthy(value)); + let group_id = body.get("groupId").filter(|value| truthy(value)); + let (Some(invoice_reference), Some(group_id)) = (invoice_reference, group_id) else { + return Err(AppError::bad_request("Invoice reference and group ID required")); + }; + if !["admin", "directeur"].contains(&user.role()) { + return Err(AppError::forbidden("Insufficient permissions")); + } + verification::update_cache_as_reconciled_raw(&state.db, invoice_reference, group_id).await; + Ok(Json(json!({ "message": "Cache marked as reconciled successfully" })).into_response()) +} diff --git a/server-rs/src/modules/reconciliation/send_invoice.rs b/server-rs/src/modules/reconciliation/send_invoice.rs new file mode 100644 index 0000000..adb7dd9 --- /dev/null +++ b/server-rs/src/modules/reconciliation/send_invoice.rs @@ -0,0 +1,407 @@ +// POST /api/reconciliation/send-invoice (server/routes.ts) : le fichier d'une +// facture, reçu en multipart, est renvoyé en multipart (file, supplier, blNumber, +// type) au webhook dont l'adresse accompagne le formulaire (admin et directeur). +// +// Écart voulu (audit RAPPRO-21, déclaré au banc) : Node appelle toute adresse +// fournie par le navigateur. Ici, l'adresse doit être celle d'un magasin +// (groups.webhook_url, ce que l'interface envoie) ou du webhook BAP actif ; toute +// autre adresse est refusée (400) avant l'appel. +// +// Repris de Node : l'analyse multipart écrite à la main (parseMultipart), la +// lecture du corps sans limite de taille, l'encodage multipart de node-fetch +// (formdata-polyfill) et ses messages d'erreur, sans délai maximal. + +use std::collections::HashMap; + +use axum::Json; +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::IntoResponse; +use rand::Rng; +use serde_json::json; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::error::{ApiResult, AppError}; +use crate::modules::bap::node_fetch_post; +use crate::services::nocodb::database_message; + +fn error_body(status: StatusCode, error: &str) -> AppError { + AppError::with_body(status, json!({ "error": error })) +} + +// ---------- Analyse multipart (parseMultipart de Node) ---------- + +#[derive(Debug, Clone, PartialEq)] +enum Part { + Text(String), + File { + filename: String, + content_type: String, + data: Vec, + }, +} + +impl Part { + /// Valeur « vraie » : un fichier (objet) toujours, un texte s'il n'est pas vide + fn truthy(&self) -> bool { + match self { + Part::Text(text) => !text.is_empty(), + Part::File { .. } => true, + } + } + + /// `parts.x || défaut` passé à FormData.append : un fichier devient « [object Object] » + fn text_or(part: Option<&Part>, default: &str) -> String { + match part { + Some(Part::File { .. }) => "[object Object]".to_string(), + Some(Part::Text(text)) if !text.is_empty() => text.clone(), + _ => default.to_string(), + } + } +} + +/// Première capture de /prefix([^stop]+)suffix/ : au moins un caractère, suivi de +/// `suffix` s'il est donné (recherche de gauche à droite, comme une expression régulière) +fn capture(text: &str, prefix: &str, stop: &[char], suffix: Option) -> Option { + let mut from = 0; + while let Some(found) = text[from..].find(prefix) { + let start = from + found + prefix.len(); + let rest = &text[start..]; + let end = rest.find(|c: char| stop.contains(&c)).unwrap_or(rest.len()); + let complete = match suffix { + Some(suffix) => rest[end..].starts_with(suffix), + None => true, + }; + if end > 0 && complete { + return Some(rest[..end].to_string()); + } + from = from + found + 1; + while !text.is_char_boundary(from) { + from += 1; + } + } + None +} + +/// /boundary="?([^";]+)"?/i sur l'en-tête Content-Type +fn boundary(content_type: &str) -> Option { + let lower = content_type.to_ascii_lowercase(); + let mut from = 0; + while let Some(found) = lower[from..].find("boundary=") { + let start = from + found + "boundary=".len(); + let rest = &content_type[start..]; + let is_value = |c: char| c != '"' && c != ';'; + // « "? » gourmand d'abord, puis sans le guillemet + for skip in [rest.starts_with('"') as usize, 0] { + let value: String = rest[skip..].chars().take_while(|c| is_value(*c)).collect(); + if !value.is_empty() { + return Some(value); + } + } + from = start; + } + None +} + +fn find(haystack: &[u8], needle: &[u8], from: usize) -> Option { + if needle.is_empty() || from > haystack.len() { + return None; + } + haystack[from..] + .windows(needle.len()) + .position(|window| window == needle) + .map(|position| position + from) +} + +/// Sections entre les délimiteurs, en-têtes jusqu'à la première ligne vide, +/// contenu sans ses deux derniers octets ; un nom répété garde la dernière partie +fn parse_multipart(buffer: &[u8], boundary: &str) -> HashMap { + let delimiter = format!("--{boundary}").into_bytes(); + let mut sections = Vec::new(); + let mut start = 0; + while let Some(index) = find(buffer, &delimiter, start) { + if start > 0 { + sections.push(&buffer[start..index]); + } + start = index + delimiter.len(); + } + + let mut parts = HashMap::new(); + for section in sections { + let Some(header_end) = find(section, b"\r\n\r\n", 0) else { + continue; + }; + let headers = String::from_utf8_lossy(§ion[..header_end]); + let content_start = header_end + 4; + let content_end = section.len().saturating_sub(2).max(content_start); + let content = section.get(content_start..content_end).unwrap_or_default(); + + let Some(name) = capture(&headers, "name=\"", &['"'], Some('"')) else { + continue; + }; + let part = match capture(&headers, "filename=\"", &['"'], Some('"')) { + Some(filename) => Part::File { + filename, + content_type: capture(&headers, "Content-Type: ", &['\r', '\n'], None) + .unwrap_or_else(|| "application/octet-stream".to_string()), + data: content.to_vec(), + }, + None => Part::Text(String::from_utf8_lossy(content).into_owned()), + }; + parts.insert(name, part); + } + parts +} + +// ---------- Renvoi en multipart (formDataToBlob de node-fetch) ---------- + +/// Type d'un Blob : vide s'il sort de U+0020–U+007E, sinon en minuscules +fn blob_type(content_type: &str) -> String { + if content_type.chars().all(|c| (' '..='~').contains(&c)) { + content_type.to_ascii_lowercase() + } else { + String::new() + } +} + +/// Fin de ligne isolée (\r ou \n) remplacée par \r\n +fn crlf(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => { + out.push_str("\r\n"); + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + '\n' => out.push_str("\r\n"), + other => out.push(other), + } + } + out +} + +/// Échappement des noms de champ et de fichier dans Content-Disposition +fn escape_name(name: &str, keep_newlines: bool) -> String { + let name = if keep_newlines { + name.to_string() + } else { + crlf(name) + }; + name.replace('\n', "%0A").replace('\r', "%0D").replace('"', "%22") +} + +/// Corps multipart et son type (boundary aléatoire, 32 caractères) +fn encode_form(file: (&str, &str, &[u8]), fields: &[(&str, String)]) -> (Vec, String) { + let digits: String = (0..28) + .map(|_| char::from(b'0' + rand::thread_rng().gen_range(0..10))) + .collect(); + let boundary = format!("----{digits}"); + let (filename, content_type, data) = file; + let mut body = Vec::with_capacity(data.len() + 512); + let content_type = if content_type.is_empty() { + "application/octet-stream" + } else { + content_type + }; + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {content_type}\r\n\r\n", + escape_name(filename, true) + ) + .as_bytes(), + ); + body.extend_from_slice(data); + body.extend_from_slice(b"\r\n"); + for (name, value) in fields { + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", + escape_name(name, false), + crlf(value) + ) + .as_bytes(), + ); + } + body.extend_from_slice(format!("--{boundary}--").as_bytes()); + (body, format!("multipart/form-data; boundary={boundary}")) +} + +// ---------- Adresse autorisée (RAPPRO-21) ---------- + +/// L'adresse est-elle celle d'un magasin ou du webhook BAP actif (première ligne +/// de la configuration, comme storage.getWebhookBapConfig) ? +async fn is_configured_webhook(db: &sqlx::PgPool, url: &str) -> Result { + let (allowed,): (bool,) = sqlx::query_as( + "SELECT EXISTS (SELECT 1 FROM groups WHERE webhook_url = $1) + OR EXISTS (SELECT 1 FROM (SELECT webhook_url, is_active FROM webhook_bap_config LIMIT 1) bap + WHERE bap.is_active AND bap.webhook_url = $1)", + ) + .bind(url) + .fetch_one(db) + .await?; + Ok(allowed) +} + +pub async fn send_invoice( + State(state): State, + user: CurrentUser, + headers: HeaderMap, + body: Body, +) -> ApiResult { + let server_error = |message: &str| { + let message = if message.is_empty() { + "Erreur serveur" + } else { + message + }; + error_body(StatusCode::INTERNAL_SERVER_ERROR, message) + }; + if !["admin", "directeur"].contains(&user.role()) { + return Err(error_body(StatusCode::FORBIDDEN, "Accès refusé")); + } + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let Some(boundary) = boundary(content_type) else { + return Err(error_body(StatusCode::BAD_REQUEST, "Format multipart invalide")); + }; + // Corps lu en entier, sans limite de taille, comme Node + let buffer = axum::body::to_bytes(body, usize::MAX) + .await + .map_err(|error| server_error(&error.to_string()))?; + let parts = parse_multipart(&buffer, &boundary); + + let (Some(file), Some(webhook_url)) = ( + parts.get("file").filter(|part| part.truthy()), + parts.get("webhookUrl").filter(|part| part.truthy()), + ) else { + return Err(error_body(StatusCode::BAD_REQUEST, "Fichier ou webhook manquant")); + }; + // Champ « file » en texte : parts.file.buffer.length lève une TypeError + let Part::File { + filename, + content_type: file_type, + data, + } = file + else { + return Err(server_error( + "Cannot read properties of undefined (reading 'length')", + )); + }; + tracing::info!( + user = user.id(), + filename, + size = data.len(), + "envoi de facture au webhook" + ); + + // Écart voulu (RAPPRO-21) : seule une adresse configurée est appelée + let allowed_url = match webhook_url { + Part::Text(url) => is_configured_webhook(&state.db, url) + .await + .map_err(|error| server_error(&database_message(&error)))? + .then_some(url), + Part::File { .. } => None, + }; + let Some(url) = allowed_url else { + return Err(error_body( + StatusCode::BAD_REQUEST, + "Adresse de webhook non autorisée : seule l'adresse configurée pour un magasin ou pour le webhook BAP est acceptée", + )); + }; + + let fields = [ + ("supplier", Part::text_or(parts.get("supplier"), "")), + ("blNumber", Part::text_or(parts.get("blNumber"), "")), + ("type", Part::text_or(parts.get("type"), "Facture")), + ]; + let (form, form_type) = encode_form((filename, &blob_type(file_type), data), &fields); + let response = node_fetch_post( + url, + |request| request.header(header::CONTENT_TYPE, form_type).body(form), + None, + ) + .await; + match response { + Ok((status, _)) if status.is_success() => { + Ok(Json(json!({ "success": true, "message": "Facture envoyée" })).into_response()) + } + Ok((status, details)) => { + tracing::error!(%status, "webhook de facture en erreur"); + Err(AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": format!("Webhook error: {}", status.as_u16()), "details": details }), + )) + } + Err(error) => Err(server_error(&error.message)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boundary_comme_l_expression_de_node() { + assert_eq!( + boundary("multipart/form-data; boundary=abc").as_deref(), + Some("abc") + ); + assert_eq!( + boundary("multipart/form-data; BOUNDARY=\"a b\"; x=1").as_deref(), + Some("a b") + ); + assert_eq!( + boundary("multipart/form-data; boundary=;boundary=z").as_deref(), + Some("z") + ); + assert_eq!(boundary("application/json"), None); + assert_eq!(boundary(""), None); + } + + #[test] + fn analyse_multipart_comme_node() { + let body = b"preambule\r\n--XX\r\nContent-Disposition: form-data; name=\"supplier\"\r\n\r\nLactalis\r\n\ +--XX\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.pdf\"\r\nContent-Type: application/pdf\r\n\r\n%%%\r\n\ +--XX\r\nContent-Disposition: form-data; name=\"vide\"; filename=\"\"\r\n\r\nabc\r\n\ +--XX\r\nContent-Disposition: form-data; name=\"supplier\"\r\n\r\nBonduelle\r\n--XX--\r\n"; + let parts = parse_multipart(body, "XX"); + assert_eq!(parts.get("supplier"), Some(&Part::Text("Bonduelle".into()))); + assert_eq!( + parts.get("file"), + Some(&Part::File { + filename: "f.pdf".into(), + content_type: "application/pdf".into(), + data: b"%%%".to_vec() + }) + ); + // filename vide : /filename="([^"]+)"/ échoue, la partie est un texte + assert_eq!(parts.get("vide"), Some(&Part::Text("abc".into()))); + } + + #[test] + fn encodage_comme_form_data_to_blob() { + assert_eq!(blob_type("Application/PDF"), "application/pdf"); + assert_eq!(blob_type("image/é"), ""); + assert_eq!(crlf("a\nb\rc\r\nd"), "a\r\nb\r\nc\r\nd"); + assert_eq!(escape_name("a\"b\nc", true), "a%22b%0Ac"); + let (body, content_type) = encode_form(("f.pdf", "", b"xy"), &[("type", "Facture".into())]); + let boundary = content_type + .strip_prefix("multipart/form-data; boundary=") + .unwrap(); + assert_eq!(boundary.len(), 32); + assert_eq!( + String::from_utf8(body).unwrap(), + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.pdf\"\r\nContent-Type: application/octet-stream\r\n\r\nxy\r\n\ +--{boundary}\r\nContent-Disposition: form-data; name=\"type\"\r\n\r\nFacture\r\n--{boundary}--" + ) + ); + } +} diff --git a/server-rs/src/modules/sav.rs b/server-rs/src/modules/sav.rs new file mode 100644 index 0000000..1b4cbd2 --- /dev/null +++ b/server-rs/src/modules/sav.rs @@ -0,0 +1,407 @@ +// SAV : /api/sav/tickets et /api/sav/stats (server/routes.ts, « SAV routes » ; +// storage.ts getSavTickets à getSavTicketStats). Comme Node aujourd'hui : +// - un compte sans magasin voit les tickets et les compteurs de tous les magasins ; +// - la suppression ne contrôle pas le magasin : un directeur supprime les tickets +// de n'importe quel magasin ; +// - la modification (schéma zod partiel) peut déplacer le ticket vers un autre +// magasin et changer son créateur ; +// - l'historique est désactivé dans storage.ts : POST …/history n'enregistre rien et +// renvoie null, history vaut toujours [] ; +// - numéro de ticket « nombre de tickets de l'année + 1 », mais le COUNT est lu en +// texte par node-pg, donc concaténé : 5 tickets donnent « 51 », SAV-2026-0051. +// Reproduit tel quel (doublons possibles, docs/PLAN-MIGRATION-RUST.md) ; +// - GET /:id joint la ligne complète du magasin (mot de passe SMTP remplacé par +// smtpPasswordSet). + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::post}; +use chrono::{Datelike, NaiveDate}; +use serde_json::{Value, json}; +use sqlx::{Postgres, QueryBuilder}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, columns_of}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js; +use crate::json::{spread, strip_smtp_password}; +use crate::projections::{GroupSummary, UserSummary}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::{GroupsRow, SavTicketsRow, SuppliersRow}; +use crate::zod::{Field, Object}; + +pub fn routes() -> Router { + Router::new() + .route("/api/sav/tickets", get(list).post(create)) + .route("/api/sav/tickets/{id}", get(show).patch(update).delete(remove)) + .route("/api/sav/tickets/{id}/history", post(add_history)) + .route("/api/sav/stats", get(stats)) +} + +const PRIORITIES: [&str; 4] = ["faible", "normale", "haute", "critique"]; +const STATUSES: [&str; 6] = [ + "nouveau", + "en_cours", + "attente_pieces", + "attente_echange", + "resolu", + "ferme", +]; +const PROBLEM_TYPES: [&str; 4] = ["defectueux", "pieces_manquantes", "non_conforme", "autre"]; +const EDITORS: [&str; 3] = ["admin", "manager", "directeur"]; + +/// insertSavTicketSchema +fn insert_schema() -> Object { + SavTicketsRow::insert_schema() + .omit(&[ + "id", + "ticketNumber", + "createdAt", + "updatedAt", + "resolvedAt", + "closedAt", + ]) + .extend(vec![ + Field::enumeration("priority", &PRIORITIES).default(json!("normale")), + Field::enumeration("status", &STATUSES).default(json!("nouveau")), + Field::enumeration("problemType", &PROBLEM_TYPES).default(json!("defectueux")), + ]) +} + +/// Nombre lu par parseInt utilisé comme entier : hors limites, la requête de Node échoue +fn int4(number: f64, failed: &str) -> Result { + if number >= f64::from(i32::MIN) && number <= f64::from(i32::MAX) { + Ok(number as i32) + } else { + Err(AppError::internal(failed)) + } +} + +/// Magasins lus (vide : tous). Admin : ?groupId s'il se lit comme un nombre non nul ; +/// les autres : leurs magasins (aucun pour un compte sans magasin, donc tous) +fn scope(user: &CurrentUser, query: &JsQuery, failed: &str) -> Result, AppError> { + if !user.is_admin() { + return Ok(user.group_ids()); + } + match query.truthy("groupId").and_then(js::parse_int) { + Some(group) if group != 0.0 => Ok(vec![int4(group, failed)?]), + _ => Ok(Vec::new()), + } +} + +/// Ajoute « WHERE » ou « AND » avant une condition +fn and(sql: &mut QueryBuilder<'_, Postgres>, count: &mut usize) { + sql.push(if *count == 0 { " WHERE " } else { " AND " }); + *count += 1; +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch tickets"; + let group_ids = scope(&user, &query, FAILED)?; + let mut sql = QueryBuilder::::new(format!( + "SELECT {} FROM sav_tickets + LEFT JOIN suppliers ON sav_tickets.supplier_id = suppliers.id + LEFT JOIN groups ON sav_tickets.group_id = groups.id + LEFT JOIN users ON sav_tickets.created_by = users.id", + columns_of::("sav_tickets") + )); + let mut count = 0; + if !group_ids.is_empty() { + and(&mut sql, &mut count); + sql.push("sav_tickets.group_id = ANY(") + .push_bind(group_ids) + .push(")"); + } + if let Some(status) = query.truthy("status") { + and(&mut sql, &mut count); + sql.push("sav_tickets.status = ").push_bind(status.to_string()); + } + // parseInt(supplierId) : NaN et 0 ne filtrent pas + if let Some(supplier) = query + .truthy("supplierId") + .and_then(js::parse_int) + .filter(|supplier| *supplier != 0.0) + { + let supplier = int4(supplier, FAILED)?; + and(&mut sql, &mut count); + sql.push("sav_tickets.supplier_id = ").push_bind(supplier); + } + if let Some(priority) = query.truthy("priority") { + and(&mut sql, &mut count); + sql.push("sav_tickets.priority = ") + .push_bind(priority.to_string()); + } + // new Date(…) : une date invalide fait échouer toISOString + for (key, operator) in [("startDate", " >= "), ("endDate", " <= ")] { + if let Some(text) = query.truthy(key) { + let date = js::parse_date(text).ok_or_else(|| AppError::internal(FAILED))?; + and(&mut sql, &mut count); + sql.push("sav_tickets.created_at") + .push(operator) + .push_bind(date.naive_utc()); + } + } + sql.push(" ORDER BY sav_tickets.created_at DESC"); + let tickets: Vec = sql.build_query_as().fetch_all(&state.db).await.or_500(FAILED)?; + + let supplier_ids: Vec = tickets.iter().map(|ticket| ticket.supplier_id).collect(); + let suppliers: HashMap = db::find_by_ids::(&state.db, &supplier_ids) + .await + .or_500(FAILED)? + .into_iter() + .map(|supplier| (supplier.id, supplier)) + .collect(); + let group_ids: Vec = tickets.iter().map(|ticket| ticket.group_id).collect(); + let groups = GroupSummary::by_ids(&state.db, &group_ids).await.or_500(FAILED)?; + let creator_ids: Vec = tickets.iter().map(|ticket| ticket.created_by.clone()).collect(); + let creators = UserSummary::by_ids(&state.db, &creator_ids) + .await + .or_500(FAILED)?; + + let out: Vec = tickets + .iter() + .map(|ticket| { + with_relations( + ticket, + json!(suppliers.get(&ticket.supplier_id)), + json!(groups.get(&ticket.group_id)), + json!(creators.get(&ticket.created_by)), + ) + }) + .collect(); + Ok(Json(out).into_response()) +} + +/// { ...ticket, supplier, group, creator, history: [] } +fn with_relations(ticket: &SavTicketsRow, supplier: Value, group: Value, creator: Value) -> Value { + let mut entry = spread(ticket); + entry.insert("supplier".into(), supplier); + entry.insert("group".into(), group); + entry.insert("creator".into(), creator); + entry.insert("history".into(), json!([])); + Value::Object(entry) +} + +/// Ticket existant ; `denied` : message si le magasin n'est pas celui de l'utilisateur +/// (None : pas de contrôle du magasin) +async fn find_ticket( + state: &AppState, + user: &CurrentUser, + raw_id: &str, + failed: &str, + denied: Option<&str>, +) -> Result { + let id = int_id(raw_id, failed)?; + let ticket = db::find_by_id::(&state.db, id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Ticket not found"))?; + let foreign = !user.is_admin() && !user.group_ids().contains(&ticket.group_id); + match denied { + Some(denied) if foreign => Err(AppError::forbidden(denied)), + _ => Ok(ticket), + } +} + +async fn show(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to fetch ticket"; + let ticket = find_ticket(&state, &user, &id, FAILED, Some("Access denied to this ticket")).await?; + let supplier = db::find_by_id::(&state.db, ticket.supplier_id) + .await + .or_500(FAILED)?; + let group = db::find_by_id::(&state.db, ticket.group_id) + .await + .or_500(FAILED)?; + let creator = UserSummary::by_ids(&state.db, std::slice::from_ref(&ticket.created_by)) + .await + .or_500(FAILED)? + .remove(&ticket.created_by); + let body = with_relations( + &ticket, + json!(supplier), + strip_smtp_password(json!(group)), + json!(creator), + ); + Ok(Json(body).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create ticket"; + if !EDITORS.contains(&user.role()) { + return Err(AppError::forbidden("Insufficient permissions to create tickets")); + } + // Magasins possibles : tous (par nom) pour l'admin, les siens sinon + let available: Vec = if user.is_admin() { + sqlx::query_scalar("SELECT id FROM groups ORDER BY name") + .fetch_all(&state.db) + .await + .or_500(FAILED)? + } else { + user.group_ids() + }; + let Some(first) = available.first().copied() else { + return Err(AppError::bad_request("No groups available for ticket creation")); + }; + + // { ...req.body, groupId: req.body.groupId || premier magasin, createdBy } + let mut input = body.as_object().cloned().unwrap_or_default(); + if !input.get("groupId").is_some_and(js::truthy) { + input.insert("groupId".into(), json!(first)); + } + input.insert("createdBy".into(), json!(user.id())); + let mut ticket = insert_schema() + .parse(&Value::Object(input)) + .map_err(|issues| AppError::validation("Validation error", issues))?; + let allowed = ticket + .get("groupId") + .and_then(Value::as_f64) + .is_some_and(|group| available.iter().any(|id| f64::from(*id) == group)); + if !allowed { + return Err(AppError::forbidden("Access denied to the specified group")); + } + + // createSavTicket : tickets créés cette année, année de Paris (décision R5) ; + // created_at est en UTC, d'où les bornes converties + let year = crate::clock::today().year(); + let year_start = + |year: i32| crate::clock::start_of_day_utc(NaiveDate::from_ymd_opt(year, 1, 1).expect("1er janvier")); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM sav_tickets WHERE created_at >= $1 AND created_at < $2") + .bind(year_start(year)) + .bind(year_start(year + 1)) + .fetch_one(&state.db) + .await + .or_500(FAILED)?; + ticket.insert("ticketNumber".into(), json!(ticket_number(year, count))); + let created: SavTicketsRow = db::insert(&state.db, &ticket).await.or_500(FAILED)?; + Ok((StatusCode::CREATED, Json(created)).into_response()) +} + +/// `SAV-${année}-${String((count || 0) + 1).padStart(4, '0')}` avec count en texte : +/// « 5 » + 1 donne « 51 » +fn ticket_number(year: i32, count: i64) -> String { + format!("SAV-{year}-{:0>4}", format!("{count}1")) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update ticket"; + if !EDITORS.contains(&user.role()) { + return Err(AppError::forbidden("Insufficient permissions to modify tickets")); + } + let ticket = find_ticket( + &state, + &user, + &id, + FAILED, + Some("Access denied to modify this ticket"), + ) + .await?; + let data = insert_schema() + .partial() + .parse(&body) + .map_err(|issues| AppError::validation("Validation error", issues))?; + let updated = db::update_by_id::(&state.db, ticket.id, &data, true) + .await + .or_500(FAILED)?; + // Changement de statut : entrée d'historique désactivée dans storage.ts + Ok(json_or_empty(updated)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete ticket"; + if !["admin", "directeur"].contains(&user.role()) { + return Err(AppError::forbidden("Insufficient permissions to delete tickets")); + } + let ticket = find_ticket(&state, &user, &id, FAILED, None).await?; + db::delete_by_id::(&state.db, ticket.id) + .await + .or_500(FAILED)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +async fn add_history(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to add comment"; + if !EDITORS.contains(&user.role()) { + return Err(AppError::forbidden("Insufficient permissions to add comments")); + } + find_ticket( + &state, + &user, + &id, + FAILED, + Some("Access denied to add comments to this ticket"), + ) + .await?; + // addSavTicketHistory n'enregistre rien et renvoie null + Ok((StatusCode::CREATED, Json(Value::Null)).into_response()) +} + +async fn stats(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + const FAILED: &str = "Failed to fetch stats"; + let group_ids = scope(&user, &query, FAILED)?; + let mut sql = QueryBuilder::::new( + "SELECT count(*), + count(*) filter (where status = 'nouveau'), + count(*) filter (where status in ('en_cours', 'attente_pieces', 'attente_echange')), + count(*) filter (where status in ('resolu', 'ferme')), + count(*) filter (where priority = 'critique') + FROM sav_tickets", + ); + if !group_ids.is_empty() { + sql.push(" WHERE group_id = ANY(").push_bind(group_ids).push(")"); + } + let (total, new, in_progress, resolved, critical): (i64, i64, i64, i64, i64) = + sql.build_query_as().fetch_one(&state.db).await.or_500(FAILED)?; + Ok(Json(json!({ + "totalTickets": total, + "newTickets": new, + "inProgressTickets": in_progress, + "resolvedTickets": resolved, + "criticalTickets": critical, + })) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numero_de_ticket_comme_node() { + assert_eq!(ticket_number(2026, 0), "SAV-2026-0001"); + assert_eq!(ticket_number(2026, 5), "SAV-2026-0051"); + assert_eq!(ticket_number(2026, 123), "SAV-2026-1231"); + } + + #[test] + fn schema_de_creation() { + let issues = insert_schema() + .parse(&json!({ "priority": "urgente" })) + .unwrap_err(); + let paths: Vec<&str> = issues + .iter() + .map(|issue| issue["path"][0].as_str().unwrap()) + .collect(); + assert_eq!( + paths, + [ + "supplierId", + "groupId", + "productGencode", + "productDesignation", + "problemDescription", + "priority", + "createdBy" + ] + ); + } +} diff --git a/server-rs/src/modules/session_routes.rs b/server-rs/src/modules/session_routes.rs new file mode 100644 index 0000000..fccf48f --- /dev/null +++ b/server-rs/src/modules/session_routes.rs @@ -0,0 +1,647 @@ +// Connexion et session : POST /api/login, GET et POST /api/logout, GET /api/user, +// GET /api/default-credentials-check (server/localAuth.ts : c'est lui que la +// production exécute ; localAuth.production.ts n'est jamais chargé). +// +// Les sessions restent celles d'express-session + connect-pg-simple, dans les +// deux sens tant que les deux serveurs tournent : même ligne dans la table +// sessions, même cookie connect.sid signé (docs/PLAN-MIGRATION-RUST.md, +// « Sessions partagées entre Node et Rust »). La lecture de la session est dans +// crate::auth. +// +// Non reporté (décision R3, audit AUTH-04) : le mot de passe de développement +// « admin.salt » accepté pour « admin », et la création du compte admin/admin au +// démarrage. Non reporté non plus : la sauvegarde quotidienne lancée après +// chaque connexion (backupService.checkAndPerformDailyBackup) ; la vérification +// horaire de Node continue de la faire. + +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; +use rand::RngCore; +use serde_json::{Value, json}; +use subtle::ConstantTimeEq; + +use crate::AppState; +use crate::auth::{self, MaybeUser, SESSION_COOKIE}; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError}; +use crate::js; +use crate::request::JsonBody; +use crate::schema::UsersRow; + +pub fn routes() -> Router { + Router::new() + .route("/api/login", post(login)) + .route("/api/logout", get(logout).post(logout)) + .route("/api/user", get(current_user)) + .route("/api/default-credentials-check", get(default_credentials_check)) +} + +const REFUSED: &str = "Identifiant ou mot de passe incorrect"; + +// ---------- Mots de passe (crypto.scrypt de Node) ---------- + +/// N = 16384, r = 8, p = 1, clé de 64 octets : les valeurs par défaut de crypto.scrypt +const SCRYPT_LOG_N: u8 = 14; +const KEY_LEN: usize = 64; + +fn derive(password: &str, salt: &str) -> [u8; KEY_LEN] { + let params = scrypt::Params::new(SCRYPT_LOG_N, 8, 1, KEY_LEN).expect("paramètres scrypt valides"); + let mut key = [0u8; KEY_LEN]; + scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut key).expect("longueur de clé valide"); + key +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Buffer.from(texte, "hex") : paires décodées jusqu'au premier caractère non +/// hexadécimal, demi-octet final ignoré +fn node_hex(text: &str) -> Vec { + let digit = |c: u8| (c as char).to_digit(16).map(|d| d as u8); + let mut out = Vec::with_capacity(text.len() / 2); + for pair in text.as_bytes().chunks_exact(2) { + match (digit(pair[0]), digit(pair[1])) { + (Some(high), Some(low)) => out.push((high << 4) | low), + _ => break, + } + } + out +} + +fn hash_with_salt(password: &str, salt: &str) -> String { + format!("{}.{salt}", to_hex(&derive(password, salt))) +} + +/// hashPassword (server/localAuth.ts) : « empreinte_hex.sel_hex », le sel +/// hexadécimal (16 octets aléatoires) servant tel quel de texte. Calcul de +/// plusieurs dizaines de millisecondes : hors des fils de tokio. +pub async fn hash_password(password: String) -> String { + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + let salt = to_hex(&salt); + tokio::task::spawn_blocking(move || hash_with_salt(&password, &salt)) + .await + .expect("calcul scrypt") +} + +/// comparePasswords (server/localAuth.ts), sans l'accès de développement +/// « admin.salt » (AUTH-04) ; comparaison en temps constant +fn verify_password(supplied: &str, stored: &str) -> bool { + let mut parts = stored.split('.'); + let (Some(hashed), Some(salt)) = (parts.next(), parts.next()) else { + return false; + }; + if hashed.is_empty() || salt.is_empty() { + return false; + } + let expected = node_hex(hashed); + // timingSafeEqual lève une erreur quand les longueurs diffèrent : refus + if expected.len() != KEY_LEN { + return false; + } + expected.ct_eq(&derive(supplied, salt)).into() +} + +// ---------- Session d'express-session ---------- + +/// cookie.maxAge de localAuth.ts : 24 heures +const MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000; + +/// uid-safe(24), comme express-session : 32 caractères base64url +fn new_session_id() -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Colonne sess : la session d'express-session (cookie) et l'utilisateur de passport +fn session_json(user_id: &str, expires: &DateTime) -> Value { + json!({ + "cookie": { + "originalMaxAge": MAX_AGE_MS, + "expires": js::iso(expires), + "secure": false, + "httpOnly": true, + "path": "/", + "sameSite": "lax", + }, + "passport": { "user": user_id }, + }) +} + +/// Colonne expire : connect-pg-simple écrit to_timestamp(Math.ceil(expires / 1000)) +fn expire_seconds(expires: &DateTime) -> i64 { + let millis = expires.timestamp_millis(); + millis.div_euclid(1000) + i64::from(millis.rem_euclid(1000) != 0) +} + +/// Date.prototype.toUTCString +fn http_date(date: &DateTime) -> String { + date.format("%a, %d %b %Y %H:%M:%S GMT").to_string() +} + +/// encodeURIComponent, utilisé par le paquet cookie pour la valeur +fn encode_uri_component(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Set-Cookie d'express-session : s:., mêmes attributs +fn session_cookie(sid: &str, secret: &str, expires: &DateTime) -> String { + let value = encode_uri_component(&format!("s:{sid}.{}", auth::sign(sid, secret))); + format!( + "{SESSION_COOKIE}={value}; Path=/; Expires={}; HttpOnly; SameSite=Lax", + http_date(expires) + ) +} + +/// res.clearCookie('connect.sid', { path: '/' }) d'Express 4 +fn cleared_cookie() -> String { + format!("{SESSION_COOKIE}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT") +} + +/// Erreur transmise à next(err) : le gestionnaire d'erreurs de Node renvoie son message +fn server_error(error: sqlx::Error) -> AppError { + tracing::error!(%error, "session"); + let message = match &error { + sqlx::Error::Database(database) => database.message().to_string(), + _ => "Internal Server Error".to_string(), + }; + AppError::internal(&message) +} + +/// Session détruite par req.session.regenerate / destroy. express-session n'a +/// chargé (donc ne détruit) que la session encore valide du cookie. +async fn destroy_session(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> { + if let Some(sid) = auth::session_id(headers, &state.config.session_secret) { + sqlx::query("DELETE FROM sessions WHERE sid = $1 AND expire >= now()") + .bind(sid) + .execute(&state.db) + .await + .map_err(server_error)?; + } + Ok(()) +} + +// ---------- Connexion ---------- + +/// lookup(req.body, champ) || lookup(req.query, champ) de passport-local : une +/// valeur simple (texte, nombre, booléen) non vide ; un objet, un tableau ou null +/// ne compte pas +fn credential(body: &Value, query: Option<&str>, field: &str) -> Option { + let truthy = |value: &&Value| match value { + Value::String(text) => !text.is_empty(), + Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0), + Value::Bool(flag) => *flag, + _ => false, + }; + body.as_object() + .and_then(|object| object.get(field)) + .filter(truthy) + .cloned() + .or_else(|| query_value(query, field).map(Value::String)) +} + +/// req.query[champ] (analyseur qs d'Express) : une seule occurrence donne du +/// texte ; répété ou entre crochets, c'est un tableau ou un objet, ignoré +fn query_value(query: Option<&str>, field: &str) -> Option { + let mut found = None; + let mut count = 0; + let bracketed = format!("{field}["); + for (key, value) in form_urlencoded::parse(query?.as_bytes()) { + if key == field { + count += 1; + found = Some(value.into_owned()); + } else if key.starts_with(&bracketed) { + return None; + } + } + found.filter(|value| count == 1 && !value.is_empty()) +} + +async fn login( + State(state): State, + headers: HeaderMap, + uri: Uri, + JsonBody(body): JsonBody, +) -> ApiResult { + let (Some(username), Some(password)) = ( + credential(&body, uri.query(), "username"), + credential(&body, uri.query(), "password"), + ) else { + return Err(AppError::bad_request("Missing credentials")); + }; + + // storage.getUserByUsername : un nombre ou un booléen part en texte (node-pg) + let user: Option = sqlx::query_as(&UsersRow::select("WHERE username = $1")) + .bind(db::pg_text(&username)) + .fetch_optional(&state.db) + .await + .map_err(server_error)?; + let Some((user, stored)) = user.and_then(|user| { + let stored = user.password.clone().filter(|password| !password.is_empty())?; + Some((user, stored)) + }) else { + return Err(AppError::bad_request(REFUSED)); + }; + // Un mot de passe qui n'est pas du texte fait échouer crypto.scrypt : refusé + let valid = match password { + Value::String(password) => tokio::task::spawn_blocking(move || verify_password(&password, &stored)) + .await + .unwrap_or(false), + _ => false, + }; + if !valid { + return Err(AppError::bad_request(REFUSED)); + } + + // req.login : passport régénère la session (l'ancienne est détruite), puis + // express-session l'enregistre et envoie le cookie + destroy_session(&state, &headers).await?; + let sid = new_session_id(); + let expires = Utc::now() + Duration::milliseconds(MAX_AGE_MS); + sqlx::query( + "INSERT INTO sessions (sess, expire, sid) VALUES ($1, to_timestamp($2), $3) + ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire", + ) + .bind(sqlx::types::Json(session_json(&user.id, &expires))) + .bind(expire_seconds(&expires) as f64) + .bind(&sid) + .execute(&state.db) + .await + .map_err(server_error)?; + + let mut response = Json(json!({ + "id": user.id, + "username": user.username, + "email": user.email, + "firstName": user.first_name, + "lastName": user.last_name, + "role": user.role, + "passwordChanged": user.password_changed, + })) + .into_response(); + if let Ok(cookie) = HeaderValue::from_str(&session_cookie(&sid, &state.config.session_secret, &expires)) { + response.headers_mut().insert(header::SET_COOKIE, cookie); + } + Ok(response) +} + +// ---------- Déconnexion ---------- + +/// Corps de res.redirect : celui que le client accepte (res.format avec text, +/// html et default, paquets accepts / negotiator) +#[derive(Debug, PartialEq)] +enum RedirectBody { + Text, + Html, + Empty, +} + +/// Une entrée de l'en-tête Accept (negotiator, parseMediaType) +struct AcceptSpec { + kind: String, + subtype: String, + params: Vec<(String, String)>, + q: f64, + index: usize, +} + +fn parse_accept(accept: &str) -> Vec { + split_unquoted(accept, ',') + .iter() + .enumerate() + .filter_map(|(index, entry)| { + let entry = entry.trim(); + let (media, rest) = match entry.split_once(';') { + Some((media, rest)) => (media.trim_end(), Some(rest)), + None => (entry, None), + }; + let (kind, subtype) = media.split_once('/')?; + if kind.is_empty() || subtype.is_empty() || media.contains(char::is_whitespace) { + return None; + } + let mut params = Vec::new(); + let mut q = 1.0; + for pair in rest.map(|rest| split_unquoted(rest, ';')).unwrap_or_default() { + let pair = pair.trim(); + let (key, value) = match pair.split_once('=') { + Some((key, value)) => (key.to_lowercase(), value), + None => (pair.to_lowercase(), ""), + }; + let value = if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + &value[1..value.len() - 1] + } else { + value + }; + if key == "q" { + q = parse_float(value); + break; + } + params.push((key, value.to_string())); + } + Some(AcceptSpec { + kind: kind.to_string(), + subtype: subtype.to_string(), + params, + q, + index, + }) + }) + .collect() +} + +/// Découpe sur un séparateur hors des guillemets +fn split_unquoted(text: &str, separator: char) -> Vec { + let mut parts = vec![String::new()]; + let mut quoted = false; + for c in text.chars() { + if c == '"' { + quoted = !quoted; + } + if c == separator && !quoted { + parts.push(String::new()); + } else { + parts.last_mut().expect("au moins une partie").push(c); + } + } + parts +} + +/// parseFloat : nombre décimal de tête, NaN sinon +fn parse_float(text: &str) -> f64 { + let text = text.trim_start(); + let end = text + .char_indices() + .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && (*c == '-' || *c == '+'))) + .map(|(i, c)| i + c.len_utf8()) + .last() + .unwrap_or(0); + text[..end].parse().unwrap_or(f64::NAN) +} + +/// accepts(req).types(['text', 'html']) : text/plain ou text/html, ou aucun +fn negotiate(accept: Option<&str>) -> RedirectBody { + let Some(accept) = accept.filter(|accept| !accept.is_empty()) else { + return RedirectBody::Text; + }; + let specs = parse_accept(accept); + // (q, spécificité, index de l'entrée Accept, index du type proposé) + let mut ranked: Vec<(f64, u8, i64, usize)> = ["plain", "html"] + .iter() + .enumerate() + .map(|(candidate, subtype)| { + // getMediaTypePriority : l'entrée la plus spécifique, puis de plus + // grand q, puis la dernière ; un q illisible (NaN) ne départage pas + let mut best = (0.0, 0u8, -1i64); + for spec in &specs { + let mut s = 0u8; + if spec.kind.eq_ignore_ascii_case("text") { + s |= 4; + } else if spec.kind != "*" { + continue; + } + if spec.subtype.eq_ignore_ascii_case(subtype) { + s |= 2; + } else if spec.subtype != "*" { + continue; + } + if !spec.params.is_empty() { + // Les types proposés n'ont pas de paramètre + if spec + .params + .iter() + .all(|(_, value)| value == "*" || value.is_empty()) + { + s |= 1; + } else { + continue; + } + } + let o = spec.index as i64; + let replaces = if s != best.1 { + s > best.1 + } else if spec.q != best.0 && !spec.q.is_nan() && !best.0.is_nan() { + spec.q > best.0 + } else { + o > best.2 + }; + if replaces { + best = (spec.q, s, o); + } + } + (best.0, best.1, best.2, candidate) + }) + .filter(|(q, ..)| *q > 0.0) + .collect(); + ranked.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.1.cmp(&a.1)) + .then(a.2.cmp(&b.2)) + .then(a.3.cmp(&b.3)) + }); + match ranked.first().map(|best| best.3) { + Some(0) => RedirectBody::Text, + Some(_) => RedirectBody::Html, + None => RedirectBody::Empty, + } +} + +/// res.clearCookie puis res.redirect('/auth') d'Express 4 +fn redirect_to_auth(headers: &HeaderMap) -> Response { + let accept = headers.get(header::ACCEPT).and_then(|value| value.to_str().ok()); + let (content_type, body) = match negotiate(accept) { + RedirectBody::Text => (Some("text/plain; charset=utf-8"), "Found. Redirecting to /auth"), + RedirectBody::Html => ( + Some("text/html; charset=utf-8"), + "

Found. Redirecting to /auth

", + ), + RedirectBody::Empty => (None, ""), + }; + let mut response = (StatusCode::FOUND, body).into_response(); + let out = response.headers_mut(); + out.remove(header::CONTENT_TYPE); + if let Some(content_type) = content_type { + out.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + } + out.insert(header::LOCATION, HeaderValue::from_static("/auth")); + out.insert(header::VARY, HeaderValue::from_static("Accept")); + if let Ok(cookie) = HeaderValue::from_str(&cleared_cookie()) { + out.insert(header::SET_COOKIE, cookie); + } + response +} + +/// req.logout puis req.session.destroy : la session du cookie disparaît, que +/// Node ou Rust l'ait ouverte +async fn logout(State(state): State, headers: HeaderMap) -> ApiResult { + destroy_session(&state, &headers).await?; + Ok(redirect_to_auth(&headers)) +} + +// ---------- Utilisateur connecté ---------- + +async fn current_user(MaybeUser(user): MaybeUser) -> ApiResult { + let Some(current) = user else { + return Err(AppError::new(StatusCode::UNAUTHORIZED, "Non authentifié")); + }; + let user = ¤t.user; + Ok(Json(json!({ + "id": user.id, + "username": user.username, + "email": user.email, + "firstName": user.first_name, + "lastName": user.last_name, + "role": user.role, + "passwordChanged": user.password_changed, + "userGroups": current.user_groups, + })) + .into_response()) +} + +/// Identifiants par défaut à afficher : compte « admin » dont le mot de passe n'a +/// pas été changé (true aussi en cas d'erreur) +async fn default_credentials_check(State(state): State) -> Response { + let admin: Result,)>, sqlx::Error> = + sqlx::query_as("SELECT password_changed FROM users WHERE username = 'admin'") + .fetch_optional(&state.db) + .await; + let show_default = match admin { + Ok(Some((changed,))) => changed != Some(true), + Ok(None) => false, + Err(error) => { + tracing::error!(%error, "identifiants par défaut"); + true + } + }; + Json(json!({ "showDefault": show_default })).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + // Produit par hashPassword de server/localAuth.ts (Node 20) + const NODE_HASH: &str = "b72e456b13b111145d2092d3d54bb640795e2635232a2d87227c38456ee4adca56d349fb141f212c51954c8f86b39975aabb3067ca8527bc8197e808f76fadd7.a6e8e83d32cdaaf698eca9e18fb65909"; + + #[test] + fn verifie_une_empreinte_produite_par_node() { + assert!(verify_password("mot-de-passe-été", NODE_HASH)); + assert!(!verify_password("mot-de-passe-ete", NODE_HASH)); + assert!(!verify_password("", NODE_HASH)); + } + + #[test] + fn empreinte_identique_a_celle_de_node() { + // crypto.scryptSync('banc-admin', '0123456789abcdef0123456789abcdef', 64) + assert_eq!( + hash_with_salt("banc-admin", "0123456789abcdef0123456789abcdef"), + "b03c7493b9f7715b6936bf0197882c85221196dfbddd7800c99e9175e85f1697c65cbd4fcb9cd175e6612075eeddfb5056728b15d45d0fd4602310c2b48791d7.0123456789abcdef0123456789abcdef" + ); + } + + #[tokio::test] + async fn nouvelle_empreinte_verifiable() { + let stored = hash_password("secret".into()).await; + let (hash, salt) = stored.split_once('.').unwrap(); + assert_eq!((hash.len(), salt.len()), (128, 32)); + assert!(verify_password("secret", &stored)); + } + + #[test] + fn empreintes_mal_formees_refusees() { + // L'accès de développement de Node (AUTH-04) n'est pas reporté + assert!(!verify_password("admin", "admin.salt")); + assert!(!verify_password("x", "sans-point")); + assert!(!verify_password("x", ".sel")); + assert!(!verify_password("x", "abcd.")); + // Buffer.from(…, "hex") s'arrête au premier caractère invalide + assert_eq!(node_hex("admin").len(), 1); + assert_eq!(node_hex("abc").len(), 1); + assert_eq!(node_hex("0g12").len(), 0); + assert_eq!(node_hex("aabbz1"), vec![0xaa, 0xbb]); + } + + #[test] + fn session_au_format_d_express_session() { + // Connexion de « manager » sur Node : cookie et ligne de la table sessions + let sid = "BulI376e_Tsc2DIl_gcIiFo4700H27-7"; + let expires = Utc.timestamp_millis_opt(1_792_151_360_449).unwrap(); + assert_eq!(js::iso(&expires), "2026-10-16T11:49:20.449Z"); + assert_eq!( + session_cookie(sid, "banc-secret-de-session", &expires), + "connect.sid=s%3ABulI376e_Tsc2DIl_gcIiFo4700H27-7.RnXJIMwqvuD90YKAO4k0RQnTWgzTi%2BzRdqTrrY5cvho; Path=/; Expires=Fri, 16 Oct 2026 11:49:20 GMT; HttpOnly; SameSite=Lax" + ); + let node_sess: Value = serde_json::from_str( + r#"{"cookie": {"path": "/", "secure": false, "expires": "2026-10-16T11:49:20.449Z", "httpOnly": true, "sameSite": "lax", "originalMaxAge": 86400000}, "passport": {"user": "bench_manager"}}"#, + ) + .unwrap(); + assert_eq!(session_json("bench_manager", &expires), node_sess); + // expire = 2026-10-16 11:49:21 (arrondi à la seconde supérieure) + assert_eq!( + expire_seconds(&expires), + Utc.with_ymd_and_hms(2026, 10, 16, 11, 49, 21) + .unwrap() + .timestamp() + ); + let whole = Utc.timestamp_millis_opt(1_792_151_360_000).unwrap(); + assert_eq!(expire_seconds(&whole), 1_792_151_360); + assert_eq!(new_session_id().len(), 32); + } + + #[test] + fn cookie_efface_comme_express() { + assert_eq!( + cleared_cookie(), + "connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT" + ); + assert_eq!(encode_uri_component("s:abc+/=.def"), "s%3Aabc%2B%2F%3D.def"); + } + + #[test] + fn corps_de_la_redirection_selon_accept() { + assert_eq!(negotiate(None), RedirectBody::Text); + assert_eq!(negotiate(Some("*/*")), RedirectBody::Text); + assert_eq!( + negotiate(Some("text/html,application/xhtml+xml,*/*;q=0.8")), + RedirectBody::Html + ); + assert_eq!(negotiate(Some("application/json")), RedirectBody::Empty); + assert_eq!(negotiate(Some("text/*")), RedirectBody::Text); + assert_eq!(negotiate(Some("text/plain;q=0.5, text/html")), RedirectBody::Html); + assert_eq!(negotiate(Some("text/html;q=0")), RedirectBody::Empty); + } + + #[test] + fn identifiants_comme_passport_local() { + let body = json!({ "username": "manager", "password": 123, "vide": "", "liste": ["x"] }); + assert_eq!(credential(&body, None, "username"), Some(json!("manager"))); + assert_eq!(credential(&body, None, "password"), Some(json!(123))); + assert_eq!(credential(&body, None, "vide"), None); + assert_eq!(credential(&body, None, "liste"), None); + assert_eq!( + credential(&json!([]), Some("username=a&x=1"), "username"), + Some(json!("a")) + ); + assert_eq!( + credential(&json!({}), Some("username=a&username=b"), "username"), + None + ); + assert_eq!(credential(&json!({}), Some("username[0]=a"), "username"), None); + } +} diff --git a/server-rs/src/modules/stats.rs b/server-rs/src/modules/stats.rs new file mode 100644 index 0000000..4b1a975 --- /dev/null +++ b/server-rs/src/modules/stats.rs @@ -0,0 +1,380 @@ +// Statistiques : /api/stats/by-group, /by-supplier (comptages des pages Magasins +// et Fournisseurs), /monthly, /yearly (server/routes.ts ; storage +// getOrderDeliveryStatsByGroup, getOrderDeliveryStatsBySupplier, +// getMonthlyStats, getYearlyStats). +// Comme Node aujourd'hui : pour /monthly et /yearly, un compte sans magasin +// voit les chiffres de tous les magasins (liste vide = aucun filtre), et toute +// erreur SQL (année ou mois illisibles, magasin hors limites) rend des zéros +// au lieu d'une erreur. + +use axum::extract::State; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get}; +use chrono::Datelike; +use serde::Serialize; +use serde_json::json; +use sqlx::PgPool; +use sqlx::postgres::PgArguments; +use sqlx::{Arguments, Postgres}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::js_int4; +use crate::error::{ApiResult, OrInternal}; +use crate::js; +use crate::request::JsQuery; + +pub fn routes() -> Router { + Router::new() + .route("/api/stats/by-group", get(by_group)) + .route("/api/stats/by-supplier", get(by_supplier)) + .route("/api/stats/monthly", get(monthly)) + .route("/api/stats/yearly", get(yearly)) +} + +/// Magasins retenus, nombres tels que parseInt les rend (NaN compris) +type GroupIds = Vec; + +/// resolveStatsGroupIds : None = refus (liste vide), Some(None) = tous les magasins +fn resolve_stats_group_ids(user: &CurrentUser, store_id: Option<&str>) -> Option> { + let user_group_ids: GroupIds = user.group_ids().into_iter().map(f64::from).collect(); + let requested = store_id.map(|raw| js::parse_int(raw).unwrap_or(f64::NAN)); + if user.is_admin() { + return Some(requested.map(|id| vec![id])); + } + match requested { + // includes : NaN n'est jamais trouvé + Some(id) => user_group_ids.contains(&id).then(|| Some(vec![id])), + None => (!user_group_ids.is_empty()).then_some(Some(user_group_ids)), + } +} + +/// Magasins des statistiques mensuelles et annuelles (sans refus) +fn monthly_group_ids(user: &CurrentUser, store_id: Option<&str>) -> Option { + let requested = store_id.map(|raw| js::parse_int(raw).unwrap_or(f64::NAN)); + if user.is_admin() { + return requested.map(|id| vec![id]); + } + let user_group_ids: GroupIds = user.group_ids().into_iter().map(f64::from).collect(); + match requested { + Some(id) if user_group_ids.contains(&id) => Some(vec![id]), + _ => Some(user_group_ids), + } +} + +/// inArray(colonne, ids) : liste de paramètres, comme Drizzle ; None si +/// PostgreSQL refuserait l'un des identifiants +fn in_list(arguments: &mut PgArguments, ids: &[f64]) -> Option { + let mut placeholders = Vec::new(); + for id in ids { + arguments.add(js_int4(*id)?).ok()?; + placeholders.push(format!("${}", arguments.len())); + } + Some(format!("({})", placeholders.join(", "))) +} + +/// EntityActivityStats +#[derive(Serialize)] +struct ActivityStats { + id: i32, + orders: i64, + deliveries: i64, + delivered: i64, +} + +/// Comptages par magasin ou par fournisseur (colonne `key`), sans tri : l'ordre +/// est celui de la base, puis les lignes de livraisons sans commande à la suite +async fn activity_stats( + db: &PgPool, + key: &str, + group_ids: Option<&GroupIds>, +) -> Result, sqlx::Error> { + let scoped = group_ids.filter(|ids| !ids.is_empty()); + let query = |table: &str, extra: &str| -> Result<(String, PgArguments), sqlx::Error> { + let mut arguments = PgArguments::default(); + let filter = match scoped { + Some(ids) => { + let list = in_list(&mut arguments, ids) + .ok_or_else(|| sqlx::Error::Protocol("invalid input syntax for type integer".into()))?; + format!("WHERE {table}.group_id IN {list}") + } + None => String::new(), + }; + Ok(( + format!("SELECT {table}.{key}, count(*){extra} FROM {table} {filter} GROUP BY {table}.{key}"), + arguments, + )) + }; + let (orders_sql, orders_arguments) = query("orders", "")?; + let (deliveries_sql, deliveries_arguments) = query( + "deliveries", + ", count(*) filter (where deliveries.status = 'delivered')", + )?; + let (order_rows, delivery_rows) = tokio::join!( + sqlx::query_as_with::, i64), _>(&orders_sql, orders_arguments) + .persistent(false) + .fetch_all(db), + sqlx::query_as_with::, i64, i64), _>(&deliveries_sql, deliveries_arguments) + .persistent(false) + .fetch_all(db), + ); + let (order_rows, delivery_rows) = (order_rows?, delivery_rows?); + + // mergeActivityStats : Map par identifiant, dans l'ordre d'apparition + let mut stats: Vec = Vec::new(); + fn entry(stats: &mut Vec, id: i32) -> &mut ActivityStats { + let index = match stats.iter().position(|entry| entry.id == id) { + Some(index) => index, + None => { + stats.push(ActivityStats { + id, + orders: 0, + deliveries: 0, + delivered: 0, + }); + stats.len() - 1 + } + }; + &mut stats[index] + } + for (id, count) in order_rows { + if let Some(id) = id { + entry(&mut stats, id).orders = count; + } + } + for (id, count, delivered) in delivery_rows { + if let Some(id) = id { + let entry = entry(&mut stats, id); + entry.deliveries = count; + entry.delivered = delivered; + } + } + Ok(stats) +} + +async fn by_group(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let Some(group_ids) = resolve_stats_group_ids(&user, query.truthy("storeId")) else { + return Ok(Json(json!([])).into_response()); + }; + let stats = activity_stats(&state.db, "group_id", group_ids.as_ref()) + .await + .or_500("Failed to fetch group stats")?; + Ok(Json(stats).into_response()) +} + +async fn by_supplier(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let Some(group_ids) = resolve_stats_group_ids(&user, query.truthy("storeId")) else { + return Ok(Json(json!([])).into_response()); + }; + let stats = activity_stats(&state.db, "supplier_id", group_ids.as_ref()) + .await + .or_500("Failed to fetch supplier stats")?; + Ok(Json(stats).into_response()) +} + +/// Chiffres de /monthly et /yearly +#[derive(Serialize, Default)] +struct PeriodStats { + #[serde(rename = "ordersCount")] + orders_count: i64, + #[serde(rename = "deliveriesCount")] + deliveries_count: i64, + #[serde(rename = "pendingOrdersCount")] + pending_orders_count: i64, + #[serde(rename = "averageDeliveryTime", serialize_with = "js_number")] + average_delivery_time: f64, + #[serde(rename = "totalPalettes")] + total_palettes: i64, + #[serde(rename = "totalPackages")] + total_packages: i64, +} + +fn js_number(value: &f64, serializer: S) -> Result { + js::number_value(*value).serialize(serializer) +} + +/// `${nombre}`.padStart(2, '0') +fn pad2(number: f64) -> String { + format!("{:0>2}", js::number_string(number)) +} + +/// Bornes [début, fin) du mois, en texte comme Node les construit +fn month_range(year: f64, month: f64) -> (String, String) { + let start = format!("{}-{}-01", js::number_string(year), pad2(month)); + let end = if month == 12.0 { + format!("{}-01-01", js::number_string(year + 1.0)) + } else { + format!("{}-{}-01", js::number_string(year), pad2(month + 1.0)) + }; + (start, end) +} + +/// Bornes [début, fin) de l'année +fn year_range(year: f64) -> (String, String) { + ( + format!("{}-01-01", js::number_string(year)), + format!("{}-01-01", js::number_string(year + 1.0)), + ) +} + +/// Comptages d'une période ; `deliveries_by_delivered_date` : les livraisons +/// sont comptées à leur date de livraison (année) ou à leur date prévue (mois) +async fn period_stats( + db: &PgPool, + (start, end): (String, String), + group_ids: Option<&GroupIds>, + deliveries_by_delivered_date: bool, +) -> Result { + let scoped = group_ids.filter(|ids| !ids.is_empty()); + // Requête avec bornes (texte converti par PostgreSQL) et filtre de magasins + let build = |sql: &str, bounds: bool, group_column: &str| -> Result<(String, PgArguments), sqlx::Error> { + let mut arguments = PgArguments::default(); + if bounds { + arguments.add(start.as_str()).map_err(sqlx::Error::Encode)?; + arguments.add(end.as_str()).map_err(sqlx::Error::Encode)?; + } + let filter = match scoped { + Some(ids) => { + let list = in_list(&mut arguments, ids) + .ok_or_else(|| sqlx::Error::Protocol("invalid input syntax for type integer".into()))?; + format!(" AND {group_column} IN {list}") + } + None => String::new(), + }; + Ok((sql.replace("{filter}", &filter), arguments)) + }; + let deliveries_range = if deliveries_by_delivered_date { + "deliveries.delivered_date >= $1::timestamp AND deliveries.delivered_date < $2::timestamp" + } else { + "deliveries.scheduled_date >= $1::date AND deliveries.scheduled_date < $2::date" + }; + let (orders_sql, orders_arguments) = build( + "SELECT count(*) FROM orders WHERE (orders.planned_date >= $1::date AND orders.planned_date < $2::date{filter})", + true, + "orders.group_id", + )?; + let (deliveries_sql, deliveries_arguments) = build( + &format!("SELECT count(*) FROM deliveries WHERE ({deliveries_range}{{filter}})"), + true, + "deliveries.group_id", + )?; + let (pending_sql, pending_arguments) = build( + "SELECT count(*) FROM orders WHERE (orders.status = 'pending'{filter})", + false, + "orders.group_id", + )?; + let (delivered_sql, delivered_arguments) = build( + "SELECT COALESCE(SUM(CAST(deliveries.quantity as INTEGER)), 0), + COALESCE(COUNT(*), 0), + COALESCE(AVG(EXTRACT(DAY FROM (deliveries.delivered_date - orders.planned_date))), 0)::text + FROM deliveries + INNER JOIN orders ON deliveries.order_id = orders.id + WHERE ((deliveries.delivered_date >= $1::timestamp AND deliveries.delivered_date < $2::timestamp + AND deliveries.status = 'delivered' AND deliveries.delivered_date IS NOT NULL{filter}) + AND deliveries.order_id IS NOT NULL AND orders.planned_date IS NOT NULL)", + true, + "deliveries.group_id", + )?; + // Promise.all : les quatre requêtes vont à leur terme, la première erreur l'emporte + let (orders, deliveries, pending, delivered) = tokio::join!( + sqlx::query_as_with::(&orders_sql, orders_arguments).fetch_one(db), + sqlx::query_as_with::(&deliveries_sql, deliveries_arguments).fetch_one(db), + sqlx::query_as_with::(&pending_sql, pending_arguments).fetch_one(db), + sqlx::query_as_with::(&delivered_sql, delivered_arguments) + .fetch_one(db), + ); + let ((orders,), (deliveries,), (pending,), (palettes, packages, average)) = + (orders?, deliveries?, pending?, delivered?); + Ok(PeriodStats { + orders_count: orders, + deliveries_count: deliveries, + pending_orders_count: pending, + // Number(avgDelay) : moyenne numeric relue en texte + average_delivery_time: js::to_number(&json!(average)), + total_palettes: palettes, + total_packages: packages, + }) +} + +/// year ? parseInt(year) : année en cours à Paris (décision R5) +fn year_param(query: &JsQuery) -> f64 { + match query.truthy("year") { + Some(year) => js::parse_int(year).unwrap_or(f64::NAN), + None => f64::from(crate::clock::today().year()), + } +} + +async fn monthly(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let year = year_param(&query); + let month = match query.truthy("month") { + Some(month) => js::parse_int(month).unwrap_or(f64::NAN), + None => f64::from(crate::clock::today().month()), + }; + let group_ids = monthly_group_ids(&user, query.truthy("storeId")); + let stats = period_stats(&state.db, month_range(year, month), group_ids.as_ref(), false) + .await + .unwrap_or_else(|error| { + tracing::error!(%error, "Erreur calcul statistiques"); + PeriodStats::default() + }); + Ok(Json(stats).into_response()) +} + +async fn yearly(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + let year = year_param(&query); + let group_ids = monthly_group_ids(&user, query.truthy("storeId")); + let stats = period_stats(&state.db, year_range(year), group_ids.as_ref(), true) + .await + .unwrap_or_else(|error| { + tracing::error!(%error, "Erreur calcul statistiques annuelles"); + PeriodStats::default() + }); + Ok(Json(stats).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bornes_des_periodes_comme_node() { + assert_eq!( + month_range(2026.0, 3.0), + ("2026-03-01".into(), "2026-04-01".into()) + ); + assert_eq!( + month_range(2026.0, 9.0), + ("2026-09-01".into(), "2026-10-01".into()) + ); + assert_eq!( + month_range(2026.0, 12.0), + ("2026-12-01".into(), "2027-01-01".into()) + ); + assert_eq!( + month_range(2026.0, 13.0), + ("2026-13-01".into(), "2026-14-01".into()) + ); + assert_eq!( + month_range(f64::NAN, 3.0), + ("NaN-03-01".into(), "NaN-04-01".into()) + ); + assert_eq!( + month_range(2026.0, -1.0), + ("2026--1-01".into(), "2026-00-01".into()) + ); + assert_eq!(year_range(2025.0), ("2025-01-01".into(), "2026-01-01".into())); + } + + #[test] + fn statistiques_au_format_javascript() { + let stats = PeriodStats { + orders_count: 3, + average_delivery_time: 1.5, + ..PeriodStats::default() + }; + assert_eq!( + serde_json::to_string(&stats).unwrap(), + r#"{"ordersCount":3,"deliveriesCount":0,"pendingOrdersCount":0,"averageDeliveryTime":1.5,"totalPalettes":0,"totalPackages":0}"# + ); + } +} diff --git a/server-rs/src/modules/suppliers.rs b/server-rs/src/modules/suppliers.rs new file mode 100644 index 0000000..484a8cb --- /dev/null +++ b/server-rs/src/modules/suppliers.rs @@ -0,0 +1,95 @@ +// Fiches fournisseurs : /api/suppliers (server/routes.ts, « Suppliers routes »). + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::put}; +use serde_json::json; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::SuppliersRow; +use crate::zod::Object; + +pub fn routes() -> Router { + Router::new() + .route("/api/suppliers", get(list).post(create)) + .route("/api/suppliers/{id}", put(update).delete(remove)) +} + +/// insertSupplierSchema +fn insert_schema() -> Object { + SuppliersRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +fn forbidden() -> AppError { + AppError::forbidden("Insufficient permissions") +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + if !["admin", "manager", "directeur", "employee"].contains(&user.role()) { + return Err(forbidden()); + } + let suppliers: Vec = sqlx::query_as(&SuppliersRow::select("ORDER BY name")) + .fetch_all(&state.db) + .await + .or_500("Failed to fetch suppliers")?; + // ?dlc=true : fournisseurs gérés en DLC seulement + let suppliers: Vec = if query.get("dlc") == Some("true") { + suppliers + .into_iter() + .filter(|s| s.has_dlc == Some(true)) + .collect() + } else { + suppliers + }; + Ok(Json(suppliers).into_response()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + if !["admin", "manager", "directeur"].contains(&user.role()) { + return Err(forbidden()); + } + let data = insert_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Validation failed", issues))?; + let supplier: SuppliersRow = db::insert(&state.db, &data) + .await + .or_500("Failed to create supplier")?; + Ok(Json(supplier).into_response()) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update supplier"; + if !["admin", "manager", "directeur"].contains(&user.role()) { + return Err(forbidden()); + } + let id = int_id(&id, FAILED)?; + let data = insert_schema() + .partial() + .parse(&body) + .map_err(|_| AppError::internal(FAILED))?; + let supplier = db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(supplier)) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete supplier"; + if !["admin", "directeur"].contains(&user.role()) { + return Err(forbidden()); + } + let id = int_id(&id, FAILED)?; + db::delete_by_id::(&state.db, id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Supplier deleted successfully" })).into_response()) +} diff --git a/server-rs/src/modules/tasks.rs b/server-rs/src/modules/tasks.rs new file mode 100644 index 0000000..250cf0e --- /dev/null +++ b/server-rs/src/modules/tasks.rs @@ -0,0 +1,303 @@ +// Tâches : /api/tasks (server/routes.ts, « Tasks routes » ; storage.ts getTasks à +// completeTask). Comme Node aujourd'hui : +// - les corps ne passent par aucun schéma zod : les champs connus de la table sont +// écrits tels quels, les erreurs de la base deviennent des 500 ; +// - seul le magasin est contrôlé pour modifier, terminer ou supprimer une tâche : +// un employé peut modifier ou supprimer celles de son magasin ; +// - un compte sans magasin qui aurait le droit de créer passerait le contrôle du +// magasin (« userGroupIds.length > 0 && … ») : inatteignable avec la matrice actuelle. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::post, routing::put}; +use chrono::{DateTime, Datelike, NaiveDateTime, Timelike, Utc}; +use serde_json::{Value, json}; +use sqlx::{Postgres, QueryBuilder}; + +use crate::AppState; +use crate::access; +use crate::auth::CurrentUser; +use crate::db::{self, Table, columns_of, drizzle_values, node_pg_value}; +use crate::error::{ApiResult, AppError, OrInternal}; +use crate::js; +use crate::json::spread; +use crate::projections::GroupSummary; +use crate::request::{JsQuery, JsonBody, int_id}; +use crate::schema::TasksRow; +use crate::zod::Parsed; + +pub fn routes() -> Router { + Router::new() + .route("/api/tasks", get(list).post(create)) + .route("/api/tasks/{id}", put(update).delete(remove)) + .route("/api/tasks/{id}/complete", post(complete)) +} + +/// Minuit du jour courant à Paris (décision R5), comparé aux dates de début +/// enregistrées à minuit : today.setHours(0, 0, 0, 0) +fn today_midnight() -> NaiveDateTime { + crate::clock::today().and_hms_opt(0, 0, 0).expect("minuit existe") +} + +async fn list(State(state): State, user: CurrentUser, query: JsQuery) -> ApiResult { + // Toute erreur : 500 avec une liste vide + let failed = || AppError::with_body(StatusCode::INTERNAL_SERVER_ERROR, json!([])); + let empty = || Ok(Json(json!([])).into_response()); + + let group_ids: Option> = if user.is_admin() { + // ?storeId illisible : [NaN] fait échouer la requête de Node + match query.truthy("storeId") { + Some(store) => Some(vec![int_id(store, "").map_err(|_| failed())?]), + None => None, + } + } else { + // Hors admin, un magasin doit être choisi, et être l'un des siens + let Some(store) = query.truthy("storeId") else { + return empty(); + }; + match js::parse_int(store) { + Some(requested) if user.group_ids().iter().any(|id| f64::from(*id) == requested) => { + Some(vec![requested as i32]) + } + _ => return empty(), + } + }; + + let today = today_midnight(); + let mut sql = QueryBuilder::::new(format!( + "SELECT {} FROM tasks LEFT JOIN groups ON tasks.group_id = groups.id", + columns_of::("tasks") + )); + let filtered = group_ids.is_some(); + if let Some(ids) = group_ids { + sql.push(" WHERE tasks.group_id = ANY(").push_bind(ids).push(")"); + } + // Managers et employés : seulement les tâches dont la date de départ est atteinte + if ["manager", "employee"].contains(&user.role()) { + sql.push(if filtered { " AND " } else { " WHERE " }) + .push("(tasks.start_date IS NULL OR tasks.start_date <= ") + .push_bind(today) + .push(")"); + } + sql.push(" ORDER BY tasks.created_at DESC"); + let tasks: Vec = sql + .build_query_as() + .fetch_all(&state.db) + .await + .map_err(|_| failed())?; + + let ids: Vec = tasks.iter().map(|task| task.group_id).collect(); + let groups = GroupSummary::by_ids(&state.db, &ids) + .await + .map_err(|_| failed())?; + // { ...tâche, group, isFutureTask } : date de départ après minuit aujourd'hui + let out: Vec = tasks + .iter() + .map(|task| { + let mut entry = spread(task); + entry.insert("group".into(), json!(groups.get(&task.group_id))); + entry.insert( + "isFutureTask".into(), + Value::Bool(task.start_date.is_some_and(|start| start > today)), + ); + Value::Object(entry) + }) + .collect(); + Ok(Json(out).into_response()) +} + +/// x dans (user.userGroups || []).map(ug => ug.groupId) : égalité stricte de JavaScript +fn in_groups(user: &CurrentUser, value: Option<&Value>) -> bool { + value + .and_then(Value::as_f64) + .is_some_and(|requested| user.group_ids().iter().any(|id| f64::from(*id) == requested)) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create task"; + if !access::has_permission(user.role(), "tasks", "create") { + return Err(AppError::forbidden("Permission denied: cannot create tasks")); + } + let mut data = body.as_object().cloned().unwrap_or_default(); + data.insert("createdBy".into(), json!(user.id())); + // Magasin par défaut : 1 pour l'admin, sinon le premier des siens (ou 1) + if !data.get("groupId").is_some_and(js::truthy) { + let default = if user.is_admin() { + 1 + } else { + user.group_ids().first().copied().unwrap_or(1) + }; + data.insert("groupId".into(), json!(default)); + } + if !user.is_admin() && !user.group_ids().is_empty() && !in_groups(&user, data.get("groupId")) { + return Err(AppError::forbidden("Access denied to this group")); + } + + // createTask : startDate et dueDate convertis en Date (null si absents ou vides) + let start = match data.remove("startDate") { + Some(value) if js::truthy(&value) => match js::new_date(&value) { + Some(date) => Value::String(js::iso(&date)), + // toISOString d'une date invalide : RangeError avant la requête + None => return Err(AppError::internal(FAILED)), + }, + _ => Value::Null, + }; + let due = match data.remove("dueDate") { + Some(value) if js::truthy(&value) => Value::String(node_pg_date(js::new_date(&value).as_ref())), + _ => Value::Null, + }; + let mut values = drizzle_values::(&data).map_err(|_| AppError::internal(FAILED))?; + values.insert("startDate".into(), start); + values.insert("dueDate".into(), due); + let task: TasksRow = db::insert(&state.db, &values).await.or_500(FAILED)?; + Ok(Json(task).into_response()) +} + +/// Date JavaScript envoyée par node-pg dans une colonne date (dateToString, heure +/// locale du serveur Node = UTC) ; une date invalide donne le texte que node-pg +/// produit, refusé par PostgreSQL avec le même message +fn node_pg_date(date: Option<&DateTime>) -> String { + let Some(date) = date else { + return "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN".into(); + }; + let year = date.year(); + let (year, era) = if year < 1 { (1 - year, " BC") } else { (year, "") }; + format!( + "{year:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}+00:00{era}", + date.month(), + date.day(), + date.hour(), + date.minute(), + date.second(), + date.timestamp_subsec_millis() + ) +} + +/// storage.getTask(parseInt(id)) : l'identifiant part en texte, comme node-pg, pour +/// que PostgreSQL rende la même erreur (NaN, hors limites) que Node affiche +async fn find_task(db: &sqlx::PgPool, raw_id: &str) -> Result, sqlx::Error> { + let id = js::parse_int(raw_id).map_or_else(|| "NaN".to_string(), js::number_to_string); + sqlx::query_as::<_, TasksRow>(&TasksRow::select("WHERE id = $1::int4")) + .bind(id) + .fetch_optional(db) + .await +} + +/// error.message de node-pg : le message de PostgreSQL +fn pg_message(error: &sqlx::Error) -> String { + match error.as_database_error() { + Some(database) => database.message().to_string(), + None => error.to_string(), + } +} + +/// Tâche existante et du magasin de l'utilisateur (hors admin) +async fn check_access( + state: &AppState, + user: &CurrentUser, + raw_id: &str, + failed: &str, +) -> Result { + let task = find_task(&state.db, raw_id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Task not found"))?; + if !user.is_admin() && !user.group_ids().contains(&task.group_id) { + return Err(AppError::forbidden("Access denied")); + } + Ok(task) +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + // 500 avec le message de l'erreur en détail + let failed = |details: String| { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": "Failed to update task", "details": details }), + ) + }; + let task = find_task(&state.db, &id) + .await + .map_err(|error| failed(pg_message(&error)))? + .ok_or_else(|| AppError::not_found("Task not found"))?; + if !user.is_admin() && !user.group_ids().contains(&task.group_id) { + return Err(AppError::forbidden("Access denied")); + } + + // Champs retenus par la route puis par storage.updateTask + let body = body.as_object().cloned().unwrap_or_default(); + let in_database = |message: &str| failed(format!("Failed to update task in database: {message}")); + let mut values = Parsed::new(); + for key in ["title", "description", "priority", "status", "assignedTo"] { + if let Some(value) = body.get(key) { + values.insert(key.into(), node_pg_value(value)); + } + } + if let Some(value) = body.get("startDate") { + let start = if value.is_null() || value == "" { + Value::Null + } else { + match js::new_date(value) { + Some(date) => Value::String(js::iso(&date)), + None => return Err(in_database("Invalid time value")), + } + }; + values.insert("startDate".into(), start); + } + if let Some(value) = body.get("dueDate") { + let due = if value.is_null() || value == "" { + Value::Null + } else { + Value::String(node_pg_date(js::new_date(value).as_ref())) + }; + values.insert("dueDate".into(), due); + } + + match db::update_by_id::(&state.db, task.id, &values, true).await { + Ok(Some(task)) => Ok(Json(task).into_response()), + // Ligne disparue entre-temps : task.id lu sur undefined dans Node + Ok(None) => Err(in_database("Cannot read properties of undefined (reading 'id')")), + Err(error) => Err(in_database(&pg_message(&error))), + } +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete task"; + let task = check_access(&state, &user, &id, FAILED).await?; + db::delete_by_id::(&state.db, task.id) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Task deleted successfully" })).into_response()) +} + +async fn complete(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to complete task"; + let task = check_access(&state, &user, &id, FAILED).await?; + let mut values = Parsed::new(); + values.insert("status".into(), json!("completed")); + values.insert("completedAt".into(), json!(crate::modules::now_iso())); + values.insert("completedBy".into(), json!(user.id())); + db::update_by_id::(&state.db, task.id, &values, true) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Task completed successfully" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn date_comme_node_pg() { + let date = Utc.with_ymd_and_hms(2026, 10, 20, 8, 5, 3).unwrap(); + assert_eq!(node_pg_date(Some(&date)), "2026-10-20T08:05:03.000+00:00"); + assert_eq!(node_pg_date(None), "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"); + } +} diff --git a/server-rs/src/modules/users.rs b/server-rs/src/modules/users.rs new file mode 100644 index 0000000..10cf61b --- /dev/null +++ b/server-rs/src/modules/users.rs @@ -0,0 +1,463 @@ +// Utilisateurs : /api/users (server/routes.ts, « Users management routes » et +// « User-Group management routes »). +// +// Les adresses POST /api/users/:id/groups et DELETE /api/users/:id/groups/:groupId +// déclarées une seconde fois plus bas dans routes.ts (« Access denied ») ne sont +// jamais atteintes : seules les premières sont reprises (décision R3). +// +// Écart voulu (audit USERS-02) : l'empreinte du mot de passe n'est jamais +// renvoyée. Node la renvoie encore après une création ou une modification ; +// Rust répond avec la projection publique (projections::PublicUser). +// +// Comme Node aujourd'hui : managers et directeurs voient tous les comptes ; une +// adresse électronique vide dans une modification ne l'efface pas ; un mot de +// passe vide dans une modification est enregistré tel quel (le compte ne peut +// plus se connecter) ; affectations et suppression sans transaction. + +use std::collections::HashMap; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::delete, routing::get, routing::post, routing::put}; +use chrono::NaiveDateTime; +use rand::Rng; +use serde_json::{Map, Value, json}; +use sqlx::postgres::PgArguments; +use sqlx::{Arguments, PgPool}; + +use crate::AppState; +use crate::auth::{CurrentUser, GroupBrief, UserGroup}; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError, OrInternal, empty_json}; +use crate::modules::session_routes::hash_password; +use crate::projections::PublicUser; +use crate::request::{JsonBody, int_id}; +use crate::schema::{UserGroupsRow, UsersRow}; +use crate::zod::{Field, Object, Parsed}; + +pub fn routes() -> Router { + Router::new() + .route("/api/users", get(list).post(create)) + .route("/api/users/{id}", put(update).delete(remove)) + .route("/api/users/{id}/groups", post(assign_group)) + .route("/api/users/{id}/groups/{group_id}", delete(remove_group)) +} + +const ROLES: &[&str] = &["admin", "directeur", "manager", "employee"]; + +fn access_denied() -> AppError { + AppError::forbidden("Access denied") +} + +/// z.union([z.string().email(), z.literal(""), z.null()]).optional() +fn email_field() -> Field { + Field::union( + "email", + vec![ + Field::string("").email(), + Field::literal("", json!("")), + Field::null(""), + ], + ) + .optional() +} + +/// createUserSchema (POST /api/users) +fn create_schema() -> Object { + Object::new(vec![ + Field::string("id").optional(), + Field::string("username").min_len_message(1, "L'identifiant est obligatoire"), + email_field(), + Field::string("firstName").optional(), + Field::string("lastName").optional(), + Field::string("password").min_len_message(1, "Le mot de passe est obligatoire"), + Field::enumeration("role", ROLES).optional(), + ]) +} + +/// updateUserSchema (PUT /api/users/:id) +fn update_schema() -> Object { + let name = |field: &str| { + Field::union( + field, + vec![Field::string(""), Field::literal("", json!("")), Field::null("")], + ) + .optional() + }; + Object::new(vec![ + Field::string("username").optional(), + Field::enumeration("role", ROLES).optional(), + name("firstName"), + name("lastName"), + email_field(), + Field::string("password").optional(), + ]) +} + +/// Projection publique d'une ligne users : jamais l'empreinte du mot de passe +fn public(user: UsersRow) -> PublicUser { + PublicUser { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + first_name: user.first_name, + last_name: user.last_name, + profile_image_url: user.profile_image_url, + role: user.role, + password_changed: user.password_changed, + created_at: user.created_at, + updated_at: user.updated_at, + } +} + +fn text(data: &Parsed, key: &str) -> Option { + data.get(key).and_then(Value::as_str).map(str::to_string) +} + +/// Doublon d'identifiant ou d'adresse (23505) : 409 avec le message de Node ; +/// base injoignable : 503 ; autre erreur : 500 avec le message donné +fn write_error(error: sqlx::Error, email_message: &str, failed: &str) -> AppError { + use axum::http::StatusCode; + tracing::error!(%error, "{failed}"); + match &error { + sqlx::Error::Database(database) if database.code().as_deref() == Some("23505") => { + match database.constraint() { + Some("users_username_key") => AppError::new( + StatusCode::CONFLICT, + "Un utilisateur avec ce nom d'utilisateur existe déjà. Veuillez choisir un autre nom d'utilisateur.", + ), + Some("users_email_key") => AppError::new(StatusCode::CONFLICT, email_message), + _ => AppError::internal(failed), + } + } + sqlx::Error::Io(io) + if matches!( + io.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + AppError::new(StatusCode::SERVICE_UNAVAILABLE, "Database connection error") + } + _ => AppError::internal(failed), + } +} + +/// storage.getUsersWithGroups : tous les comptes sans mot de passe (dans l'ordre +/// de la table), avec leurs magasins +async fn list(State(state): State, user: CurrentUser) -> ApiResult { + if !["admin", "directeur", "manager"].contains(&user.role()) { + return Err(access_denied()); + } + let users: Vec = + match sqlx::query_as(&format!("SELECT {} FROM users", PublicUser::columns("users"))) + .fetch_all(&state.db) + .await + { + Ok(users) => users, + Err(error) => { + // Node renvoie une liste vide avec le code 500 + tracing::error!(%error, "liste des utilisateurs"); + return Err(AppError::with_body( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + json!([]), + )); + } + }; + let memberships = memberships(&state.db).await.unwrap_or_else(|error| { + tracing::error!(%error, "magasins des utilisateurs"); + Vec::new() + }); + let mut by_user: HashMap> = HashMap::new(); + for membership in memberships { + by_user + .entry(membership.user_id.clone()) + .or_default() + .push(membership); + } + let users: Vec = users + .into_iter() + .map(|user| { + let groups = by_user.remove(&user.id).unwrap_or_default(); + let mut object = match serde_json::to_value(user) { + Ok(Value::Object(object)) => object, + _ => Map::new(), + }; + object.insert("userGroups".into(), json!(groups)); + object.insert("userRoles".into(), json!([])); + Value::Object(object) + }) + .collect(); + Ok(Json(users).into_response()) +} + +/// Affectations jointes à leur magasin (même requête que Node, sans tri) +async fn memberships(db: &PgPool) -> Result, sqlx::Error> { + type Row = ( + String, + i32, + i32, + String, + Option, + Option, + Option, + ); + let rows: Vec = sqlx::query_as( + "SELECT user_groups.user_id, user_groups.group_id, groups.id, groups.name, groups.color, + groups.created_at, groups.updated_at + FROM user_groups + INNER JOIN groups ON user_groups.group_id = groups.id", + ) + .fetch_all(db) + .await?; + Ok(rows + .into_iter() + .map( + |(user_id, group_id, id, name, color, created_at, updated_at)| UserGroup { + user_id, + group_id, + group: GroupBrief { + id, + name, + color, + created_at, + updated_at, + }, + }, + ) + .collect()) +} + +/// manual__<9 caractères en base 36>, comme Math.random().toString(36) +fn generated_id() -> String { + const BASE36: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::thread_rng(); + let suffix: String = (0..9) + .map(|_| BASE36[rng.gen_range(0..BASE36.len())] as char) + .collect(); + format!("manual_{}_{suffix}", chrono::Utc::now().timestamp_millis()) +} + +async fn create(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + const FAILED: &str = "Failed to create user"; + if !user.is_admin() { + return Err(access_denied()); + } + let data = create_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Invalid user data", issues))?; + + let password = hash_password(text(&data, "password").unwrap_or_default()).await; + let id = text(&data, "id") + .filter(|id| !id.is_empty()) + .unwrap_or_else(generated_id); + let mut row = Parsed::new(); + row.insert("id".into(), Value::String(id)); + row.insert( + "username".into(), + data.get("username").cloned().unwrap_or(Value::Null), + ); + // Adresse vide ou blanche : non renseignée + if let Some(email) = text(&data, "email").filter(|email| !email.trim().is_empty()) { + row.insert("email".into(), Value::String(email)); + } + for key in ["firstName", "lastName"] { + row.insert(key.into(), Value::String(text(&data, key).unwrap_or_default())); + } + row.insert("password".into(), Value::String(password)); + row.insert( + "role".into(), + Value::String(text(&data, "role").unwrap_or_else(|| "employee".into())), + ); + + let created: UsersRow = db::insert(&state.db, &row).await.map_err(|error| { + write_error( + error, + "Un utilisateur avec cette adresse email existe déjà.", + FAILED, + ) + })?; + Ok(Json(public(created)).into_response()) +} + +/// storage.updateUser : UPDATE … SET …, updatedAt WHERE id (texte) RETURNING +pub(crate) async fn update_user( + db: &PgPool, + id: &str, + data: &Parsed, +) -> Result, sqlx::Error> { + let mut sets = Vec::new(); + let mut arguments = PgArguments::default(); + for col in UsersRow::FIELDS { + let value = if col.key == "updatedAt" { + Some(Value::String(crate::modules::now_iso())) + } else { + data.get(col.key).cloned() + }; + if let Some(value) = value { + arguments.add(db::pg_text(&value)).map_err(sqlx::Error::Encode)?; + sets.push(format!( + "\"{}\" = ${}::{}", + col.column, + sets.len() + 1, + col.sql_type + )); + } + } + arguments.add(id).map_err(sqlx::Error::Encode)?; + let sql = format!( + "UPDATE users SET {} WHERE id = ${} RETURNING {}", + sets.join(", "), + sets.len() + 1, + UsersRow::COLUMNS + ); + sqlx::query_as_with::<_, UsersRow, _>(&sql, arguments) + .fetch_optional(db) + .await +} + +async fn update( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update user"; + if !user.is_admin() { + return Err(access_denied()); + } + let mut data = update_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Invalid user data", issues))?; + + // Adresse vide, blanche ou null : laissée telle quelle en base + if data.contains_key("email") + && text(&data, "email") + .filter(|email| !email.trim().is_empty()) + .is_none() + { + data.remove("email"); + } + if let Some(password) = text(&data, "password").filter(|password| !password.is_empty()) { + data.insert("password".into(), Value::String(hash_password(password).await)); + data.insert("passwordChanged".into(), Value::Bool(true)); + } + + let updated = update_user(&state.db, &id, &data).await.map_err(|error| { + write_error( + error, + "Un utilisateur avec cette adresse email existe déjà. Veuillez utiliser une autre adresse email ou laisser le champ vide.", + FAILED, + ) + })?; + Ok(match updated { + Some(updated) => Json(public(updated)).into_response(), + None => empty_json(), + }) +} + +async fn remove(State(state): State, user: CurrentUser, Path(id): Path) -> ApiResult { + const FAILED: &str = "Failed to delete user"; + if !user.is_admin() { + return Err(access_denied()); + } + if id == user.id() { + return Err(AppError::bad_request( + "Vous ne pouvez pas supprimer votre propre compte", + )); + } + // Retiré de ses magasins, puis supprimé (sans transaction, comme Node) + sqlx::query("DELETE FROM user_groups WHERE user_id = $1") + .bind(&id) + .execute(&state.db) + .await + .or_500(FAILED)?; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(&id) + .execute(&state.db) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "Utilisateur supprimé avec succès" })).into_response()) +} + +fn insufficient_permissions() -> AppError { + AppError::forbidden("Insufficient permissions") +} + +/// insertUserGroupSchema.parse({ userId, groupId: req.body.groupId }) ; une +/// erreur de validation donne le 500 de Node +async fn assign_group( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to assign user to group"; + if !user.is_admin() { + return Err(insufficient_permissions()); + } + let mut input = Map::new(); + input.insert("userId".into(), Value::String(id)); + if let Some(group_id) = body.as_object().and_then(|object| object.get("groupId")) { + input.insert("groupId".into(), group_id.clone()); + } + let data = UserGroupsRow::insert_schema() + .omit(&["createdAt"]) + .parse(&Value::Object(input)) + .map_err(|_| AppError::internal(FAILED))?; + let membership: UserGroupsRow = db::insert(&state.db, &data).await.or_500(FAILED)?; + Ok(Json(membership).into_response()) +} + +async fn remove_group( + State(state): State, + user: CurrentUser, + Path((id, group_id)): Path<(String, String)>, +) -> ApiResult { + const FAILED: &str = "Failed to remove user from group"; + if !user.is_admin() { + return Err(insufficient_permissions()); + } + let group_id = int_id(&group_id, FAILED)?; + sqlx::query("DELETE FROM user_groups WHERE user_id = $1 AND group_id = $2") + .bind(&id) + .bind(group_id) + .execute(&state.db) + .await + .or_500(FAILED)?; + Ok(Json(json!({ "message": "User removed from group successfully" })).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creation_invalide_comme_node() { + // Réponse de Node à { "username": "", "password": "", "email": 5, "role": "chef" } + let issues = create_schema() + .parse(&json!({ "username": "", "password": "", "email": 5, "role": "chef" })) + .unwrap_err(); + let node = r#"[{"code":"too_small","minimum":1,"type":"string","inclusive":true,"exact":false,"message":"L'identifiant est obligatoire","path":["username"]},{"code":"invalid_union","unionErrors":[{"issues":[{"code":"invalid_type","expected":"string","received":"number","path":["email"],"message":"Expected string, received number"}],"name":"ZodError"},{"issues":[{"received":5,"code":"invalid_literal","expected":"","path":["email"],"message":"Invalid literal value, expected \"\""}],"name":"ZodError"},{"issues":[{"code":"invalid_type","expected":"null","received":"number","path":["email"],"message":"Expected null, received number"}],"name":"ZodError"}],"path":["email"],"message":"Invalid input"},{"code":"too_small","minimum":1,"type":"string","inclusive":true,"exact":false,"message":"Le mot de passe est obligatoire","path":["password"]},{"received":"chef","code":"invalid_enum_value","options":["admin","directeur","manager","employee"],"path":["role"],"message":"Invalid enum value. Expected 'admin' | 'directeur' | 'manager' | 'employee', received 'chef'"}]"#; + assert_eq!(serde_json::to_string(&issues).unwrap(), node); + } + + #[test] + fn modification_noms_vides_ou_nuls() { + let data = update_schema() + .parse(&json!({ "firstName": null, "lastName": "", "email": "", "autre": 1 })) + .unwrap(); + assert_eq!( + Value::Object(data), + json!({ "firstName": null, "lastName": "", "email": "" }) + ); + } + + #[test] + fn identifiant_genere() { + let id = generated_id(); + let parts: Vec<&str> = id.split('_').collect(); + assert_eq!(parts[0], "manual"); + assert_eq!(parts[2].len(), 9); + } +} diff --git a/server-rs/src/modules/utilities.rs b/server-rs/src/modules/utilities.rs new file mode 100644 index 0000000..df8ccb5 --- /dev/null +++ b/server-rs/src/modules/utilities.rs @@ -0,0 +1,113 @@ +// Paramètres généraux : /api/utilities (server/routes.ts, « Routes pour +// configuration utilities ») : adresse de l'analyse des ventes et sauvegardes +// automatiques. Une seule ligne, lue ou mise à jour (créée si la table est vide). +// +// Comme Node : les erreurs sont au format { error, details } ; une erreur de +// validation n'est pas un 400 mais un 500 dont le détail est le message de +// ZodError (ses erreurs en JSON indenté). + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get}; +use serde_json::{Value, json}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table}; +use crate::error::{ApiResult, AppError}; +use crate::request::JsonBody; +use crate::schema::UtilitiesRow; + +pub fn routes() -> Router { + Router::new().route("/api/utilities", get(read).post(save)) +} + +fn forbidden() -> AppError { + AppError::with_body( + StatusCode::FORBIDDEN, + json!({ "error": "Accès refusé - Admin ou Directeur uniquement" }), + ) +} + +/// { error: 'Erreur serveur', details: error.message } +fn server_error(details: &str) -> AppError { + AppError::with_body( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "error": "Erreur serveur", "details": details }), + ) +} + +/// error.message d'une erreur de la base (node-pg : le message de PostgreSQL) +fn database_error(error: sqlx::Error) -> AppError { + tracing::error!(%error, "paramètres généraux"); + match &error { + sqlx::Error::Database(database) => server_error(database.message()), + other => server_error(&other.to_string()), + } +} + +fn can_access(user: &CurrentUser) -> Result<(), AppError> { + if ["admin", "directeur"].contains(&user.role()) { + Ok(()) + } else { + Err(forbidden()) + } +} + +/// storage.getUtilities : première ligne (sans tri, comme Node) +async fn current(state: &AppState) -> Result, AppError> { + sqlx::query_as(&UtilitiesRow::select("LIMIT 1")) + .fetch_optional(&state.db) + .await + .map_err(database_error) +} + +async fn read(State(state): State, user: CurrentUser) -> ApiResult { + can_access(&user)?; + // res.json(config || null) + Ok(match current(&state).await? { + Some(config) => Json(config).into_response(), + None => Json(Value::Null).into_response(), + }) +} + +async fn save(State(state): State, user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + can_access(&user)?; + let data = UtilitiesRow::insert_schema() + .omit(&["id", "createdAt", "updatedAt"]) + .parse(&body) + .map_err(|issues| { + // ZodError.message : JSON.stringify(issues, null, 2) + server_error(&serde_json::to_string_pretty(&issues).unwrap_or_default()) + })?; + let config = match current(&state).await? { + Some(existing) => db::update_by_id::(&state.db, existing.id, &data, true) + .await + .map_err(database_error)?, + None => Some( + db::insert::(&state.db, &data) + .await + .map_err(database_error)?, + ), + }; + Ok(Json(config).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detail_d_une_erreur_de_validation_comme_node() { + let issues = UtilitiesRow::insert_schema() + .omit(&["id", "createdAt", "updatedAt"]) + .parse(&json!({ "salesAnalysisUrl": 5 })) + .unwrap_err(); + // Réponse de Node à POST /api/utilities { "salesAnalysisUrl": 5 } + assert_eq!( + serde_json::to_string_pretty(&issues).unwrap(), + "[\n {\n \"code\": \"invalid_type\",\n \"expected\": \"string\",\n \"received\": \"number\",\n \"path\": [\n \"salesAnalysisUrl\"\n ],\n \"message\": \"Expected string, received number\"\n }\n]" + ); + } +} diff --git a/server-rs/src/modules/weather.rs b/server-rs/src/modules/weather.rs new file mode 100644 index 0000000..3f92731 --- /dev/null +++ b/server-rs/src/modules/weather.rs @@ -0,0 +1,873 @@ +// Météo : /api/weather/* (server/routes.ts, « Weather routes » et route de +// géolocalisation ; server/weatherService.ts ; storage.getWeatherSettings, +// getWeatherData, getNearestWeatherData, createWeatherData, clearWeatherCache). +// +// Les adresses de Visual Crossing et de Nominatim sont écrites dans le code, +// comme dans Node. Écart voulu (critère du lot 2, DASH-35) : chaque appel a un +// délai maximal (connexion 10 s, comme fetch dans Node, et 30 s en tout), là où +// Node attend jusqu'à 300 s une réponse lente. +// Comme Node aujourd'hui : tout utilisateur connecté peut changer la ville par +// géolocalisation (décision R3 du plan, « Ce qu'on ne reporte pas »). + +use std::sync::LazyLock; +use std::time::Duration; + +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::{Json, Router, routing::get, routing::post, routing::put}; +use chrono::{Datelike, Duration as Days, NaiveDate}; +use serde_json::{Map, Value, json}; +use sqlx::postgres::PgArguments; +use sqlx::{Arguments, PgPool}; + +use crate::AppState; +use crate::auth::CurrentUser; +use crate::db::{self, Table, node_pg_text}; +use crate::error::{ApiResult, AppError, OrInternal, json_or_empty}; +use crate::js::{self, string_of, truthy}; +use crate::request::{JsonBody, int_id}; +use crate::schema::{WeatherDataRow, WeatherSettingsRow}; +use crate::zod::Object; + +const VISUAL_CROSSING_URL: &str = + "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline"; +const NOMINATIM_URL: &str = "https://nominatim.openstreetmap.org/reverse"; +const NOMINATIM_USER_AGENT: &str = "LogiFlow-Weather-System/1.0 (Enterprise Weather Management Platform)"; + +/// Client des services météo : sans mandataire (fetch de Node n'en utilise pas), +/// avec les autorités de NODE_EXTRA_CA_CERTS que Node ajoute aux siennes +static CLIENT: LazyLock = LazyLock::new(|| { + let mut builder = reqwest::Client::builder() + .no_proxy() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .user_agent("node"); + for certificate in extra_certificates() { + builder = builder.add_root_certificate(certificate); + } + builder.build().expect("client HTTP de la météo") +}); + +/// Certificats de NODE_EXTRA_CA_CERTS ; fichier illisible : avertissement, comme Node +fn extra_certificates() -> Vec { + let Some(path) = std::env::var_os("NODE_EXTRA_CA_CERTS").filter(|path| !path.is_empty()) else { + return Vec::new(); + }; + std::fs::read(&path) + .map_err(|error| error.to_string()) + .and_then(|pem| reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| error.to_string())) + .unwrap_or_else(|error| { + tracing::warn!(?path, error, "NODE_EXTRA_CA_CERTS ignoré"); + Vec::new() + }) +} + +pub fn routes() -> Router { + Router::new() + .route("/api/weather/settings", get(get_settings).post(create_settings)) + .route("/api/weather/settings/{id}", put(update_settings)) + .route("/api/weather/test-connection", post(test_connection)) + .route("/api/weather/current", get(current)) + .route("/api/weather/geolocation", post(geolocation)) +} + +/// insertWeatherSettingsSchema +fn settings_schema() -> Object { + WeatherSettingsRow::insert_schema().omit(&["id", "createdAt", "updatedAt"]) +} + +fn admin_only(user: &CurrentUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(AppError::forbidden("Access denied")) + } +} + +/// storage.getWeatherSettings : première ligne, sans tri +pub(crate) async fn weather_settings(db: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as(&WeatherSettingsRow::select("LIMIT 1")) + .persistent(false) + .fetch_optional(db) + .await +} + +async fn get_settings(State(state): State, user: CurrentUser) -> ApiResult { + admin_only(&user)?; + let settings = weather_settings(&state.db) + .await + .or_500("Failed to fetch weather settings")?; + Ok(json_or_empty(settings)) +} + +async fn create_settings( + State(state): State, + user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + admin_only(&user)?; + let data = settings_schema() + .parse(&body) + .map_err(|issues| AppError::validation("Validation error", issues))?; + let settings: WeatherSettingsRow = db::insert(&state.db, &data) + .await + .or_500("Failed to create weather settings")?; + Ok(Json(settings).into_response()) +} + +async fn update_settings( + State(state): State, + user: CurrentUser, + Path(id): Path, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Failed to update weather settings"; + admin_only(&user)?; + // Validation avant la base : un identifiant illisible n'échoue qu'à la requête + let data = settings_schema() + .partial() + .parse(&body) + .map_err(|issues| AppError::validation("Validation error", issues))?; + let id = int_id(&id, FAILED)?; + let settings = db::update_by_id::(&state.db, id, &data, true) + .await + .or_500(FAILED)?; + Ok(json_or_empty(settings)) +} + +async fn test_connection(user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult { + admin_only(&user)?; + let given = |key: &str| body.get(key).filter(|value| truthy(value)); + let (Some(api_key), Some(location)) = (given("apiKey"), given("location")) else { + return Err(AppError::bad_request("API key and location are required")); + }; + let (success, message) = test_api_connection(&string_of(api_key), &string_of(location)).await; + Ok(Json(json!({ "success": success, "message": message })).into_response()) +} + +// ---------- Visual Crossing et Nominatim (server/weatherService.ts) ---------- + +/// encodeURIComponent +fn encode_uri_component(text: &str) -> String { + let mut encoded = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +/// Adresse de la chronologie Visual Crossing ; la clé est collée telle quelle, comme Node +fn timeline_url(location: &str, date: Option<&str>, api_key: &str, french: bool) -> String { + let date = date.map(|date| format!("/{date}")).unwrap_or_default(); + let lang = if french { "&lang=fr" } else { "" }; + format!( + "{VISUAL_CROSSING_URL}/{}{date}?unitGroup=metric&include=days&key={api_key}&contentType=json{lang}", + encode_uri_component(location) + ) +} + +/// Texte du statut (statusText de fetch) +fn status_text(response: &reqwest::Response) -> String { + response + .extensions() + .get::() + .map(|reason| String::from_utf8_lossy(reason.as_bytes()).into_owned()) + .or_else(|| response.status().canonical_reason().map(str::to_string)) + .unwrap_or_default() +} + +/// fetchCurrentWeather / fetchPreviousYearWeather : réponse JSON, None en cas d'échec +async fn fetch_timeline(url: &str) -> Option { + let response = match CLIENT.get(url).send().await { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, "Erreur lors de la récupération de la météo"); + return None; + } + }; + if !response.status().is_success() { + let status = response.status().as_u16(); + let status_text = status_text(&response); + let reason: String = response + .text() + .await + .unwrap_or_default() + .chars() + .take(300) + .collect(); + tracing::error!(status, status_text, reason, "Weather API error"); + return None; + } + response + .json::() + .await + .map_err(|error| tracing::error!(%error, "Réponse météo illisible")) + .ok() +} + +/// testApiConnection : { success, message } +pub(crate) async fn test_api_connection(api_key: &str, location: &str) -> (bool, String) { + const UNREACHABLE: &str = "Erreur de connexion à l'API météo"; + let url = timeline_url(location, None, api_key, false); + let response = match CLIENT.get(url).send().await { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, "Erreur test API météo"); + return (false, UNREACHABLE.into()); + } + }; + let status = response.status(); + if status.is_success() { + return match response.json::().await { + // data.days && data.days.length > 0 (null.days : TypeError) + Ok(Value::Null) | Err(_) => (false, UNREACHABLE.into()), + Ok(data) => { + let days = data.get("days").filter(|days| truthy(days)); + if days.is_some_and(has_items) { + (true, "Connexion réussie à l'API météo".into()) + } else { + ( + false, + "Aucune donnée météo trouvée pour cette localisation".into(), + ) + } + } + }; + } + match status.as_u16() { + 401 => (false, "Clé API invalide".into()), + 404 => (false, "Localisation introuvable".into()), + code => (false, format!("Erreur API: {code} {}", status_text(&response))), + } +} + +/// valeur.length > 0 +fn has_items(value: &Value) -> bool { + let length = match value { + Value::Array(items) => json!(items.len()), + Value::String(text) => json!(js::length(text)), + Value::Object(fields) => match fields.get("length") { + Some(length) => length.clone(), + None => return false, + }, + _ => return false, + }; + js::to_number(&length) > 0.0 +} + +/// getCityFromCoordinates : ville, pays et « ville, pays » (Nominatim) +async fn city_from_coordinates(latitude: f64, longitude: f64) -> Option<(Value, Value, String)> { + let url = format!( + "{NOMINATIM_URL}?format=json&lat={}&lon={}&accept-language=fr", + js::number_string(latitude), + js::number_string(longitude) + ); + let response = CLIENT + .get(url) + .header(reqwest::header::USER_AGENT, NOMINATIM_USER_AGENT) + .send() + .await + .map_err(|error| tracing::error!(%error, "Erreur lors de la géolocalisation")) + .ok()?; + if !response.status().is_success() { + tracing::error!(status = response.status().as_u16(), "Nominatim API error"); + return None; + } + let data = response.json::().await.ok()?; + let address = data.get("address").filter(|address| truthy(address))?; + let pick = |keys: &[&str], fallback: &str| { + keys.iter() + .filter_map(|key| address.get(*key)) + .find(|value| truthy(value)) + .cloned() + .unwrap_or_else(|| json!(fallback)) + }; + let city = pick( + &["city", "town", "village", "municipality", "hamlet"], + "Ville inconnue", + ); + let country = pick(&["country"], "Pays inconnu"); + let full_location = format!("{}, {}", string_of(&city), string_of(&country)); + Some((city, country, full_location)) +} + +/// translateConditionToFrench : premier mot-clé anglais reconnu, sinon tel quel +fn translate_condition(condition: &str) -> String { + let lower = condition.to_lowercase(); + let has = |word: &str| lower.contains(word); + let french = if has("clear") { + "Dégagé" + } else if has("sunny") { + "Ensoleillé" + } else if has("fair") { + "Beau temps" + } else if has("partly cloudy") { + "Partiellement nuageux" + } else if has("cloudy") { + "Nuageux" + } else if has("overcast") { + "Couvert" + } else if has("rain") { + if has("heavy") { + "Pluie forte" + } else if has("light") { + "Pluie légère" + } else { + "Pluie" + } + } else if has("shower") { + "Averses" + } else if has("drizzle") { + "Bruine" + } else if has("thunderstorm") || has("thunder") { + "Orage" + } else if has("snow") { + if has("heavy") { + "Neige forte" + } else if has("light") { + "Neige légère" + } else { + "Neige" + } + } else if has("fog") { + "Brouillard" + } else if has("mist") { + "Brume" + } else if has("wind") { + "Venteux" + } else { + return condition.to_string(); + }; + french.to_string() +} + +/// Méthodes héritées d'Object.prototype : iconMap[nom] est une fonction, +/// que JSON.stringify omet +const OBJECT_PROTOTYPE_METHODS: [&str; 11] = [ + "constructor", + "__defineGetter__", + "__defineSetter__", + "hasOwnProperty", + "__lookupGetter__", + "__lookupSetter__", + "isPrototypeOf", + "propertyIsEnumerable", + "toString", + "valueOf", + "toLocaleString", +]; + +/// getWeatherIcon : émoji de l'icône Visual Crossing (None : clé omise du JSON) +fn weather_icon(icon: Option<&Value>) -> Option { + let key = icon.map(string_of).unwrap_or_else(|| "undefined".into()); + let emoji = match key.as_str() { + "clear-day" => "☀️", + "clear-night" => "🌙", + "partly-cloudy-day" => "⛅", + "partly-cloudy-night" | "cloudy" => "☁️", + "rain" => "🌧️", + "snow" => "❄️", + "wind" => "💨", + "fog" => "🌫️", + "thunder" => "⛈️", + "hail" => "🌨️", + "__proto__" => return Some(json!({})), + method if OBJECT_PROTOTYPE_METHODS.contains(&method) => return None, + _ => "🌤️", + }; + Some(json!(emoji)) +} + +/// getPreviousYearDate : même jour de la semaine, même semaine du mois, l'an +/// dernier (calculé sur la date du jour à Paris) +fn previous_year_date(date: NaiveDate) -> NaiveDate { + let (year, month) = (date.year() - 1, date.month()); + let weekday = date.weekday().num_days_from_sunday() as i64; + let first_of_month = NaiveDate::from_ymd_opt(year, month, 1).expect("premier du mois"); + let first_weekday = first_of_month.weekday().num_days_from_sunday() as i64; + let days_to_add = (weekday - first_weekday + 7) % 7; + let week_of_month = i64::from(date.day().div_ceil(7)); + // new Date(année, mois, jour) déborde sur le mois suivant si besoin + let target = first_of_month + Days::days(days_to_add + (week_of_month - 1) * 7); + if target.month() != month { + target - Days::days(7) + } else { + target + } +} + +/// Météo d'un jour : ligne en cache, ou réponse de l'API (InsertWeatherData) +/// quand elle n'a pas pu être enregistrée ; None = valeur undefined (clé omise) +type WeatherRecord = Vec<(String, Option)>; + +fn record_of(row: WeatherDataRow) -> WeatherRecord { + match serde_json::to_value(row) { + Ok(Value::Object(fields)) => fields + .into_iter() + .map(|(key, value)| (key, Some(value))) + .collect(), + _ => Vec::new(), + } +} + +/// TypeError de convertApiDataToWeatherData sur une réponse inattendue +#[derive(Debug, PartialEq)] +struct TypeError; + +/// valeur[clé] en JavaScript (undefined sur une valeur qui n'est pas un objet) +fn property<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + value.as_object().and_then(|fields| fields.get(key)) +} + +/// convertApiDataToWeatherData : premier jour de la réponse, conditions traduites +fn convert_api_data( + api: &Value, + location: &str, + is_current_year: bool, +) -> Result, TypeError> { + let Some(days) = property(api, "days").filter(|days| truthy(days)) else { + return Ok(None); + }; + // days.length === 0 ; puis days[0] + let today = match days { + Value::Array(items) if items.is_empty() => return Ok(None), + Value::Array(items) => items.first(), + Value::Object(fields) => { + if fields.get("length").and_then(Value::as_f64) == Some(0.0) { + return Ok(None); + } + fields.get("0") + } + // Chaîne : days[0] est un caractère, sans propriété tempmax + _ => None, + }; + let Some(today @ Value::Object(_)) = today else { + return Err(TypeError); + }; + let text = |key: &str| match property(today, key) { + None | Some(Value::Null) => Err(TypeError), + Some(value) => Ok(string_of(value)), + }; + let temp_max = text("tempmax")?; + let temp_min = text("tempmin")?; + let Some(Value::String(conditions)) = property(today, "conditions") else { + return Err(TypeError); + }; + Ok(Some(vec![ + ("date".into(), property(today, "datetime").cloned()), + ("location".into(), Some(json!(location))), + ("tempMax".into(), Some(json!(temp_max))), + ("tempMin".into(), Some(json!(temp_min))), + ("icon".into(), property(today, "icon").cloned()), + ("conditions".into(), Some(json!(translate_condition(conditions)))), + ("isCurrentYear".into(), Some(json!(is_current_year))), + ])) +} + +/// Forme renvoyée : la météo du jour, l'icône en émoji, les températures en nombres +fn displayed(record: &WeatherRecord) -> Value { + let field = |key: &str| { + record + .iter() + .find(|(name, _)| name == key) + .and_then(|(_, value)| value.as_ref()) + }; + let temperature = |key: &str| { + let text = field(key).map(string_of).unwrap_or_else(|| "undefined".into()); + js::number_value(js::parse_float(&text)) + }; + let mut out = Map::new(); + for (key, value) in record { + let value = if key == "icon" { + weather_icon(value.as_ref()) + } else { + value.clone() + }; + if let Some(value) = value { + out.insert(key.clone(), value); + } + } + out.insert("maxTemperature".into(), temperature("tempMax")); + out.insert("minTemperature".into(), temperature("tempMin")); + if let Some(conditions) = field("conditions") { + out.insert("condition".into(), conditions.clone()); + } + Value::Object(out) +} + +/// storage.getWeatherData : ligne du jour en cache (sans tri, comme Node) +async fn cached( + db: &PgPool, + date: &str, + is_current_year: bool, +) -> Result, sqlx::Error> { + let row: Option = sqlx::query_as(&WeatherDataRow::select( + "WHERE (date = $1::date AND is_current_year = $2) LIMIT 1", + )) + .bind(date) + .bind(is_current_year) + .persistent(false) + .fetch_optional(db) + .await?; + Ok(row.map(record_of)) +} + +/// storage.getNearestWeatherData : ligne en cache la plus proche, à 7 jours près +async fn nearest( + db: &PgPool, + date: &str, + is_current_year: bool, +) -> Result, sqlx::Error> { + let row: Option = sqlx::query_as(&WeatherDataRow::select( + "WHERE (is_current_year = $1 AND ABS(date - $2::date) <= $3) + ORDER BY ABS(date - $4::date) LIMIT 1", + )) + .bind(is_current_year) + .bind(date) + .bind(7_i32) + .bind(date) + .persistent(false) + .fetch_optional(db) + .await?; + Ok(row.map(record_of)) +} + +/// storage.createWeatherData : valeurs envoyées comme node-pg, undefined → défaut +async fn save(db: &PgPool, record: &WeatherRecord) -> Result { + let mut arguments = PgArguments::default(); + let mut columns = Vec::new(); + let mut placeholders = Vec::new(); + for (key, value) in record { + let (Some(value), Some(col)) = (value, WeatherDataRow::field(key)) else { + continue; + }; + arguments.add(node_pg_text(value)).map_err(sqlx::Error::Encode)?; + columns.push(format!("\"{}\"", col.column)); + placeholders.push(format!("${}::{}", columns.len(), col.sql_type)); + } + let sql = format!( + "INSERT INTO weather_data ({}) VALUES ({}) RETURNING {}", + columns.join(", "), + placeholders.join(", "), + WeatherDataRow::COLUMNS + ); + let row: WeatherDataRow = sqlx::query_as_with(&sql, arguments).fetch_one(db).await?; + Ok(record_of(row)) +} + +/// Météo absente du cache : appel à l'API, puis enregistrement ; si +/// l'enregistrement échoue, la ligne en cache, à défaut la réponse de l'API +async fn fetch_and_save( + db: &PgPool, + url: &str, + location: &str, + date: &str, + is_current_year: bool, +) -> Result, AppError> { + const FAILED: &str = "Failed to fetch weather data"; + let Some(api) = fetch_timeline(url).await.filter(truthy) else { + return Ok(None); + }; + let Some(record) = + convert_api_data(&api, location, is_current_year).map_err(|_| AppError::internal(FAILED))? + else { + return Ok(None); + }; + match save(db, &record).await { + Ok(saved) => Ok(Some(saved)), + Err(error) => { + tracing::warn!(%error, "météo : enregistrement impossible"); + let existing = cached(db, date, is_current_year).await.or_500(FAILED)?; + Ok(Some(existing.unwrap_or(record))) + } + } +} + +async fn current(State(state): State, _user: CurrentUser) -> ApiResult { + const FAILED: &str = "Failed to fetch weather data"; + let db = &state.db; + let settings = weather_settings(db) + .await + .or_500(FAILED)? + .filter(|settings| settings.is_active) + .ok_or_else(|| AppError::not_found("Weather service not configured or disabled"))?; + + let today = crate::clock::today(); + let today_text = today.format("%Y-%m-%d").to_string(); + let previous_text = previous_year_date(today).format("%Y-%m-%d").to_string(); + // Lectures du cache en parallèle (Promise.all) + let (current_year, previous_year) = + tokio::join!(cached(db, &today_text, true), cached(db, &previous_text, false)); + let (mut current_year, mut previous_year) = (current_year.or_500(FAILED)?, previous_year.or_500(FAILED)?); + + // Appels à l'API l'un après l'autre (limite de requêtes simultanées de Visual Crossing) + if current_year.is_none() { + let url = timeline_url(&settings.location, None, &settings.api_key, true); + current_year = fetch_and_save(db, &url, &settings.location, &today_text, true).await?; + } + if previous_year.is_none() { + let url = timeline_url(&settings.location, Some(&previous_text), &settings.api_key, true); + previous_year = fetch_and_save(db, &url, &settings.location, &previous_text, false).await?; + } + // Replis : ligne de l'an dernier la plus proche, puis météo relevée l'an dernier + if previous_year.is_none() { + previous_year = nearest(db, &previous_text, false).await.or_500(FAILED)?; + } + if previous_year.is_none() { + previous_year = nearest(db, &previous_text, true).await.or_500(FAILED)?; + } + + Ok(Json(json!({ + "currentYear": current_year.as_ref().map(displayed), + "previousYear": previous_year.as_ref().map(displayed), + "location": settings.location, + })) + .into_response()) +} + +async fn geolocation( + State(state): State, + _user: CurrentUser, + JsonBody(body): JsonBody, +) -> ApiResult { + const FAILED: &str = "Erreur lors de la géolocalisation"; + let given = |key: &str| body.get(key).filter(|value| truthy(value)); + let (Some(latitude), Some(longitude)) = (given("latitude"), given("longitude")) else { + return Err(AppError::bad_request("Latitude et longitude requises")); + }; + let settings = weather_settings(&state.db) + .await + .or_500(FAILED)? + .filter(|settings| !settings.api_key.is_empty()) + .ok_or_else(|| AppError::internal("Configuration météo manquante"))?; + let latitude = js::parse_float(&string_of(latitude)); + let longitude = js::parse_float(&string_of(longitude)); + let (city, country, full_location) = city_from_coordinates(latitude, longitude) + .await + .ok_or_else(|| AppError::internal("Impossible de déterminer la ville à partir des coordonnées"))?; + + let mut data = Map::new(); + data.insert("location".into(), json!(full_location)); + db::update_by_id::(&state.db, settings.id, &data, true) + .await + .or_500(FAILED)?; + // La ville a changé : le cache météo est vidé + sqlx::query("DELETE FROM weather_data") + .execute(&state.db) + .await + .or_500(FAILED)?; + tracing::info!(from = %settings.location, to = %full_location, "localisation météo mise à jour"); + Ok(Json(json!({ + "success": true, + "location": { "city": city, "country": country, "fullLocation": full_location }, + "message": format!("Localisation mise à jour vers {full_location}"), + })) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(text: &str) -> NaiveDate { + NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap() + } + + #[test] + fn date_de_l_an_dernier_comme_node() { + // weatherService.getPreviousYearDate(new Date(`${date}T12:00:00Z`)), TZ=UTC + for (date, expected) in [ + ("2026-10-15", "2025-10-16"), + ("2026-10-01", "2025-10-02"), + ("2026-10-31", "2025-10-25"), + ("2026-03-29", "2025-03-30"), + ("2026-03-31", "2025-03-25"), + ("2026-02-28", "2025-02-22"), + ("2026-01-01", "2025-01-02"), + ("2026-12-31", "2025-12-25"), + ("2025-03-30", "2024-03-31"), + ("2024-02-29", "2023-02-23"), + ("2028-02-29", "2027-02-23"), + ("2026-07-07", "2025-07-01"), + ("2026-05-31", "2025-05-25"), + ("2026-08-29", "2025-08-30"), + ("2026-11-30", "2025-11-24"), + ] { + assert_eq!(previous_year_date(day(date)), day(expected), "{date}"); + } + } + + fn as_json(record: Option) -> Value { + match record { + None => Value::Null, + Some(record) => Value::Object( + record + .into_iter() + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect(), + ), + } + } + + #[test] + fn conversion_de_la_reponse_comme_node() { + // JSON.stringify(weatherService.convertApiDataToWeatherData(…, "Nancy, France", true)) + let convert = |api: Value| as_json(convert_api_data(&api, "Nancy, France", true).unwrap()); + assert_eq!( + convert( + json!({ "days": [{ "datetime": "2026-10-15", "tempmax": 18.3, "tempmin": 7, "icon": "partly-cloudy-day", "conditions": "Partially cloudy" }] }) + ), + json!({ "date": "2026-10-15", "location": "Nancy, France", "tempMax": "18.3", "tempMin": "7", "icon": "partly-cloudy-day", "conditions": "Nuageux", "isCurrentYear": true }) + ); + assert_eq!( + convert( + json!({ "days": [{ "datetime": "2025-10-16", "tempmax": -2.5, "tempmin": -10.25, "icon": "snow", "conditions": "Snow, Heavy" }] }) + ), + json!({ "date": "2025-10-16", "location": "Nancy, France", "tempMax": "-2.5", "tempMin": "-10.25", "icon": "snow", "conditions": "Neige forte", "isCurrentYear": true }) + ); + assert_eq!( + convert( + json!({ "days": [{ "datetime": "2026-01-02", "tempmax": "12", "tempmin": true, "icon": "rain", "conditions": "Rain, Overcast" }] }) + ), + json!({ "date": "2026-01-02", "location": "Nancy, France", "tempMax": "12", "tempMin": "true", "icon": "rain", "conditions": "Couvert", "isCurrentYear": true }) + ); + assert_eq!( + convert( + json!({ "days": [{ "datetime": "2026-01-03", "tempmax": [1, 2], "tempmin": { "a": 1 }, "conditions": "Pluie modérée" }] }) + ), + json!({ "date": "2026-01-03", "location": "Nancy, France", "tempMax": "1,2", "tempMin": "[object Object]", "conditions": "Pluie modérée", "isCurrentYear": true }) + ); + assert_eq!( + convert( + json!({ "days": [{ "tempmax": 0.1, "tempmin": 1e-7, "icon": 5, "conditions": "Light Drizzle" }] }) + ), + json!({ "location": "Nancy, France", "tempMax": "0.1", "tempMin": "1e-7", "icon": 5, "conditions": "Bruine", "isCurrentYear": true }) + ); + for empty in [ + json!({ "days": [] }), + json!({ "days": { "length": 0 } }), + json!({ "nodays": 1 }), + json!("texte"), + ] { + assert_eq!(convert(empty), Value::Null); + } + for invalid in [ + json!({ "days": [{ "tempmax": null, "tempmin": 1, "conditions": "x" }] }), + json!({ "days": [{ "tempmax": 1, "tempmin": 1, "conditions": 3 }] }), + json!({ "days": [5] }), + json!({ "days": 5 }), + json!({ "days": "abc" }), + ] { + assert_eq!(convert_api_data(&invalid, "L", false), Err(TypeError)); + } + } + + #[test] + fn conditions_traduites_comme_node() { + let conditions = [ + "Clear", + "Sunny", + "Fair", + "Partially cloudy", + "Cloudy", + "Overcast", + "Rain, Heavy", + "Light Rain", + "Rain", + "Showers", + "Drizzle", + "Thunderstorm", + "Snow, Light", + "Snow", + "Fog", + "Mist", + "Windy", + "Pluie modérée", + "Rain, Partially cloudy", + "Clear, Snow", + ]; + let expected = [ + "Dégagé", + "Ensoleillé", + "Beau temps", + "Nuageux", + "Nuageux", + "Couvert", + "Pluie forte", + "Pluie légère", + "Pluie", + "Averses", + "Bruine", + "Orage", + "Neige légère", + "Neige", + "Brouillard", + "Brume", + "Venteux", + "Pluie modérée", + "Nuageux", + "Dégagé", + ]; + for (condition, french) in conditions.iter().zip(expected) { + assert_eq!(translate_condition(condition), french, "{condition}"); + } + } + + #[test] + fn icones_comme_node() { + for (icon, expected) in [ + ("clear-day", json!("☀️")), + ("clear-night", json!("🌙")), + ("partly-cloudy-day", json!("⛅")), + ("partly-cloudy-night", json!("☁️")), + ("cloudy", json!("☁️")), + ("rain", json!("🌧️")), + ("snow", json!("❄️")), + ("wind", json!("💨")), + ("fog", json!("🌫️")), + ("thunder", json!("⛈️")), + ("hail", json!("🌨️")), + ("showers-day", json!("🌤️")), + ("", json!("🌤️")), + ("__proto__", json!({})), + ] { + assert_eq!(weather_icon(Some(&json!(icon))), Some(expected), "{icon}"); + } + assert_eq!(weather_icon(Some(&json!("constructor"))), None); + assert_eq!(weather_icon(Some(&json!("toString"))), None); + assert_eq!(weather_icon(None), Some(json!("🌤️"))); + assert_eq!(weather_icon(Some(&Value::Null)), Some(json!("🌤️"))); + } + + #[test] + fn affichage_d_une_reponse_non_enregistree() { + // Réponse de l'API sans date ni icône : clés omises, icône par défaut à sa place + let record = convert_api_data( + &json!({ "days": [{ "tempmax": "21.5°", "tempmin": "n/a", "conditions": "Clear" }] }), + "Metz", + false, + ) + .unwrap() + .unwrap(); + let shown = displayed(&record); + assert_eq!( + serde_json::to_string(&shown).unwrap(), + r#"{"location":"Metz","tempMax":"21.5°","tempMin":"n/a","icon":"🌤️","conditions":"Dégagé","isCurrentYear":false,"maxTemperature":21.5,"minTemperature":null,"condition":"Dégagé"}"# + ); + } + + #[test] + fn adresses_des_services() { + assert_eq!(encode_uri_component("Nancy, France"), "Nancy%2C%20France"); + assert_eq!(encode_uri_component("Épinal (88)!*'~"), "%C3%89pinal%20(88)!*'~"); + assert_eq!( + timeline_url("Nancy, France", Some("2025-10-16"), "CLE", true), + "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/Nancy%2C%20France/2025-10-16?unitGroup=metric&include=days&key=CLE&contentType=json&lang=fr" + ); + assert!(has_items(&json!([1])) && !has_items(&json!([])) && !has_items(&json!(3))); + } +} diff --git a/server-rs/src/origin.rs b/server-rs/src/origin.rs new file mode 100644 index 0000000..849f8f3 --- /dev/null +++ b/server-rs/src/origin.rs @@ -0,0 +1,173 @@ +// Protection des requêtes d'écriture contre les sites tiers (décision R4). +// +// Le cookie de session est SameSite=Lax : un autre site ne peut pas l'envoyer +// dans un POST de formulaire ou un fetch. Ce contrôle ajoute une seconde +// barrière sans rien changer à l'interface : toute requête /api qui n'est pas +// une lecture est refusée (403) si le navigateur indique qu'elle vient d'un +// autre site. +// +// - Sec-Fetch-Site (navigateurs récents) : « same-origin » ou « none » acceptés ; +// ne dépend pas de l'en-tête Host, que nginx peut réécrire. +// - Sinon, en-tête Origin : accepté s'il est absent (outils, n8n, curl), ou si +// son hôte est celui de la requête (Host ou X-Forwarded-Host). +// - Dans les deux cas, les origines de ALLOWED_ORIGINS sont acceptées. +// - /api/ext/ (API externe, clé d'API sans cookie) n'est pas concerné. + +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +use crate::AppState; + +pub async fn check(State(state): State, request: Request, next: Next) -> Response { + let path = request.uri().path(); + let guarded = !matches!(*request.method(), Method::GET | Method::HEAD | Method::OPTIONS) + && is_api(path) + && !is_external_api(path); + if guarded && !allowed(request.headers(), &state.config.allowed_origins) { + tracing::warn!( + method = %request.method(), + path, + origin = ?request.headers().get(header::ORIGIN), + "requête d'écriture d'une autre origine refusée" + ); + return refused(); + } + next.run(request).await +} + +fn is_api(path: &str) -> bool { + let lowered = path.to_ascii_lowercase(); + lowered == "/api" || lowered.starts_with("/api/") +} + +fn is_external_api(path: &str) -> bool { + path.to_ascii_lowercase().starts_with("/api/ext/") +} + +fn allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool { + let origin = + header_text(headers, header::ORIGIN.as_str()).map(|o| o.trim_end_matches('/').to_ascii_lowercase()); + if origin.as_ref().is_some_and(|o| allowed_origins.contains(o)) { + return true; + } + if let Some(site) = header_text(headers, "sec-fetch-site") { + return matches!(site.to_ascii_lowercase().as_str(), "same-origin" | "none"); + } + let Some(origin) = origin else { + return true; + }; + let Some(origin_host) = origin.split_once("://").map(|(_, host)| host) else { + // « null » (iframe isolée, fichier local) ou valeur illisible + return false; + }; + ["x-forwarded-host", header::HOST.as_str()] + .into_iter() + .filter_map(|name| header_text(headers, name)) + .flat_map(|value| value.split(',')) + .any(|host| host.trim().eq_ignore_ascii_case(origin_host)) +} + +fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +fn refused() -> Response { + ( + StatusCode::FORBIDDEN, + [ + ( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-cache"), + ), + ( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ), + ], + r#"{"message":"Origine de la requête non autorisée"}"#, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert(*name, HeaderValue::from_static(value)); + } + map + } + + #[test] + fn sec_fetch_site() { + assert!(allowed(&headers(&[("sec-fetch-site", "same-origin")]), &[])); + assert!(allowed(&headers(&[("sec-fetch-site", "none")]), &[])); + assert!(!allowed( + &headers(&[ + ("sec-fetch-site", "cross-site"), + ("origin", "https://evil.example") + ]), + &[] + )); + assert!(!allowed( + &headers(&[ + ("sec-fetch-site", "same-site"), + ("origin", "https://autre.logiflow.fr") + ]), + &[] + )); + // Origine déclarée dans ALLOWED_ORIGINS + assert!(allowed( + &headers(&[ + ("sec-fetch-site", "same-site"), + ("origin", "https://autre.logiflow.fr/") + ]), + &["https://autre.logiflow.fr".to_string()] + )); + } + + #[test] + fn en_tete_origin() { + // Sans Origin : outils et services (n8n, curl) + assert!(allowed(&headers(&[]), &[])); + assert!(allowed( + &headers(&[("origin", "https://logiflow.fr"), ("host", "logiflow.fr")]), + &[] + )); + assert!(allowed( + &headers(&[("origin", "http://localhost:3000"), ("host", "localhost:3000")]), + &[] + )); + // nginx qui réécrit Host mais transmet X-Forwarded-Host + assert!(allowed( + &headers(&[ + ("origin", "https://LogiFlow.fr"), + ("host", "logiflow:3000"), + ("x-forwarded-host", "logiflow.fr") + ]), + &[] + )); + assert!(!allowed( + &headers(&[("origin", "https://evil.example"), ("host", "logiflow.fr")]), + &[] + )); + assert!(!allowed( + &headers(&[("origin", "null"), ("host", "logiflow.fr")]), + &[] + )); + } + + #[test] + fn adresses_concernees() { + assert!(is_api("/api/orders")); + assert!(is_api("/API/Orders")); + assert!(!is_api("/apiculture")); + assert!(is_external_api("/api/ext/v1/deliveries")); + assert!(is_external_api("/API/Ext/v1/deliveries")); + } +} diff --git a/server-rs/src/projections.rs b/server-rs/src/projections.rs new file mode 100644 index 0000000..970c08e --- /dev/null +++ b/server-rs/src/projections.rs @@ -0,0 +1,132 @@ +// Formes réduites jointes aux réponses, comme les projections de server/storage.ts : +// jamais le logo ni la configuration SMTP d'un magasin, jamais l'empreinte d'un +// mot de passe. + +use std::collections::HashMap; + +use chrono::NaiveDateTime; +use serde::Serialize; +use sqlx::{FromRow, PgPool}; + +/// groupSummaryColumns : { id, name, color } +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct GroupSummary { + pub id: i32, + pub name: String, + pub color: Option, +} + +impl GroupSummary { + /// Colonnes à sélectionner, préfixées par l'alias de la table groups + pub fn columns(alias: &str) -> String { + format!("{alias}.id, {alias}.name, {alias}.color") + } + + /// Résumés des magasins `ids`, par identifiant (seconde moitié d'un leftJoin(groups)) + pub async fn by_ids(db: &PgPool, ids: &[i32]) -> Result, sqlx::Error> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {} FROM groups g WHERE g.id = ANY($1)", + Self::columns("g") + )) + .bind(ids) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|group| (group.id, group)).collect()) + } +} + +/// groupReconciliationColumns : résumé + configuration NocoDB et webhook +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct GroupReconciliation { + pub id: i32, + pub name: String, + pub color: Option, + #[serde(rename = "nocodbConfigId")] + pub nocodb_config_id: Option, + #[serde(rename = "nocodbTableName")] + pub nocodb_table_name: Option, + #[serde(rename = "webhookUrl")] + pub webhook_url: Option, +} + +impl GroupReconciliation { + pub fn columns(alias: &str) -> String { + format!( + "{alias}.id, {alias}.name, {alias}.color, {alias}.nocodb_config_id, {alias}.nocodb_table_name, {alias}.webhook_url" + ) + } +} + +/// userSummaryColumns : créateur ou auteur joint à une ligne +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct UserSummary { + pub id: String, + #[serde(rename = "firstName")] + pub first_name: Option, + #[serde(rename = "lastName")] + pub last_name: Option, + pub username: String, + pub email: Option, +} + +impl UserSummary { + pub fn columns(alias: &str) -> String { + format!("{alias}.id, {alias}.first_name, {alias}.last_name, {alias}.username, {alias}.email") + } + + /// Résumés des utilisateurs `ids`, par identifiant (seconde moitié d'un leftJoin(users)) + pub async fn by_ids(db: &PgPool, ids: &[String]) -> Result, sqlx::Error> { + let rows: Vec = sqlx::query_as(&format!( + "SELECT {} FROM users u WHERE u.id = ANY($1)", + Self::columns("u") + )) + .bind(ids) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|user| (user.id.clone(), user)).collect()) + } +} + +/// publicUserColumns : toutes les colonnes de users sauf password +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PublicUser { + pub id: String, + pub username: String, + pub email: Option, + pub name: Option, + #[serde(rename = "firstName")] + pub first_name: Option, + #[serde(rename = "lastName")] + pub last_name: Option, + #[serde(rename = "profileImageUrl")] + pub profile_image_url: Option, + pub role: String, + #[serde(rename = "passwordChanged")] + pub password_changed: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl PublicUser { + pub fn columns(alias: &str) -> String { + [ + "id", + "username", + "email", + "name", + "first_name", + "last_name", + "profile_image_url", + "role", + "password_changed", + "created_at", + "updated_at", + ] + .iter() + .map(|column| format!("{alias}.{column}")) + .collect::>() + .join(", ") + } +} diff --git a/server-rs/src/request.rs b/server-rs/src/request.rs new file mode 100644 index 0000000..85b6f55 --- /dev/null +++ b/server-rs/src/request.rs @@ -0,0 +1,105 @@ +// Lecture des requêtes au comportement d'Express : +// - corps : express.json() / express.urlencoded() ; autre type de contenu → {} ; +// - paramètres de l'adresse : req.query (valeurs répétées jointes par « , », +// comme String(tableau)) ; +// - identifiants : parseInt(req.params.id). + +use std::collections::HashMap; + +use axum::body::Bytes; +use axum::extract::{FromRequest, FromRequestParts, Request}; +use axum::http::StatusCode; +use axum::http::header::CONTENT_TYPE; +use axum::http::request::Parts; +use serde_json::{Map, Value}; + +use crate::error::AppError; + +/// req.body +pub struct JsonBody(pub Value); + +impl FromRequest for JsonBody { + type Rejection = AppError; + + async fn from_request(request: Request, state: &S) -> Result { + let content_type = request + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.split(';').next().unwrap_or("").trim().to_ascii_lowercase()) + .unwrap_or_default(); + let bytes = Bytes::from_request(request, state) + .await + .map_err(|_| AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "request entity too large"))?; + let body = match content_type.as_str() { + "application/json" => parse_json(&bytes)?, + "application/x-www-form-urlencoded" => Value::Object( + form_urlencoded::parse(&bytes) + .map(|(key, value)| (key.into_owned(), Value::String(value.into_owned()))) + .collect(), + ), + _ => Value::Object(Map::new()), + }; + Ok(Self(body)) + } +} + +/// body-parser en mode strict : objet ou tableau, corps vide → {} +fn parse_json(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| AppError::bad_request("Invalid UTF-8"))?; + let trimmed = text.trim_start(); + if trimmed.is_empty() { + return Ok(Value::Object(Map::new())); + } + if !trimmed.starts_with(['{', '[']) { + let token = trimmed.chars().next().unwrap_or(' '); + return Err(AppError::bad_request(&format!( + "Unexpected token '{token}', \"{}\" is not valid JSON", + trimmed.chars().take(10).collect::() + ))); + } + serde_json::from_str(trimmed).map_err(|error| AppError::bad_request(&error.to_string())) +} + +/// req.query, aplati +pub struct JsQuery(pub HashMap); + +impl JsQuery { + pub fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).map(String::as_str) + } + + /// `if (req.query.x)` : présent et non vide + pub fn truthy(&self, key: &str) -> Option<&str> { + self.get(key).filter(|value| !value.is_empty()) + } +} + +impl FromRequestParts for JsQuery { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let mut values: HashMap = HashMap::new(); + if let Some(query) = parts.uri.query() { + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + values + .entry(key.into_owned()) + .and_modify(|existing| { + existing.push(','); + existing.push_str(&value); + }) + .or_insert_with(|| value.into_owned()); + } + } + Ok(Self(values)) + } +} + +/// parseInt(texte) utilisé comme identifiant integer : NaN ou hors limites fait +/// échouer la requête SQL côté Node, d'où son message 500 +pub fn int_id(raw: &str, message: &str) -> Result { + crate::js::parse_int(raw) + .filter(|n| *n >= f64::from(i32::MIN) && *n <= f64::from(i32::MAX)) + .map(|n| n as i32) + .ok_or_else(|| AppError::internal(message)) +} diff --git a/server-rs/src/router.rs b/server-rs/src/router.rs new file mode 100644 index 0000000..943abed --- /dev/null +++ b/server-rs/src/router.rs @@ -0,0 +1,77 @@ +// Table des adresses de l'API. Une adresse absente d'ici : interface +// (static_files::fallback), variante qu'Express aurait servie (casse, « / » final, +// express_routes.rs) ou page 404 d'Express. + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::middleware; +use axum::routing::get; +use tower_http::compression::CompressionLayer; +use tower_http::compression::predicate::{DefaultPredicate, Predicate, SizeAbove}; +use tower_http::trace::TraceLayer; + +use crate::modules::*; +use crate::{AppState, express_routes, http, origin, static_files}; + +// Même limite qu'express.json({ limit: '10mb' }) +const BODY_LIMIT: usize = 10 * 1024 * 1024; + +pub fn build(state: AppState) -> Router { + let api = Router::new() + .route("/api/health", get(health::health)) + // Lot 1 + .merge(contacts::routes()) + .merge(suppliers::routes()) + // Lot 2 + .merge(announcements::routes()) + .merge(publicities::routes()) + .merge(weather::routes()) + .merge(stats::routes()) + .merge(analytics::routes()) + // Lot 3 + .merge(tasks::routes()) + .merge(dlc::routes()) + .merge(customer_orders::routes()) + .merge(sav::routes()) + // Lot 4 + .merge(orders::routes()) + .merge(deliveries::routes()) + .merge(reconciliation::routes()) + .merge(avoirs::routes()) + .merge(payment_schedule::routes()) + .merge(nocodb_config::routes()) + .merge(bap::routes()) + .merge(external_api::routes()) + .merge(ffnancy::routes()) + // Lot 5 + .merge(session_routes::routes()) + .merge(users::routes()) + .merge(groups::routes()) + .merge(backups::routes()) + .merge(utilities::routes()) + .merge(admin::routes()) + // Méthode sans route sur une adresse connue : 404 d'Express + .method_not_allowed_fallback(static_files::not_found) + .route_layer(middleware::from_fn(http::api_response_headers)); + + // Pour rejouer une adresse qu'Express aurait servie (casse, « / » final) + express_routes::install(api.clone().with_state(state.clone())); + + Router::new() + .merge(api) + .fallback(static_files::fallback) + // Requêtes d'écriture venues d'un autre site refusées (décision R4) + .layer(middleware::from_fn_with_state(state.clone(), origin::check)) + .layer(DefaultBodyLimit::max(BODY_LIMIT)) + // Comme compression() d'Express : gzip au-delà de 1 Ko + .layer( + CompressionLayer::new() + .gzip(true) + .no_br() + .no_deflate() + .no_zstd() + .compress_when(DefaultPredicate::new().and(SizeAbove::new(1024))), + ) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/server-rs/src/schema.rs b/server-rs/src/schema.rs new file mode 100644 index 0000000..21bd33d --- /dev/null +++ b/server-rs/src/schema.rs @@ -0,0 +1,3003 @@ +// Généré par scripts/gen-rust-schema.ts à partir de shared/schema.ts : ne pas modifier à la main. +// Une structure par table (clés, ordre et formats JSON de Drizzle) et son schéma +// d'insertion drizzle-zod (createInsertSchema). +#![allow(dead_code)] + +use chrono::{NaiveDate, NaiveDateTime}; +use rust_decimal::Decimal; +use serde::Serialize; +use sqlx::FromRow; + +use crate::db::{Col, Table}; +use crate::zod::{Field, Object}; + +/// Table avoirs +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AvoirsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "invoiceReference")] + pub invoice_reference: Option, + #[serde(rename = "amount", serialize_with = "crate::json::numeric_opt::<2, _>")] + pub amount: Option, + #[serde(rename = "comment")] + pub comment: Option, + #[serde(rename = "commercialProcessed")] + pub commercial_processed: Option, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "webhookSent")] + pub webhook_sent: Option, + #[serde(rename = "nocodbVerified")] + pub nocodb_verified: Option, + #[serde(rename = "nocodbVerifiedAt", serialize_with = "crate::json::ts_opt")] + pub nocodb_verified_at: Option, + #[serde(rename = "processedAt", serialize_with = "crate::json::ts_opt")] + pub processed_at: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for AvoirsRow { + const TABLE: &'static str = "avoirs"; + const COLUMNS: &'static str = "\"id\", \"supplier_id\", \"group_id\", \"invoice_reference\", \"amount\", \"comment\", \"commercial_processed\", \"status\", \"webhook_sent\", \"nocodb_verified\", \"nocodb_verified_at\", \"processed_at\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "invoiceReference", + column: "invoice_reference", + sql_type: "text", + }, + Col { + key: "amount", + column: "amount", + sql_type: "numeric", + }, + Col { + key: "comment", + column: "comment", + sql_type: "text", + }, + Col { + key: "commercialProcessed", + column: "commercial_processed", + sql_type: "bool", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "webhookSent", + column: "webhook_sent", + sql_type: "bool", + }, + Col { + key: "nocodbVerified", + column: "nocodb_verified", + sql_type: "bool", + }, + Col { + key: "nocodbVerifiedAt", + column: "nocodb_verified_at", + sql_type: "timestamp", + }, + Col { + key: "processedAt", + column: "processed_at", + sql_type: "timestamp", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl AvoirsRow { + /// createInsertSchema(avoirs) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("supplierId"), + Field::int32("groupId"), + Field::string("invoiceReference") + .max_len(255) + .nullable() + .optional(), + Field::string("amount").nullable().optional(), + Field::string("comment").nullable().optional(), + Field::boolean("commercialProcessed").nullable().optional(), + Field::string("status").max_len(50).optional(), + Field::boolean("webhookSent").nullable().optional(), + Field::boolean("nocodbVerified").nullable().optional(), + Field::date("nocodbVerifiedAt").nullable().optional(), + Field::date("processedAt").nullable().optional(), + Field::string("createdBy"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table contacts +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ContactsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "company")] + pub company: Option, + #[serde(rename = "role")] + pub role: Option, + #[serde(rename = "phone")] + pub phone: Option, + #[serde(rename = "email")] + pub email: Option, + #[serde(rename = "notes")] + pub notes: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for ContactsRow { + const TABLE: &'static str = "contacts"; + const COLUMNS: &'static str = "\"id\", \"group_id\", \"name\", \"company\", \"role\", \"phone\", \"email\", \"notes\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "company", + column: "company", + sql_type: "text", + }, + Col { + key: "role", + column: "role", + sql_type: "text", + }, + Col { + key: "phone", + column: "phone", + sql_type: "text", + }, + Col { + key: "email", + column: "email", + sql_type: "text", + }, + Col { + key: "notes", + column: "notes", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl ContactsRow { + /// createInsertSchema(contacts) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("groupId"), + Field::string("name"), + Field::string("company").nullable().optional(), + Field::string("role").nullable().optional(), + Field::string("phone").nullable().optional(), + Field::string("email").nullable().optional(), + Field::string("notes").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table customer_orders +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct CustomerOrdersRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "orderTaker")] + pub order_taker: String, + #[serde(rename = "customerName")] + pub customer_name: String, + #[serde(rename = "customerPhone")] + pub customer_phone: String, + #[serde(rename = "customerEmail")] + pub customer_email: Option, + #[serde(rename = "productDesignation")] + pub product_designation: String, + #[serde(rename = "productReference")] + pub product_reference: Option, + #[serde(rename = "gencode")] + pub gencode: String, + #[serde(rename = "quantity")] + pub quantity: i32, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "deposit", serialize_with = "crate::json::numeric_opt::<2, _>")] + pub deposit: Option, + #[serde(rename = "isPromotionalPrice")] + pub is_promotional_price: Option, + #[serde(rename = "customerNotified")] + pub customer_notified: Option, + #[serde(rename = "notifiedAt", serialize_with = "crate::json::ts_opt")] + pub notified_at: Option, + #[serde(rename = "notifiedComment")] + pub notified_comment: Option, + #[serde(rename = "notes")] + pub notes: Option, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for CustomerOrdersRow { + const TABLE: &'static str = "customer_orders"; + const COLUMNS: &'static str = "\"id\", \"order_taker\", \"customer_name\", \"customer_phone\", \"customer_email\", \"product_designation\", \"product_reference\", \"gencode\", \"quantity\", \"supplier_id\", \"status\", \"deposit\", \"is_promotional_price\", \"customer_notified\", \"notified_at\", \"notified_comment\", \"notes\", \"group_id\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "orderTaker", + column: "order_taker", + sql_type: "text", + }, + Col { + key: "customerName", + column: "customer_name", + sql_type: "text", + }, + Col { + key: "customerPhone", + column: "customer_phone", + sql_type: "text", + }, + Col { + key: "customerEmail", + column: "customer_email", + sql_type: "text", + }, + Col { + key: "productDesignation", + column: "product_designation", + sql_type: "text", + }, + Col { + key: "productReference", + column: "product_reference", + sql_type: "text", + }, + Col { + key: "gencode", + column: "gencode", + sql_type: "text", + }, + Col { + key: "quantity", + column: "quantity", + sql_type: "int4", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "deposit", + column: "deposit", + sql_type: "numeric", + }, + Col { + key: "isPromotionalPrice", + column: "is_promotional_price", + sql_type: "bool", + }, + Col { + key: "customerNotified", + column: "customer_notified", + sql_type: "bool", + }, + Col { + key: "notifiedAt", + column: "notified_at", + sql_type: "timestamp", + }, + Col { + key: "notifiedComment", + column: "notified_comment", + sql_type: "text", + }, + Col { + key: "notes", + column: "notes", + sql_type: "text", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl CustomerOrdersRow { + /// createInsertSchema(customer_orders) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("orderTaker"), + Field::string("customerName"), + Field::string("customerPhone"), + Field::string("customerEmail").nullable().optional(), + Field::string("productDesignation"), + Field::string("productReference").nullable().optional(), + Field::string("gencode"), + Field::int32("quantity").optional(), + Field::int32("supplierId"), + Field::string("status").optional(), + Field::string("deposit").nullable().optional(), + Field::boolean("isPromotionalPrice").nullable().optional(), + Field::boolean("customerNotified").nullable().optional(), + Field::date("notifiedAt").nullable().optional(), + Field::string("notifiedComment").nullable().optional(), + Field::string("notes").nullable().optional(), + Field::int32("groupId"), + Field::string("createdBy"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table dashboard_messages +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct DashboardMessagesRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "title")] + pub title: String, + #[serde(rename = "content")] + pub content: String, + #[sqlx(rename = "type")] + #[serde(rename = "type")] + pub type_: String, + #[serde(rename = "storeId")] + pub store_id: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, +} + +impl Table for DashboardMessagesRow { + const TABLE: &'static str = "dashboard_messages"; + const COLUMNS: &'static str = + "\"id\", \"title\", \"content\", \"type\", \"store_id\", \"created_by\", \"created_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "title", + column: "title", + sql_type: "text", + }, + Col { + key: "content", + column: "content", + sql_type: "text", + }, + Col { + key: "type", + column: "type", + sql_type: "text", + }, + Col { + key: "storeId", + column: "store_id", + sql_type: "int4", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + ]; +} + +impl DashboardMessagesRow { + /// createInsertSchema(dashboard_messages) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("title").max_len(255), + Field::string("content"), + Field::string("type").max_len(50).optional(), + Field::int32("storeId").nullable().optional(), + Field::string("createdBy").max_len(255), + Field::date("createdAt").nullable().optional(), + ]) + } +} + +/// Table database_backups +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct DatabaseBackupsRow { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "filename")] + pub filename: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "size")] + pub size: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "tablesCount")] + pub tables_count: Option, + #[serde(rename = "status")] + pub status: Option, + #[serde(rename = "backupType")] + pub backup_type: Option, +} + +impl Table for DatabaseBackupsRow { + const TABLE: &'static str = "database_backups"; + const COLUMNS: &'static str = "\"id\", \"filename\", \"description\", \"size\", \"created_at\", \"created_by\", \"tables_count\", \"status\", \"backup_type\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "text", + }, + Col { + key: "filename", + column: "filename", + sql_type: "text", + }, + Col { + key: "description", + column: "description", + sql_type: "text", + }, + Col { + key: "size", + column: "size", + sql_type: "int8", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "tablesCount", + column: "tables_count", + sql_type: "int4", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "backupType", + column: "backup_type", + sql_type: "text", + }, + ]; +} + +impl DatabaseBackupsRow { + /// createInsertSchema(database_backups) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::string("id").max_len(255), + Field::string("filename").max_len(255), + Field::string("description").nullable().optional(), + Field::safe_integer("size").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::string("createdBy").max_len(255), + Field::int32("tablesCount").nullable().optional(), + Field::string("status").max_len(50).nullable().optional(), + Field::string("backupType").max_len(10).nullable().optional(), + ]) + } +} + +/// Table deliveries +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct DeliveriesRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "orderId")] + pub order_id: Option, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "scheduledDate")] + pub scheduled_date: NaiveDate, + #[serde(rename = "deliveredDate", serialize_with = "crate::json::ts_opt")] + pub delivered_date: Option, + #[serde(rename = "quantity")] + pub quantity: i32, + #[serde(rename = "unit")] + pub unit: String, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "notes")] + pub notes: Option, + #[serde(rename = "blNumber")] + pub bl_number: Option, + #[serde(rename = "blAmount", serialize_with = "crate::json::numeric_opt::<2, _>")] + pub bl_amount: Option, + #[serde(rename = "invoiceReference")] + pub invoice_reference: Option, + #[serde(rename = "invoiceAmount", serialize_with = "crate::json::numeric_opt::<2, _>")] + pub invoice_amount: Option, + #[serde( + rename = "invoiceAmountTTC", + serialize_with = "crate::json::numeric_opt::<2, _>" + )] + pub invoice_amount_ttc: Option, + #[serde(rename = "dueDate", serialize_with = "crate::json::ts_opt")] + pub due_date: Option, + #[serde(rename = "reconciled")] + pub reconciled: Option, + #[serde(rename = "validatedAt", serialize_with = "crate::json::ts_opt")] + pub validated_at: Option, + #[serde(rename = "controlValidated")] + pub control_validated: Option, + #[serde(rename = "controlValidatedBy")] + pub control_validated_by: Option, + #[serde(rename = "controlValidatedAt", serialize_with = "crate::json::ts_opt")] + pub control_validated_at: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for DeliveriesRow { + const TABLE: &'static str = "deliveries"; + const COLUMNS: &'static str = "\"id\", \"order_id\", \"supplier_id\", \"group_id\", \"scheduled_date\", \"delivered_date\", \"quantity\", \"unit\", \"status\", \"notes\", \"bl_number\", \"bl_amount\", \"invoice_reference\", \"invoice_amount\", \"invoice_amount_ttc\", \"due_date\", \"reconciled\", \"validated_at\", \"control_validated\", \"control_validated_by\", \"control_validated_at\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "orderId", + column: "order_id", + sql_type: "int4", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "scheduledDate", + column: "scheduled_date", + sql_type: "date", + }, + Col { + key: "deliveredDate", + column: "delivered_date", + sql_type: "timestamp", + }, + Col { + key: "quantity", + column: "quantity", + sql_type: "int4", + }, + Col { + key: "unit", + column: "unit", + sql_type: "text", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "notes", + column: "notes", + sql_type: "text", + }, + Col { + key: "blNumber", + column: "bl_number", + sql_type: "text", + }, + Col { + key: "blAmount", + column: "bl_amount", + sql_type: "numeric", + }, + Col { + key: "invoiceReference", + column: "invoice_reference", + sql_type: "text", + }, + Col { + key: "invoiceAmount", + column: "invoice_amount", + sql_type: "numeric", + }, + Col { + key: "invoiceAmountTTC", + column: "invoice_amount_ttc", + sql_type: "numeric", + }, + Col { + key: "dueDate", + column: "due_date", + sql_type: "timestamp", + }, + Col { + key: "reconciled", + column: "reconciled", + sql_type: "bool", + }, + Col { + key: "validatedAt", + column: "validated_at", + sql_type: "timestamp", + }, + Col { + key: "controlValidated", + column: "control_validated", + sql_type: "bool", + }, + Col { + key: "controlValidatedBy", + column: "control_validated_by", + sql_type: "text", + }, + Col { + key: "controlValidatedAt", + column: "control_validated_at", + sql_type: "timestamp", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl DeliveriesRow { + /// createInsertSchema(deliveries) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("orderId").nullable().optional(), + Field::int32("supplierId"), + Field::int32("groupId"), + Field::string("scheduledDate"), + Field::date("deliveredDate").nullable().optional(), + Field::int32("quantity"), + Field::string("unit"), + Field::string("status").optional(), + Field::string("notes").nullable().optional(), + Field::string("blNumber").nullable().optional(), + Field::string("blAmount").nullable().optional(), + Field::string("invoiceReference").nullable().optional(), + Field::string("invoiceAmount").nullable().optional(), + Field::string("invoiceAmountTTC").nullable().optional(), + Field::date("dueDate").nullable().optional(), + Field::boolean("reconciled").nullable().optional(), + Field::date("validatedAt").nullable().optional(), + Field::boolean("controlValidated").nullable().optional(), + Field::string("controlValidatedBy").nullable().optional(), + Field::date("controlValidatedAt").nullable().optional(), + Field::string("createdBy"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table dlc_products +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct DlcProductsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "productName")] + pub product_name: Option, + #[serde(rename = "gencode")] + pub gencode: Option, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "expiryDate")] + pub expiry_date: NaiveDate, + #[serde(rename = "dateType")] + pub date_type: String, + #[serde(rename = "quantity")] + pub quantity: i32, + #[serde(rename = "unit")] + pub unit: String, + #[serde(rename = "location")] + pub location: String, + #[serde(rename = "alertThreshold")] + pub alert_threshold: i32, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "stockEpuise")] + pub stock_epuise: Option, + #[serde(rename = "stockEpuiseBy")] + pub stock_epuise_by: Option, + #[serde(rename = "stockEpuiseAt", serialize_with = "crate::json::ts_opt")] + pub stock_epuise_at: Option, + #[serde(rename = "processedAt", serialize_with = "crate::json::ts_opt")] + pub processed_at: Option, + #[serde(rename = "processedBy")] + pub processed_by: Option, + #[serde(rename = "processedUntilExpiry")] + pub processed_until_expiry: Option, + #[serde(rename = "notes")] + pub notes: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "validatedBy")] + pub validated_by: Option, + #[serde(rename = "validatedAt", serialize_with = "crate::json::ts_opt")] + pub validated_at: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for DlcProductsRow { + const TABLE: &'static str = "dlc_products"; + const COLUMNS: &'static str = "\"id\", \"name\", \"product_name\", \"gencode\", \"supplier_id\", \"group_id\", \"expiry_date\", \"date_type\", \"quantity\", \"unit\", \"location\", \"alert_threshold\", \"status\", \"stock_epuise\", \"stock_epuise_by\", \"stock_epuise_at\", \"processed_at\", \"processed_by\", \"processed_until_expiry\", \"notes\", \"created_by\", \"validated_by\", \"validated_at\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "productName", + column: "product_name", + sql_type: "text", + }, + Col { + key: "gencode", + column: "gencode", + sql_type: "text", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "expiryDate", + column: "expiry_date", + sql_type: "date", + }, + Col { + key: "dateType", + column: "date_type", + sql_type: "text", + }, + Col { + key: "quantity", + column: "quantity", + sql_type: "int4", + }, + Col { + key: "unit", + column: "unit", + sql_type: "text", + }, + Col { + key: "location", + column: "location", + sql_type: "text", + }, + Col { + key: "alertThreshold", + column: "alert_threshold", + sql_type: "int4", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "stockEpuise", + column: "stock_epuise", + sql_type: "bool", + }, + Col { + key: "stockEpuiseBy", + column: "stock_epuise_by", + sql_type: "text", + }, + Col { + key: "stockEpuiseAt", + column: "stock_epuise_at", + sql_type: "timestamp", + }, + Col { + key: "processedAt", + column: "processed_at", + sql_type: "timestamp", + }, + Col { + key: "processedBy", + column: "processed_by", + sql_type: "text", + }, + Col { + key: "processedUntilExpiry", + column: "processed_until_expiry", + sql_type: "bool", + }, + Col { + key: "notes", + column: "notes", + sql_type: "text", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "validatedBy", + column: "validated_by", + sql_type: "text", + }, + Col { + key: "validatedAt", + column: "validated_at", + sql_type: "timestamp", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl DlcProductsRow { + /// createInsertSchema(dlc_products) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name"), + Field::string("productName").nullable().optional(), + Field::string("gencode").nullable().optional(), + Field::int32("supplierId"), + Field::int32("groupId"), + Field::string("expiryDate"), + Field::string("dateType").optional(), + Field::int32("quantity").optional(), + Field::string("unit").optional(), + Field::string("location").optional(), + Field::int32("alertThreshold").optional(), + Field::string("status").optional(), + Field::boolean("stockEpuise").nullable().optional(), + Field::string("stockEpuiseBy").nullable().optional(), + Field::date("stockEpuiseAt").nullable().optional(), + Field::date("processedAt").nullable().optional(), + Field::string("processedBy").nullable().optional(), + Field::boolean("processedUntilExpiry").nullable().optional(), + Field::string("notes").nullable().optional(), + Field::string("createdBy"), + Field::string("validatedBy").nullable().optional(), + Field::date("validatedAt").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table external_api_keys +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ExternalApiKeysRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "keyPrefix")] + pub key_prefix: String, + #[serde(rename = "keyHash")] + pub key_hash: String, + #[serde(rename = "createdBy")] + pub created_by: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "lastUsedAt", serialize_with = "crate::json::ts_opt")] + pub last_used_at: Option, + #[serde(rename = "revokedAt", serialize_with = "crate::json::ts_opt")] + pub revoked_at: Option, +} + +impl Table for ExternalApiKeysRow { + const TABLE: &'static str = "external_api_keys"; + const COLUMNS: &'static str = "\"id\", \"name\", \"key_prefix\", \"key_hash\", \"created_by\", \"created_at\", \"last_used_at\", \"revoked_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "keyPrefix", + column: "key_prefix", + sql_type: "text", + }, + Col { + key: "keyHash", + column: "key_hash", + sql_type: "text", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "lastUsedAt", + column: "last_used_at", + sql_type: "timestamp", + }, + Col { + key: "revokedAt", + column: "revoked_at", + sql_type: "timestamp", + }, + ]; +} + +impl ExternalApiKeysRow { + /// createInsertSchema(external_api_keys) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name").max_len(100), + Field::string("keyPrefix").max_len(16), + Field::string("keyHash").max_len(64), + Field::string("createdBy").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("lastUsedAt").nullable().optional(), + Field::date("revokedAt").nullable().optional(), + ]) + } +} + +/// Table groups +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct GroupsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "color")] + pub color: Option, + #[serde(rename = "nocodbConfigId")] + pub nocodb_config_id: Option, + #[serde(rename = "nocodbTableName")] + pub nocodb_table_name: Option, + #[serde(rename = "nocodbTableId")] + pub nocodb_table_id: Option, + #[serde(rename = "invoiceColumnName")] + pub invoice_column_name: Option, + #[serde(rename = "nocodbBlColumnName")] + pub nocodb_bl_column_name: Option, + #[serde(rename = "nocodbAmountColumnName")] + pub nocodb_amount_column_name: Option, + #[serde(rename = "nocodbInvoiceAmountTTCColumnName")] + pub nocodb_invoice_amount_ttc_column_name: Option, + #[serde(rename = "nocodbSupplierColumnName")] + pub nocodb_supplier_column_name: Option, + #[serde(rename = "nocodbDueDateColumnName")] + pub nocodb_due_date_column_name: Option, + #[serde(rename = "webhookUrl")] + pub webhook_url: Option, + #[serde(rename = "address")] + pub address: Option, + #[serde(rename = "phone")] + pub phone: Option, + #[serde(rename = "logo")] + pub logo: Option, + #[serde(rename = "smtpEnabled")] + pub smtp_enabled: Option, + #[serde(rename = "smtpHost")] + pub smtp_host: Option, + #[serde(rename = "smtpPort")] + pub smtp_port: Option, + #[serde(rename = "smtpSecure")] + pub smtp_secure: Option, + #[serde(rename = "smtpUser")] + pub smtp_user: Option, + #[serde(rename = "smtpPassword")] + pub smtp_password: Option, + #[serde(rename = "smtpSenderEmail")] + pub smtp_sender_email: Option, + #[serde(rename = "smtpSenderName")] + pub smtp_sender_name: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for GroupsRow { + const TABLE: &'static str = "groups"; + const COLUMNS: &'static str = "\"id\", \"name\", \"color\", \"nocodb_config_id\", \"nocodb_table_name\", \"nocodb_table_id\", \"invoice_column_name\", \"nocodb_bl_column_name\", \"nocodb_amount_column_name\", \"nocodb_invoice_amount_ttc_column_name\", \"nocodb_supplier_column_name\", \"nocodb_due_date_column_name\", \"webhook_url\", \"address\", \"phone\", \"logo\", \"smtp_enabled\", \"smtp_host\", \"smtp_port\", \"smtp_secure\", \"smtp_user\", \"smtp_password\", \"smtp_sender_email\", \"smtp_sender_name\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "color", + column: "color", + sql_type: "text", + }, + Col { + key: "nocodbConfigId", + column: "nocodb_config_id", + sql_type: "int4", + }, + Col { + key: "nocodbTableName", + column: "nocodb_table_name", + sql_type: "text", + }, + Col { + key: "nocodbTableId", + column: "nocodb_table_id", + sql_type: "text", + }, + Col { + key: "invoiceColumnName", + column: "invoice_column_name", + sql_type: "text", + }, + Col { + key: "nocodbBlColumnName", + column: "nocodb_bl_column_name", + sql_type: "text", + }, + Col { + key: "nocodbAmountColumnName", + column: "nocodb_amount_column_name", + sql_type: "text", + }, + Col { + key: "nocodbInvoiceAmountTTCColumnName", + column: "nocodb_invoice_amount_ttc_column_name", + sql_type: "text", + }, + Col { + key: "nocodbSupplierColumnName", + column: "nocodb_supplier_column_name", + sql_type: "text", + }, + Col { + key: "nocodbDueDateColumnName", + column: "nocodb_due_date_column_name", + sql_type: "text", + }, + Col { + key: "webhookUrl", + column: "webhook_url", + sql_type: "text", + }, + Col { + key: "address", + column: "address", + sql_type: "text", + }, + Col { + key: "phone", + column: "phone", + sql_type: "text", + }, + Col { + key: "logo", + column: "logo", + sql_type: "text", + }, + Col { + key: "smtpEnabled", + column: "smtp_enabled", + sql_type: "bool", + }, + Col { + key: "smtpHost", + column: "smtp_host", + sql_type: "text", + }, + Col { + key: "smtpPort", + column: "smtp_port", + sql_type: "int4", + }, + Col { + key: "smtpSecure", + column: "smtp_secure", + sql_type: "bool", + }, + Col { + key: "smtpUser", + column: "smtp_user", + sql_type: "text", + }, + Col { + key: "smtpPassword", + column: "smtp_password", + sql_type: "text", + }, + Col { + key: "smtpSenderEmail", + column: "smtp_sender_email", + sql_type: "text", + }, + Col { + key: "smtpSenderName", + column: "smtp_sender_name", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl GroupsRow { + /// createInsertSchema(groups) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name"), + Field::string("color").nullable().optional(), + Field::int32("nocodbConfigId").nullable().optional(), + Field::string("nocodbTableName").nullable().optional(), + Field::string("nocodbTableId").nullable().optional(), + Field::string("invoiceColumnName").nullable().optional(), + Field::string("nocodbBlColumnName").nullable().optional(), + Field::string("nocodbAmountColumnName").nullable().optional(), + Field::string("nocodbInvoiceAmountTTCColumnName") + .nullable() + .optional(), + Field::string("nocodbSupplierColumnName").nullable().optional(), + Field::string("nocodbDueDateColumnName").nullable().optional(), + Field::string("webhookUrl").max_len(500).nullable().optional(), + Field::string("address").nullable().optional(), + Field::string("phone").max_len(50).nullable().optional(), + Field::string("logo").nullable().optional(), + Field::boolean("smtpEnabled").nullable().optional(), + Field::string("smtpHost").max_len(255).nullable().optional(), + Field::int32("smtpPort").nullable().optional(), + Field::boolean("smtpSecure").nullable().optional(), + Field::string("smtpUser").max_len(255).nullable().optional(), + Field::string("smtpPassword").max_len(255).nullable().optional(), + Field::string("smtpSenderEmail") + .max_len(255) + .nullable() + .optional(), + Field::string("smtpSenderName").max_len(255).nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table invoice_verification_cache +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct InvoiceVerificationCacheRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "cacheKey")] + pub cache_key: String, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "invoiceReference")] + pub invoice_reference: String, + #[serde(rename = "supplierName")] + pub supplier_name: Option, + #[serde(rename = "invoiceAmount", serialize_with = "crate::json::numeric_opt::<2, _>")] + pub invoice_amount: Option, + #[serde( + rename = "invoiceAmountTTC", + serialize_with = "crate::json::numeric_opt::<2, _>" + )] + pub invoice_amount_ttc: Option, + #[serde(rename = "dueDate", serialize_with = "crate::json::ts_opt")] + pub due_date: Option, + #[serde(rename = "exists")] + pub exists: bool, + #[serde(rename = "matchType")] + pub match_type: String, + #[serde(rename = "errorMessage")] + pub error_message: Option, + #[serde(rename = "cacheHit")] + pub cache_hit: Option, + #[serde(rename = "apiCallTime")] + pub api_call_time: Option, + #[serde(rename = "isReconciled")] + pub is_reconciled: Option, + #[serde(rename = "expiresAt", serialize_with = "crate::json::ts")] + pub expires_at: NaiveDateTime, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for InvoiceVerificationCacheRow { + const TABLE: &'static str = "invoice_verification_cache"; + const COLUMNS: &'static str = "\"id\", \"cache_key\", \"group_id\", \"invoice_reference\", \"supplier_name\", \"invoice_amount\", \"invoice_amount_ttc\", \"due_date\", \"exists\", \"match_type\", \"error_message\", \"cache_hit\", \"api_call_time\", \"is_reconciled\", \"expires_at\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "cacheKey", + column: "cache_key", + sql_type: "text", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "invoiceReference", + column: "invoice_reference", + sql_type: "text", + }, + Col { + key: "supplierName", + column: "supplier_name", + sql_type: "text", + }, + Col { + key: "invoiceAmount", + column: "invoice_amount", + sql_type: "numeric", + }, + Col { + key: "invoiceAmountTTC", + column: "invoice_amount_ttc", + sql_type: "numeric", + }, + Col { + key: "dueDate", + column: "due_date", + sql_type: "timestamp", + }, + Col { + key: "exists", + column: "exists", + sql_type: "bool", + }, + Col { + key: "matchType", + column: "match_type", + sql_type: "text", + }, + Col { + key: "errorMessage", + column: "error_message", + sql_type: "text", + }, + Col { + key: "cacheHit", + column: "cache_hit", + sql_type: "bool", + }, + Col { + key: "apiCallTime", + column: "api_call_time", + sql_type: "int4", + }, + Col { + key: "isReconciled", + column: "is_reconciled", + sql_type: "bool", + }, + Col { + key: "expiresAt", + column: "expires_at", + sql_type: "timestamp", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl InvoiceVerificationCacheRow { + /// createInsertSchema(invoice_verification_cache) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("cacheKey").max_len(255), + Field::int32("groupId"), + Field::string("invoiceReference").max_len(255), + Field::string("supplierName").max_len(255).nullable().optional(), + Field::string("invoiceAmount").nullable().optional(), + Field::string("invoiceAmountTTC").nullable().optional(), + Field::date("dueDate").nullable().optional(), + Field::boolean("exists"), + Field::string("matchType").max_len(50), + Field::string("errorMessage").nullable().optional(), + Field::boolean("cacheHit").nullable().optional(), + Field::int32("apiCallTime").nullable().optional(), + Field::boolean("isReconciled").nullable().optional(), + Field::date("expiresAt"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table nocodb_config +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct NocodbConfigRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "baseUrl")] + pub base_url: String, + #[serde(rename = "projectId")] + pub project_id: String, + #[serde(rename = "apiToken")] + pub api_token: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "isActive")] + pub is_active: Option, + #[serde(rename = "createdBy")] + pub created_by: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for NocodbConfigRow { + const TABLE: &'static str = "nocodb_config"; + const COLUMNS: &'static str = "\"id\", \"name\", \"base_url\", \"project_id\", \"api_token\", \"description\", \"is_active\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "baseUrl", + column: "base_url", + sql_type: "text", + }, + Col { + key: "projectId", + column: "project_id", + sql_type: "text", + }, + Col { + key: "apiToken", + column: "api_token", + sql_type: "text", + }, + Col { + key: "description", + column: "description", + sql_type: "text", + }, + Col { + key: "isActive", + column: "is_active", + sql_type: "bool", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl NocodbConfigRow { + /// createInsertSchema(nocodb_config) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name"), + Field::string("baseUrl"), + Field::string("projectId"), + Field::string("apiToken"), + Field::string("description").nullable().optional(), + Field::boolean("isActive").nullable().optional(), + Field::string("createdBy").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table orders +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct OrdersRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "plannedDate")] + pub planned_date: NaiveDate, + #[serde(rename = "quantity")] + pub quantity: Option, + #[serde(rename = "unit")] + pub unit: Option, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "notes")] + pub notes: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for OrdersRow { + const TABLE: &'static str = "orders"; + const COLUMNS: &'static str = "\"id\", \"supplier_id\", \"group_id\", \"planned_date\", \"quantity\", \"unit\", \"status\", \"notes\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "plannedDate", + column: "planned_date", + sql_type: "date", + }, + Col { + key: "quantity", + column: "quantity", + sql_type: "int4", + }, + Col { + key: "unit", + column: "unit", + sql_type: "text", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "notes", + column: "notes", + sql_type: "text", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl OrdersRow { + /// createInsertSchema(orders) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("supplierId"), + Field::int32("groupId"), + Field::string("plannedDate"), + Field::int32("quantity").nullable().optional(), + Field::string("unit").nullable().optional(), + Field::string("status").optional(), + Field::string("notes").nullable().optional(), + Field::string("createdBy"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table publicities +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PublicitiesRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "pubNumber")] + pub pub_number: String, + #[serde(rename = "designation")] + pub designation: String, + #[serde(rename = "startDate")] + pub start_date: NaiveDate, + #[serde(rename = "endDate")] + pub end_date: NaiveDate, + #[serde(rename = "year")] + pub year: i32, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for PublicitiesRow { + const TABLE: &'static str = "publicities"; + const COLUMNS: &'static str = "\"id\", \"pub_number\", \"designation\", \"start_date\", \"end_date\", \"year\", \"created_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "pubNumber", + column: "pub_number", + sql_type: "text", + }, + Col { + key: "designation", + column: "designation", + sql_type: "text", + }, + Col { + key: "startDate", + column: "start_date", + sql_type: "date", + }, + Col { + key: "endDate", + column: "end_date", + sql_type: "date", + }, + Col { + key: "year", + column: "year", + sql_type: "int4", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl PublicitiesRow { + /// createInsertSchema(publicities) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("pubNumber"), + Field::string("designation"), + Field::string("startDate"), + Field::string("endDate"), + Field::int32("year"), + Field::string("createdBy"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table publicity_participations +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PublicityParticipationsRow { + #[serde(rename = "publicityId")] + pub publicity_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, +} + +impl Table for PublicityParticipationsRow { + const TABLE: &'static str = "publicity_participations"; + const COLUMNS: &'static str = "\"publicity_id\", \"group_id\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "publicityId", + column: "publicity_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + ]; +} + +impl PublicityParticipationsRow { + /// createInsertSchema(publicity_participations) + pub fn insert_schema() -> Object { + Object::new(vec![Field::int32("publicityId"), Field::int32("groupId")]) + } +} + +/// Table reconciliation_comments +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct ReconciliationCommentsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "deliveryId")] + pub delivery_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "content")] + pub content: String, + #[sqlx(rename = "type")] + #[serde(rename = "type")] + pub type_: String, + #[serde(rename = "authorId")] + pub author_id: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for ReconciliationCommentsRow { + const TABLE: &'static str = "reconciliation_comments"; + const COLUMNS: &'static str = "\"id\", \"delivery_id\", \"group_id\", \"content\", \"type\", \"author_id\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "deliveryId", + column: "delivery_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "content", + column: "content", + sql_type: "text", + }, + Col { + key: "type", + column: "type", + sql_type: "text", + }, + Col { + key: "authorId", + column: "author_id", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl ReconciliationCommentsRow { + /// createInsertSchema(reconciliation_comments) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("deliveryId"), + Field::int32("groupId"), + Field::string("content"), + Field::string("type").optional(), + Field::string("authorId"), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table sav_tickets +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SavTicketsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "ticketNumber")] + pub ticket_number: String, + #[serde(rename = "supplierId")] + pub supplier_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "productGencode")] + pub product_gencode: String, + #[serde(rename = "productReference")] + pub product_reference: Option, + #[serde(rename = "productDesignation")] + pub product_designation: String, + #[serde(rename = "problemType")] + pub problem_type: String, + #[serde(rename = "problemDescription")] + pub problem_description: String, + #[serde(rename = "resolutionDescription")] + pub resolution_description: Option, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "priority")] + pub priority: String, + #[serde(rename = "clientName")] + pub client_name: Option, + #[serde(rename = "clientPhone")] + pub client_phone: Option, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, + #[serde(rename = "resolvedAt", serialize_with = "crate::json::ts_opt")] + pub resolved_at: Option, + #[serde(rename = "closedAt", serialize_with = "crate::json::ts_opt")] + pub closed_at: Option, +} + +impl Table for SavTicketsRow { + const TABLE: &'static str = "sav_tickets"; + const COLUMNS: &'static str = "\"id\", \"ticket_number\", \"supplier_id\", \"group_id\", \"product_gencode\", \"product_reference\", \"product_designation\", \"problem_type\", \"problem_description\", \"resolution_description\", \"status\", \"priority\", \"client_name\", \"client_phone\", \"created_by\", \"created_at\", \"updated_at\", \"resolved_at\", \"closed_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "ticketNumber", + column: "ticket_number", + sql_type: "text", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "productGencode", + column: "product_gencode", + sql_type: "text", + }, + Col { + key: "productReference", + column: "product_reference", + sql_type: "text", + }, + Col { + key: "productDesignation", + column: "product_designation", + sql_type: "text", + }, + Col { + key: "problemType", + column: "problem_type", + sql_type: "text", + }, + Col { + key: "problemDescription", + column: "problem_description", + sql_type: "text", + }, + Col { + key: "resolutionDescription", + column: "resolution_description", + sql_type: "text", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "priority", + column: "priority", + sql_type: "text", + }, + Col { + key: "clientName", + column: "client_name", + sql_type: "text", + }, + Col { + key: "clientPhone", + column: "client_phone", + sql_type: "text", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + Col { + key: "resolvedAt", + column: "resolved_at", + sql_type: "timestamp", + }, + Col { + key: "closedAt", + column: "closed_at", + sql_type: "timestamp", + }, + ]; +} + +impl SavTicketsRow { + /// createInsertSchema(sav_tickets) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("ticketNumber").max_len(50), + Field::int32("supplierId"), + Field::int32("groupId"), + Field::string("productGencode").max_len(255), + Field::string("productReference") + .max_len(255) + .nullable() + .optional(), + Field::string("productDesignation").max_len(500), + Field::string("problemType").max_len(100), + Field::string("problemDescription"), + Field::string("resolutionDescription").nullable().optional(), + Field::string("status").max_len(50).optional(), + Field::string("priority").max_len(50).optional(), + Field::string("clientName").max_len(255).nullable().optional(), + Field::string("clientPhone").max_len(255).nullable().optional(), + Field::string("createdBy").max_len(255), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + Field::date("resolvedAt").nullable().optional(), + Field::date("closedAt").nullable().optional(), + ]) + } +} + +/// Table sessions +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SessionsRow { + #[serde(rename = "sid")] + pub sid: String, + #[serde(rename = "sess")] + pub sess: serde_json::Value, + #[serde(rename = "expire", serialize_with = "crate::json::ts")] + pub expire: NaiveDateTime, +} + +impl Table for SessionsRow { + const TABLE: &'static str = "sessions"; + const COLUMNS: &'static str = "\"sid\", \"sess\", \"expire\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "sid", + column: "sid", + sql_type: "text", + }, + Col { + key: "sess", + column: "sess", + sql_type: "jsonb", + }, + Col { + key: "expire", + column: "expire", + sql_type: "timestamp", + }, + ]; +} + +impl SessionsRow { + /// createInsertSchema(sessions) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::string("sid"), + Field::json("sess"), + Field::date("expire"), + ]) + } +} + +/// Table supplier_mail_logs +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SupplierMailLogsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "deliveryId")] + pub delivery_id: i32, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "supplierId")] + pub supplier_id: Option, + #[serde(rename = "supplierName")] + pub supplier_name: Option, + #[serde(rename = "sentTo")] + pub sent_to: String, + #[serde(rename = "subject")] + pub subject: Option, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "errorMessage")] + pub error_message: Option, + #[serde(rename = "messageId")] + pub message_id: Option, + #[serde(rename = "sentBy")] + pub sent_by: String, + #[serde(rename = "sentByName")] + pub sent_by_name: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, +} + +impl Table for SupplierMailLogsRow { + const TABLE: &'static str = "supplier_mail_logs"; + const COLUMNS: &'static str = "\"id\", \"delivery_id\", \"group_id\", \"supplier_id\", \"supplier_name\", \"sent_to\", \"subject\", \"status\", \"error_message\", \"message_id\", \"sent_by\", \"sent_by_name\", \"created_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "deliveryId", + column: "delivery_id", + sql_type: "int4", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "supplierId", + column: "supplier_id", + sql_type: "int4", + }, + Col { + key: "supplierName", + column: "supplier_name", + sql_type: "text", + }, + Col { + key: "sentTo", + column: "sent_to", + sql_type: "text", + }, + Col { + key: "subject", + column: "subject", + sql_type: "text", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "errorMessage", + column: "error_message", + sql_type: "text", + }, + Col { + key: "messageId", + column: "message_id", + sql_type: "text", + }, + Col { + key: "sentBy", + column: "sent_by", + sql_type: "text", + }, + Col { + key: "sentByName", + column: "sent_by_name", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + ]; +} + +impl SupplierMailLogsRow { + /// createInsertSchema(supplier_mail_logs) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("deliveryId"), + Field::int32("groupId"), + Field::int32("supplierId").nullable().optional(), + Field::string("supplierName").max_len(255).nullable().optional(), + Field::string("sentTo").max_len(255), + Field::string("subject").nullable().optional(), + Field::string("status").max_len(20), + Field::string("errorMessage").nullable().optional(), + Field::string("messageId").max_len(255).nullable().optional(), + Field::string("sentBy"), + Field::string("sentByName").max_len(255).nullable().optional(), + Field::date("createdAt").nullable().optional(), + ]) + } +} + +/// Table suppliers +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct SuppliersRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "contact")] + pub contact: Option, + #[serde(rename = "phone")] + pub phone: Option, + #[serde(rename = "codefou")] + pub codefou: Option, + #[serde(rename = "email")] + pub email: Option, + #[serde(rename = "hasDlc")] + pub has_dlc: Option, + #[serde(rename = "paymentMethod")] + pub payment_method: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, + #[serde(rename = "automaticReconciliation")] + pub automatic_reconciliation: Option, + #[serde(rename = "requiresControl")] + pub requires_control: Option, +} + +impl Table for SuppliersRow { + const TABLE: &'static str = "suppliers"; + const COLUMNS: &'static str = "\"id\", \"name\", \"contact\", \"phone\", \"codefou\", \"email\", \"has_dlc\", \"payment_method\", \"created_at\", \"updated_at\", \"automatic_reconciliation\", \"requires_control\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "contact", + column: "contact", + sql_type: "text", + }, + Col { + key: "phone", + column: "phone", + sql_type: "text", + }, + Col { + key: "codefou", + column: "codefou", + sql_type: "text", + }, + Col { + key: "email", + column: "email", + sql_type: "text", + }, + Col { + key: "hasDlc", + column: "has_dlc", + sql_type: "bool", + }, + Col { + key: "paymentMethod", + column: "payment_method", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + Col { + key: "automaticReconciliation", + column: "automatic_reconciliation", + sql_type: "bool", + }, + Col { + key: "requiresControl", + column: "requires_control", + sql_type: "bool", + }, + ]; +} + +impl SuppliersRow { + /// createInsertSchema(suppliers) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name"), + Field::string("contact").nullable().optional(), + Field::string("phone").nullable().optional(), + Field::string("codefou").nullable().optional(), + Field::string("email").nullable().optional(), + Field::boolean("hasDlc").nullable().optional(), + Field::string("paymentMethod").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + Field::boolean("automaticReconciliation").nullable().optional(), + Field::boolean("requiresControl").nullable().optional(), + ]) + } +} + +/// Table tasks +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct TasksRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "title")] + pub title: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "startDate", serialize_with = "crate::json::ts_opt")] + pub start_date: Option, + #[serde(rename = "dueDate")] + pub due_date: Option, + #[serde(rename = "priority")] + pub priority: String, + #[serde(rename = "status")] + pub status: String, + #[serde(rename = "assignedTo")] + pub assigned_to: String, + #[serde(rename = "createdBy")] + pub created_by: String, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "completedAt", serialize_with = "crate::json::ts_opt")] + pub completed_at: Option, + #[serde(rename = "completedBy")] + pub completed_by: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for TasksRow { + const TABLE: &'static str = "tasks"; + const COLUMNS: &'static str = "\"id\", \"title\", \"description\", \"start_date\", \"due_date\", \"priority\", \"status\", \"assigned_to\", \"created_by\", \"group_id\", \"completed_at\", \"completed_by\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "title", + column: "title", + sql_type: "text", + }, + Col { + key: "description", + column: "description", + sql_type: "text", + }, + Col { + key: "startDate", + column: "start_date", + sql_type: "timestamp", + }, + Col { + key: "dueDate", + column: "due_date", + sql_type: "date", + }, + Col { + key: "priority", + column: "priority", + sql_type: "text", + }, + Col { + key: "status", + column: "status", + sql_type: "text", + }, + Col { + key: "assignedTo", + column: "assigned_to", + sql_type: "text", + }, + Col { + key: "createdBy", + column: "created_by", + sql_type: "text", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "completedAt", + column: "completed_at", + sql_type: "timestamp", + }, + Col { + key: "completedBy", + column: "completed_by", + sql_type: "text", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl TasksRow { + /// createInsertSchema(tasks) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("title"), + Field::string("description").nullable().optional(), + Field::date("startDate").nullable().optional(), + Field::string("dueDate").nullable().optional(), + Field::string("priority").optional(), + Field::string("status").optional(), + Field::string("assignedTo"), + Field::string("createdBy"), + Field::int32("groupId"), + Field::date("completedAt").nullable().optional(), + Field::string("completedBy").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table user_groups +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct UserGroupsRow { + #[serde(rename = "userId")] + pub user_id: String, + #[serde(rename = "groupId")] + pub group_id: i32, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, +} + +impl Table for UserGroupsRow { + const TABLE: &'static str = "user_groups"; + const COLUMNS: &'static str = "\"user_id\", \"group_id\", \"created_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "userId", + column: "user_id", + sql_type: "text", + }, + Col { + key: "groupId", + column: "group_id", + sql_type: "int4", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + ]; +} + +impl UserGroupsRow { + /// createInsertSchema(user_groups) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::string("userId"), + Field::int32("groupId"), + Field::date("createdAt").nullable().optional(), + ]) + } +} + +/// Table users +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct UsersRow { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "username")] + pub username: String, + #[serde(rename = "email")] + pub email: Option, + #[serde(rename = "name")] + pub name: Option, + #[serde(rename = "firstName")] + pub first_name: Option, + #[serde(rename = "lastName")] + pub last_name: Option, + #[serde(rename = "profileImageUrl")] + pub profile_image_url: Option, + #[serde(rename = "password")] + pub password: Option, + #[serde(rename = "role")] + pub role: String, + #[serde(rename = "passwordChanged")] + pub password_changed: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for UsersRow { + const TABLE: &'static str = "users"; + const COLUMNS: &'static str = "\"id\", \"username\", \"email\", \"name\", \"first_name\", \"last_name\", \"profile_image_url\", \"password\", \"role\", \"password_changed\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "text", + }, + Col { + key: "username", + column: "username", + sql_type: "text", + }, + Col { + key: "email", + column: "email", + sql_type: "text", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "firstName", + column: "first_name", + sql_type: "text", + }, + Col { + key: "lastName", + column: "last_name", + sql_type: "text", + }, + Col { + key: "profileImageUrl", + column: "profile_image_url", + sql_type: "text", + }, + Col { + key: "password", + column: "password", + sql_type: "text", + }, + Col { + key: "role", + column: "role", + sql_type: "text", + }, + Col { + key: "passwordChanged", + column: "password_changed", + sql_type: "bool", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl UsersRow { + /// createInsertSchema(users) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::string("id"), + Field::string("username"), + Field::string("email").nullable().optional(), + Field::string("name").nullable().optional(), + Field::string("firstName").nullable().optional(), + Field::string("lastName").nullable().optional(), + Field::string("profileImageUrl").nullable().optional(), + Field::string("password").nullable().optional(), + Field::string("role").optional(), + Field::boolean("passwordChanged").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table utilities +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct UtilitiesRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "salesAnalysisUrl")] + pub sales_analysis_url: Option, + #[serde(rename = "automaticBackupsEnabled")] + pub automatic_backups_enabled: Option, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for UtilitiesRow { + const TABLE: &'static str = "utilities"; + const COLUMNS: &'static str = + "\"id\", \"sales_analysis_url\", \"automatic_backups_enabled\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "salesAnalysisUrl", + column: "sales_analysis_url", + sql_type: "text", + }, + Col { + key: "automaticBackupsEnabled", + column: "automatic_backups_enabled", + sql_type: "bool", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl UtilitiesRow { + /// createInsertSchema(utilities) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("salesAnalysisUrl") + .max_len(500) + .nullable() + .optional(), + Field::boolean("automaticBackupsEnabled").nullable().optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table weather_data +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct WeatherDataRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "date")] + pub date: NaiveDate, + #[serde(rename = "location")] + pub location: String, + #[serde(rename = "tempMax")] + pub temp_max: String, + #[serde(rename = "tempMin")] + pub temp_min: String, + #[serde(rename = "icon")] + pub icon: String, + #[serde(rename = "conditions")] + pub conditions: String, + #[serde(rename = "isCurrentYear")] + pub is_current_year: bool, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for WeatherDataRow { + const TABLE: &'static str = "weather_data"; + const COLUMNS: &'static str = "\"id\", \"date\", \"location\", \"temp_max\", \"temp_min\", \"icon\", \"conditions\", \"is_current_year\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "date", + column: "date", + sql_type: "date", + }, + Col { + key: "location", + column: "location", + sql_type: "text", + }, + Col { + key: "tempMax", + column: "temp_max", + sql_type: "text", + }, + Col { + key: "tempMin", + column: "temp_min", + sql_type: "text", + }, + Col { + key: "icon", + column: "icon", + sql_type: "text", + }, + Col { + key: "conditions", + column: "conditions", + sql_type: "text", + }, + Col { + key: "isCurrentYear", + column: "is_current_year", + sql_type: "bool", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl WeatherDataRow { + /// createInsertSchema(weather_data) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("date"), + Field::string("location"), + Field::string("tempMax"), + Field::string("tempMin"), + Field::string("icon"), + Field::string("conditions"), + Field::boolean("isCurrentYear").optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table weather_settings +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct WeatherSettingsRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "apiKey")] + pub api_key: String, + #[serde(rename = "location")] + pub location: String, + #[serde(rename = "isActive")] + pub is_active: bool, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for WeatherSettingsRow { + const TABLE: &'static str = "weather_settings"; + const COLUMNS: &'static str = + "\"id\", \"api_key\", \"location\", \"is_active\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "apiKey", + column: "api_key", + sql_type: "text", + }, + Col { + key: "location", + column: "location", + sql_type: "text", + }, + Col { + key: "isActive", + column: "is_active", + sql_type: "bool", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl WeatherSettingsRow { + /// createInsertSchema(weather_settings) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("apiKey"), + Field::string("location").optional(), + Field::boolean("isActive").optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} + +/// Table webhook_bap_config +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct WebhookBapConfigRow { + #[serde(rename = "id")] + pub id: i32, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "webhookUrl")] + pub webhook_url: String, + #[serde(rename = "description")] + pub description: Option, + #[serde(rename = "isActive")] + pub is_active: bool, + #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] + pub created_at: Option, + #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] + pub updated_at: Option, +} + +impl Table for WebhookBapConfigRow { + const TABLE: &'static str = "webhook_bap_config"; + const COLUMNS: &'static str = + "\"id\", \"name\", \"webhook_url\", \"description\", \"is_active\", \"created_at\", \"updated_at\""; + const FIELDS: &'static [Col] = &[ + Col { + key: "id", + column: "id", + sql_type: "int4", + }, + Col { + key: "name", + column: "name", + sql_type: "text", + }, + Col { + key: "webhookUrl", + column: "webhook_url", + sql_type: "text", + }, + Col { + key: "description", + column: "description", + sql_type: "text", + }, + Col { + key: "isActive", + column: "is_active", + sql_type: "bool", + }, + Col { + key: "createdAt", + column: "created_at", + sql_type: "timestamp", + }, + Col { + key: "updatedAt", + column: "updated_at", + sql_type: "timestamp", + }, + ]; +} + +impl WebhookBapConfigRow { + /// createInsertSchema(webhook_bap_config) + pub fn insert_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::string("name").max_len(100).optional(), + Field::string("webhookUrl"), + Field::string("description").nullable().optional(), + Field::boolean("isActive").optional(), + Field::date("createdAt").nullable().optional(), + Field::date("updatedAt").nullable().optional(), + ]) + } +} diff --git a/server-rs/src/services/dates.rs b/server-rs/src/services/dates.rs new file mode 100644 index 0000000..04c0ed8 --- /dev/null +++ b/server-rs/src/services/dates.rs @@ -0,0 +1,762 @@ +// Dates reçues de NocoDB, au comportement du serveur Node : +// - normalize_date_string : server/dateUtils.ts (normalizeDateString), échéance +// ramenée à AAAA-MM-JJ, jour avant mois quand l'ordre est ambigu ; +// - parse_js_date / js_date : new Date(valeur) de V8 (Node 22), formats ISO et +// « anciens » (10/25/2026, 2026/10/25, Oct 25 2026, …), utilisé pour les +// échéances écrites en base. Le serveur Node tourne en UTC : l'heure locale +// de V8 est ici l'UTC. + +use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc}; +use serde_json::Value; + +/// normalizeDateString(valeur) : None pour tout ce qui n'est pas une chaîne +pub fn normalize_date_value(value: &Value) -> Option { + value.as_str().and_then(normalize_date_string) +} + +/// normalizeDateString(texte) : AAAA-MM-JJ, ou None si la date n'est pas reconnue. +/// Une chaîne déjà au format AAAA-MM-JJ est rendue telle quelle, même invalide +/// (« 2026-13-45 »), comme Node. +pub fn normalize_date_string(text: &str) -> Option { + let trimmed = crate::js::trim(text); + if trimmed.is_empty() { + return None; + } + if is_iso_day(trimmed) { + return Some(trimmed.to_string()); + } + if let Some((first, second, year)) = split_numeric_date(trimmed) { + let first_num: u32 = first.parse().ok()?; + let second_num: u32 = second.parse().ok()?; + if first_num > 31 || second_num > 31 || first_num == 0 || second_num == 0 { + return None; + } + // Premier nombre > 12 : JJ/MM ; second > 12 : MM/JJ ; sinon JJ/MM par défaut + let (day, month) = if first_num <= 12 && second_num > 12 { + (second, first) + } else { + (first, second) + }; + let (day, month) = (format!("{day:0>2}"), format!("{month:0>2}")); + let month_num: u32 = month.parse().ok()?; + if !(1..=12).contains(&month_num) { + return None; + } + return Some(format!("{year}-{month}-{day}")); + } + let date = parse_js_date(trimmed)?; + Some(format!("{}-{:02}-{:02}", date.year(), date.month(), date.day())) +} + +/// /^\d{4}-\d{2}-\d{2}$/ +fn is_iso_day(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()) +} + +/// /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/ +fn split_numeric_date(text: &str) -> Option<(&str, &str, &str)> { + let mut parts = text.split(['/', '-']); + let (first, second, year) = (parts.next()?, parts.next()?, parts.next()?); + let digits = |part: &str, min: usize, max: usize| { + (min..=max).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit()) + }; + (parts.next().is_none() && digits(first, 1, 2) && digits(second, 1, 2) && digits(year, 4, 4)) + .then_some((first, second, year)) +} + +/// new Date(valeur) : None pour « Invalid Date » +pub fn js_date(value: &Value) -> Option> { + match value { + Value::String(text) => parse_js_date(text), + Value::Array(_) => parse_js_date(&crate::js::to_js_string(value)), + Value::Object(_) => None, + other => crate::js::new_date(other), + } +} + +// ---------- Analyse des dates de V8 (src/date/dateparser-inl.h) ---------- + +const NONE: i64 = i64::MIN; +const MAX_TIME_MS: f64 = 8.64e15; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Keyword { + Month(i64), + AmPm(i64), + TimeZone(i64), + TimeSeparator, + Invalid, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Token { + End, + Number { value: i64, length: usize }, + Symbol(char), + Word { keyword: Keyword, length: usize }, + WhiteSpace, + Unknown, +} + +impl Token { + fn is_fixed_number(&self, length: usize) -> bool { + matches!(self, Token::Number { length: l, .. } if *l == length) + } + fn number(&self) -> i64 { + match self { + Token::Number { value, .. } => *value, + _ => NONE, + } + } + fn is_sign(&self) -> bool { + matches!(self, Token::Symbol('+' | '-')) + } + fn sign(&self) -> i64 { + if matches!(self, Token::Symbol('-')) { -1 } else { 1 } + } + fn is_keyword_z(&self) -> bool { + matches!( + self, + Token::Word { + keyword: Keyword::TimeZone(0), + length: 1 + } + ) + } +} + +const KEYWORDS: [(&str, Keyword); 27] = [ + ("jan", Keyword::Month(1)), + ("feb", Keyword::Month(2)), + ("mar", Keyword::Month(3)), + ("apr", Keyword::Month(4)), + ("may", Keyword::Month(5)), + ("jun", Keyword::Month(6)), + ("jul", Keyword::Month(7)), + ("aug", Keyword::Month(8)), + ("sep", Keyword::Month(9)), + ("oct", Keyword::Month(10)), + ("nov", Keyword::Month(11)), + ("dec", Keyword::Month(12)), + ("am", Keyword::AmPm(0)), + ("pm", Keyword::AmPm(12)), + ("ut", Keyword::TimeZone(0)), + ("utc", Keyword::TimeZone(0)), + ("z", Keyword::TimeZone(0)), + ("gmt", Keyword::TimeZone(0)), + ("cdt", Keyword::TimeZone(-5)), + ("cst", Keyword::TimeZone(-6)), + ("edt", Keyword::TimeZone(-4)), + ("est", Keyword::TimeZone(-5)), + ("mdt", Keyword::TimeZone(-6)), + ("mst", Keyword::TimeZone(-7)), + ("pdt", Keyword::TimeZone(-7)), + ("pst", Keyword::TimeZone(-8)), + ("t", Keyword::TimeSeparator), +]; + +/// KeywordTable::Lookup : les 3 premières lettres ; un mot plus long n'est +/// reconnu que pour un nom de mois +fn lookup_keyword(prefix: &str, length: usize) -> Keyword { + KEYWORDS + .iter() + .find(|(name, keyword)| *name == prefix && (length <= 3 || matches!(keyword, Keyword::Month(_)))) + .map(|(_, keyword)| *keyword) + .unwrap_or(Keyword::Invalid) +} + +/// Blancs pour V8 (IsWhiteSpaceChar) : blancs Unicode et fins de ligne +fn is_v8_whitespace(c: char) -> bool { + c.is_whitespace() || c == '\u{FEFF}' +} + +struct Tokenizer { + chars: Vec, + pos: usize, + next: Token, +} + +impl Tokenizer { + fn new(text: &str) -> Self { + let mut tokenizer = Self { + chars: text.chars().collect(), + pos: 0, + next: Token::End, + }; + tokenizer.next = tokenizer.scan(); + tokenizer + } + + fn peek(&self) -> Token { + self.next + } + + fn next(&mut self) -> Token { + let current = self.next; + self.next = self.scan(); + current + } + + fn skip_symbol(&mut self, symbol: char) -> bool { + if self.next == Token::Symbol(symbol) { + self.next(); + true + } else { + false + } + } + + fn scan(&mut self) -> Token { + let Some(&c) = self.chars.get(self.pos) else { + return Token::End; + }; + if c.is_ascii_digit() { + // ReadUnsignedNumeral : zéros de tête ignorés, 9 chiffres significatifs au plus + let start = self.pos; + while self.chars.get(self.pos) == Some(&'0') { + self.pos += 1; + } + let mut value: i64 = 0; + let mut significant = 0; + while let Some(d) = self.chars.get(self.pos).and_then(|c| c.to_digit(10)) { + if significant < 9 { + value = value * 10 + i64::from(d); + } + significant += 1; + self.pos += 1; + } + return Token::Number { + value, + length: self.pos - start, + }; + } + if matches!(c, ':' | '-' | '+' | '.' | ')') { + self.pos += 1; + return Token::Symbol(c); + } + if c >= 'A' && !is_v8_whitespace(c) { + let start = self.pos; + while self + .chars + .get(self.pos) + .is_some_and(|c| *c >= 'A' && !is_v8_whitespace(*c)) + { + self.pos += 1; + } + let length = self.pos - start; + let prefix: String = self.chars[start..self.pos] + .iter() + .take(3) + .map(|c| c.to_ascii_lowercase()) + .collect(); + return Token::Word { + keyword: lookup_keyword(&prefix, length), + length, + }; + } + if is_v8_whitespace(c) { + while self.chars.get(self.pos).is_some_and(|c| is_v8_whitespace(*c)) { + self.pos += 1; + } + return Token::WhiteSpace; + } + if c == '(' { + // SkipParentheses : commentaire entre parenthèses, imbrications comprises + let mut depth = 0; + while let Some(&c) = self.chars.get(self.pos) { + self.pos += 1; + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + break; + } + } + } + return Token::Unknown; + } + self.pos += 1; + Token::Unknown + } +} + +#[derive(Default)] +struct DayComposer { + comp: Vec, + named_month: Option, + iso_date: bool, +} + +impl DayComposer { + fn add(&mut self, n: i64) -> bool { + if self.comp.len() < 3 { + self.comp.push(n); + true + } else { + false + } + } + + fn write(&self) -> Option<(i64, i64, i64)> { + if self.comp.is_empty() { + return None; + } + let mut comp = self.comp.clone(); + comp.resize(3, 1); + let is_day = |n: i64| (1..=31).contains(&n); + let (mut year, month, day) = match self.named_month { + None if self.iso_date || !is_day(comp[0]) => (comp[0], comp[1], comp[2]), + None => (comp[2], comp[0], comp[1]), + Some(month) if !is_day(comp[0]) => (comp[0], month, comp[1]), + Some(month) => (comp[1], month, comp[0]), + }; + if !self.iso_date { + if (0..=49).contains(&year) { + year += 2000; + } else if (50..=99).contains(&year) { + year += 1900; + } + } + ((1..=12).contains(&month) && is_day(day)).then_some((year, month, day)) + } +} + +#[derive(Default)] +struct TimeComposer { + comp: Vec, + hour_offset: Option, +} + +impl TimeComposer { + fn is_empty(&self) -> bool { + self.comp.is_empty() + } + fn add(&mut self, n: i64) -> bool { + if self.comp.len() < 4 { + self.comp.push(n); + true + } else { + false + } + } + fn add_final(&mut self, n: i64) -> bool { + if !self.add(n) { + return false; + } + self.comp.resize(4, 0); + true + } + fn is_expecting(&self, n: i64) -> bool { + match self.comp.len() { + 1 => (0..=59).contains(&n), + 2 => (0..=59).contains(&n), + 3 => (0..=999).contains(&n), + _ => false, + } + } + fn write(&self) -> Option<(i64, i64, i64, i64)> { + let mut comp = self.comp.clone(); + comp.resize(4, 0); + let (mut hour, minute, second, ms) = (comp[0], comp[1], comp[2], comp[3]); + if let Some(offset) = self.hour_offset { + if !(0..=12).contains(&hour) { + return None; + } + hour = hour % 12 + offset; + } + let valid = (0..=23).contains(&hour) + && (0..=59).contains(&minute) + && (0..=59).contains(&second) + && (0..=999).contains(&ms); + (valid || (hour == 24 && minute == 0 && second == 0 && ms == 0)).then_some((hour, minute, second, ms)) + } +} + +#[derive(Default)] +struct TimeZoneComposer { + sign: Option, + hour: Option, + minute: Option, +} + +impl TimeZoneComposer { + fn set(&mut self, offset_hours: i64) { + self.sign = Some(if offset_hours < 0 { -1 } else { 1 }); + self.hour = Some(offset_hours.abs()); + self.minute = Some(0); + } + fn is_expecting(&self, n: i64) -> bool { + self.hour.is_some() && self.minute.is_none() && (0..=59).contains(&n) + } + fn is_utc(&self) -> bool { + self.hour == Some(0) && self.minute == Some(0) + } + fn is_empty(&self) -> bool { + self.hour.is_none() + } + /// Décalage en secondes, None pour l'heure locale + fn write(&self) -> Option { + let sign = self.sign?; + Some(sign * (self.hour.unwrap_or(0) * 3600 + self.minute.unwrap_or(0) * 60)) + } +} + +/// ReadMilliseconds : les trois premiers chiffres significatifs +fn read_milliseconds(token: Token) -> i64 { + let Token::Number { value, length } = token else { + return 0; + }; + match length { + 1 => value * 100, + 2 => value * 10, + 3 => value, + _ => { + let length = length.min(9); + value / 10_i64.pow((length - 3) as u32) + } + } +} + +/// ParseES5DateTime : rend le premier jeton non traité, ou None (date invalide) +fn parse_es5( + scanner: &mut Tokenizer, + day: &mut DayComposer, + time: &mut TimeComposer, + tz: &mut TimeZoneComposer, +) -> Option { + if scanner.peek().is_sign() { + let sign_token = scanner.next(); + if !scanner.peek().is_fixed_number(6) { + return Some(sign_token); + } + let year = scanner.next().number(); + if sign_token.sign() < 0 && year == 0 { + return Some(sign_token); + } + day.add(sign_token.sign() * year); + } else if scanner.peek().is_fixed_number(4) { + day.add(scanner.next().number()); + } else { + return Some(scanner.next()); + } + if scanner.skip_symbol('-') { + let month = scanner.peek(); + if !month.is_fixed_number(2) || !(1..=12).contains(&month.number()) { + return Some(scanner.next()); + } + day.add(scanner.next().number()); + if scanner.skip_symbol('-') { + let day_token = scanner.peek(); + if !day_token.is_fixed_number(2) || !(1..=31).contains(&day_token.number()) { + return Some(scanner.next()); + } + day.add(scanner.next().number()); + } + } + if !matches!( + scanner.peek(), + Token::Word { + keyword: Keyword::TimeSeparator, + .. + } + ) { + if scanner.peek() != Token::End { + return Some(scanner.next()); + } + } else { + scanner.next(); + let hour = scanner.peek(); + if !hour.is_fixed_number(2) || !(0..=24).contains(&hour.number()) { + return None; + } + let hour_is_24 = hour.number() == 24; + time.add(scanner.next().number()); + if !scanner.skip_symbol(':') { + return None; + } + let minute = scanner.peek(); + if !minute.is_fixed_number(2) + || !(0..=59).contains(&minute.number()) + || (hour_is_24 && minute.number() > 0) + { + return None; + } + time.add(scanner.next().number()); + if scanner.skip_symbol(':') { + let second = scanner.peek(); + if !second.is_fixed_number(2) + || !(0..=59).contains(&second.number()) + || (hour_is_24 && second.number() > 0) + { + return None; + } + time.add(scanner.next().number()); + if scanner.skip_symbol('.') { + let ms = scanner.peek(); + if !matches!(ms, Token::Number { .. }) || (hour_is_24 && ms.number() > 0) { + return None; + } + time.add(read_milliseconds(scanner.next())); + } + } + if scanner.peek().is_keyword_z() { + scanner.next(); + tz.set(0); + } else if scanner.peek().is_sign() { + tz.sign = Some(scanner.next().sign()); + if scanner.peek().is_fixed_number(4) { + let hour_minute = scanner.next().number(); + let (hour, minute) = (hour_minute / 100, hour_minute % 100); + if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) { + return None; + } + tz.hour = Some(hour); + tz.minute = Some(minute); + } else { + let hour = scanner.peek(); + if !hour.is_fixed_number(2) || !(0..=23).contains(&hour.number()) { + return None; + } + tz.hour = Some(scanner.next().number()); + if !scanner.skip_symbol(':') { + return None; + } + let minute = scanner.peek(); + if !minute.is_fixed_number(2) || !(0..=59).contains(&minute.number()) { + return None; + } + tz.minute = Some(scanner.next().number()); + } + } + if scanner.peek() != Token::End { + return None; + } + } + // Date seule sans fuseau : UTC ; date et heure sans fuseau : heure locale + if tz.is_empty() && time.is_empty() { + tz.set(0); + } + day.iso_date = true; + Some(Token::End) +} + +/// new Date(texte) de V8 : None pour « Invalid Date » +pub fn parse_js_date(text: &str) -> Option> { + let mut scanner = Tokenizer::new(text); + let mut day = DayComposer::default(); + let mut time = TimeComposer::default(); + let mut tz = TimeZoneComposer::default(); + + let mut token = parse_es5(&mut scanner, &mut day, &mut time, &mut tz)?; + let mut has_read_number = !day.comp.is_empty(); + while token != Token::End { + match token { + Token::Number { value: n, .. } => { + has_read_number = true; + if scanner.skip_symbol(':') { + if scanner.skip_symbol(':') { + if !time.is_empty() { + return None; + } + time.add(n); + time.add(0); + } else { + if !time.add(n) { + return None; + } + if scanner.peek() == Token::Symbol('.') { + scanner.next(); + } + } + } else if scanner.skip_symbol('.') && time.is_expecting(n) { + // Comme V8, le point est consommé même si l'heure ne l'attend pas + time.add(n); + if !matches!(scanner.peek(), Token::Number { .. }) { + return None; + } + let ms = read_milliseconds(scanner.next()); + time.add_final(ms); + } else if tz.is_expecting(n) { + tz.minute = Some(n); + } else if time.is_expecting(n) { + time.add_final(n); + let peek = scanner.peek(); + if !matches!(peek, Token::End | Token::WhiteSpace) + && !peek.is_keyword_z() + && !peek.is_sign() + { + return None; + } + } else { + if !day.add(n) { + return None; + } + scanner.skip_symbol('-'); + } + } + Token::Word { keyword, .. } => match keyword { + Keyword::AmPm(offset) if !time.is_empty() => time.hour_offset = Some(offset), + Keyword::Month(month) => { + day.named_month = Some(month); + scanner.skip_symbol('-'); + } + Keyword::TimeZone(offset) if has_read_number => tz.set(offset), + _ => { + // Mot inconnu : interdit après un nombre, ou collé à un nombre + if has_read_number || matches!(scanner.peek(), Token::Number { .. }) { + return None; + } + } + }, + Token::Symbol('+' | '-') if tz.is_utc() || !time.is_empty() => { + tz.sign = Some(token.sign()); + let (mut n, mut length) = (0, 0); + if let Token::Number { value, length: l } = scanner.peek() { + scanner.next(); + n = value; + length = l; + } + has_read_number = true; + if scanner.peek() == Token::Symbol(':') { + tz.hour = Some(n); + tz.minute = None; + } else if length == 1 || length == 2 { + tz.hour = Some(n); + tz.minute = Some(0); + } else if length == 3 || length == 4 { + tz.hour = Some(n / 100); + tz.minute = Some(n % 100); + } else { + return None; + } + } + Token::Symbol('+' | '-' | ')') if has_read_number => return None, + _ => {} + } + token = scanner.next(); + } + + let (year, month, day) = day.write()?; + let (hour, minute, second, ms) = time.write()?; + let offset = tz.write(); + make_date(year, month, day, hour, minute, second, ms, offset) +} + +/// MakeDay / MakeTime / TimeClip : le jour peut déborder sur le mois suivant +/// (« 2026-02-31 » donne le 3 mars), l'heure 24 sur le lendemain +#[allow(clippy::too_many_arguments)] +fn make_date( + year: i64, + month: i64, + day: i64, + hour: i64, + minute: i64, + second: i64, + ms: i64, + offset_seconds: Option, +) -> Option> { + let first = NaiveDate::from_ymd_opt(i32::try_from(year).ok()?, month as u32, 1)?; + let date = first.checked_add_signed(Duration::days(day - 1))?; + let millis = date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis() as f64 + + ((hour * 3600 + minute * 60 + second) * 1000 + ms) as f64 + - offset_seconds.unwrap_or(0) as f64 * 1000.0; + if millis.abs() > MAX_TIME_MS { + return None; + } + Utc.timestamp_millis_opt(millis as i64).single() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn iso(text: &str) -> Option { + parse_js_date(text).map(|date| crate::js::iso(&date)) + } + + #[test] + fn normalise_comme_node() { + // Valeurs relevées avec normalizeDateString (server/dateUtils.ts) dans Node 22, TZ=UTC + let cases = [ + ("2026-10-25", Some("2026-10-25")), + (" 2026-10-25 ", Some("2026-10-25")), + ("25/10/2026", Some("2026-10-25")), + ("10/25/2026", Some("2026-10-25")), + ("05/06/2026", Some("2026-06-05")), + ("5-6-2026", Some("2026-06-05")), + ("31/12/2026", Some("2026-12-31")), + ("32/01/2026", None), + ("00/12/2026", None), + ("13/13/2026", None), + ("12/31/2026", Some("2026-12-31")), + ("2026-10-25T10:30:00.000Z", Some("2026-10-25")), + ("2026-10-25T23:30:00+02:00", Some("2026-10-25")), + ("2026-10-25 08:00:00", Some("2026-10-25")), + ("2026/10/25", Some("2026-10-25")), + ("Oct 25 2026", Some("2026-10-25")), + ("25 Oct 2026", Some("2026-10-25")), + ("Sun, 25 Oct 2026 10:00:00 GMT", Some("2026-10-25")), + ("pas une date", None), + ("", None), + (" ", None), + ("2026-02-31", Some("2026-02-31")), + ("2026-2-3", Some("2026-02-03")), + ("1/2/2026", Some("2026-02-01")), + ("2026-10", Some("2026-10-01")), + ("2026", Some("2026-01-01")), + ("20261025", None), + ("2026-10-25T10:30", Some("2026-10-25")), + ("10/25/2026 10:00", Some("2026-10-25")), + ("2026-13-45", Some("2026-13-45")), + ("2026-02-30T00:00:00Z", Some("2026-03-02")), + ("2026/2/30", Some("2026-03-02")), + ("99/99/2026", None), + ("1/13/2026", Some("2026-01-13")), + ]; + for (input, expected) in cases { + assert_eq!(normalize_date_string(input).as_deref(), expected, "{input:?}"); + } + assert_eq!(normalize_date_value(&json!(20261025)), None); + } + + #[test] + fn new_date_comme_v8() { + // new Date(texte).toISOString() dans Node 22, TZ=UTC + let cases = [ + ("2026-02-31", Some("2026-03-03T00:00:00.000Z")), + ("2026-02-30T00:00:00Z", Some("2026-03-02T00:00:00.000Z")), + ("2026/2/30", Some("2026-03-02T00:00:00.000Z")), + ("2026-10-25 08:00", Some("2026-10-25T08:00:00.000Z")), + ("10/25/2026", Some("2026-10-25T00:00:00.000Z")), + ("2026/10/25 10:00:00", Some("2026-10-25T10:00:00.000Z")), + ("Oct 25 2026", Some("2026-10-25T00:00:00.000Z")), + ("2026-10-25T10:30:00.123456Z", Some("2026-10-25T10:30:00.123Z")), + ("+002026-10-25", Some("2026-10-25T00:00:00.000Z")), + ("2026-10-25T24:00:00Z", Some("2026-10-26T00:00:00.000Z")), + ("2026-10-25T10:30:00Z ", None), + ("2026-1-5", Some("2026-01-05T00:00:00.000Z")), + ("2026-10-25T00:00:00.000Z", Some("2026-10-25T00:00:00.000Z")), + ("2026-10-25T23:30:00+02:00", Some("2026-10-25T21:30:00.000Z")), + ("25/10/2026", None), + ("2026-13-45", None), + ("pas une date", None), + ]; + for (input, expected) in cases { + assert_eq!(iso(input).as_deref(), expected, "{input:?}"); + } + assert_eq!( + js_date(&json!(0)).map(|d| crate::js::iso(&d)).as_deref(), + Some("1970-01-01T00:00:00.000Z") + ); + assert_eq!(js_date(&json!({})), None); + } +} diff --git a/server-rs/src/services/invoice_verification.rs b/server-rs/src/services/invoice_verification.rs new file mode 100644 index 0000000..3694885 --- /dev/null +++ b/server-rs/src/services/invoice_verification.rs @@ -0,0 +1,1059 @@ +// Vérification des factures dans NocoDB, avec son cache (server/invoiceVerification.ts). +// +// - verify_invoice : recherche par référence de facture (colonne du magasin, à +// défaut « RefFacture »), puis, si le magasin a une colonne de BL, par numéro de BL ; +// - verify_invoice_by_bl : recherche par numéro de BL, le fournisseur trouvé devant +// contenir le nom attendu (ou l'inverse) ; +// - cache dans invoice_verification_cache, mêmes clés et mêmes durées que Node : +// facture trouvée 6 h, non trouvée ou en erreur 12 h, livraison validée 50 ans, +// recherche par BL 24 h ; une entrée expirée est supprimée à sa lecture ; +// - update_cache_as_reconciled / update_existing_reconciled_caches : marquage +// « rapproché » d'une entrée, ou de toutes celles des livraisons validées. +// +// Comportement de Node en production (NODE_ENV=production), le seul porté : la +// simulation du mode développement (références contenant « test » ou « bl ») ne +// l'est pas. Toute erreur devient un résultat { exists: false, errorMessage }, +// jamais une erreur HTTP. + +use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; +use serde::ser::{Serialize, SerializeMap, Serializer}; +use serde_json::Value; +use sqlx::PgPool; +use sqlx::postgres::PgArguments; + +use crate::AppState; +use crate::db::Table; +use crate::js; +use crate::schema::{GroupsRow, InvoiceVerificationCacheRow}; +use crate::services::nocodb::{self, Search, truthy}; + +/// Table NocoDB lue quand le magasin n'en précise pas (repli codé en dur dans Node) +pub const FALLBACK_TABLE_ID: &str = "mrr733dfb8wtt9b"; + +pub const MATCH_INVOICE_REFERENCE: &str = "invoice_reference"; +pub const MATCH_BL_NUMBER: &str = "bl_number"; +pub const MATCH_NONE: &str = "none"; + +// ---------- Résultat ---------- + +/// Champ d'un objet JavaScript : absent (undefined, omis du JSON), null, ou une valeur +#[derive(Debug, Clone, PartialEq, Default)] +pub enum JsField { + #[default] + Absent, + Null, + Set(T), +} + +impl JsField { + pub fn get(&self) -> Option<&T> { + match self { + JsField::Set(value) => Some(value), + _ => None, + } + } +} + +/// Résultat de verifyInvoice / verifyInvoiceByBL, sérialisé comme Node le renvoie. +/// Les champs lus dans NocoDB (référence, fournisseur, échéance) gardent leur +/// valeur JSON d'origine ; les montants sont des nombres JavaScript (NaN possible). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Verification { + pub exists: bool, + /// invoice_reference, bl_number ou none (texte lu en cache sinon) + pub match_type: String, + pub error_message: JsField, + pub invoice_reference: JsField, + pub invoice_amount: JsField, + pub invoice_amount_ttc: JsField, + pub supplier_name: JsField, + pub due_date: JsField, + /// Présent (true) quand le résultat vient du cache + pub from_cache: Option, + /// Présent pour un résultat du cache par référence : entrée « rapprochée » + pub permanent: Option, +} + +impl Verification { + /// { exists: false, matchType: 'none', errorMessage } + pub fn failure(message: impl Into) -> Self { + Self { + exists: false, + match_type: MATCH_NONE.to_string(), + error_message: JsField::Set(message.into()), + ..Default::default() + } + } + + /// normalizeDateString(result.dueDate) : échéance AAAA-MM-JJ, si c'est une chaîne reconnue + pub fn normalized_due_date(&self) -> Option { + self.due_date + .get() + .filter(|value| truthy(value)) + .and_then(crate::services::dates::normalize_date_value) + } +} + +impl Serialize for Verification { + /// Ordre des clés de Node : celui du cache (erreur, référence, fournisseur, + /// montants, échéance, fromCache, permanent) ou celui d'une recherche (référence, + /// montants, fournisseur, échéance) + fn serialize(&self, serializer: S) -> Result { + fn put Value>( + map: &mut M, + key: &str, + field: &JsField, + to_json: F, + ) -> Result<(), M::Error> { + match field { + JsField::Absent => Ok(()), + JsField::Null => map.serialize_entry(key, &Value::Null), + JsField::Set(value) => map.serialize_entry(key, &to_json(value)), + } + } + let number = |n: &f64| { + if n.is_finite() { + js::number_value(*n) + } else { + Value::Null + } + }; + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("exists", &self.exists)?; + map.serialize_entry("matchType", &self.match_type)?; + put(&mut map, "errorMessage", &self.error_message, |m| { + Value::String(m.clone()) + })?; + put( + &mut map, + "invoiceReference", + &self.invoice_reference, + Value::clone, + )?; + if self.from_cache.is_some() { + put(&mut map, "supplierName", &self.supplier_name, Value::clone)?; + put(&mut map, "invoiceAmount", &self.invoice_amount, number)?; + put(&mut map, "invoiceAmountTTC", &self.invoice_amount_ttc, number)?; + put(&mut map, "dueDate", &self.due_date, Value::clone)?; + } else { + put(&mut map, "invoiceAmount", &self.invoice_amount, number)?; + put(&mut map, "invoiceAmountTTC", &self.invoice_amount_ttc, number)?; + put(&mut map, "supplierName", &self.supplier_name, Value::clone)?; + put(&mut map, "dueDate", &self.due_date, Value::clone)?; + } + if let Some(from_cache) = self.from_cache { + map.serialize_entry("fromCache", &from_cache)?; + } + if let Some(permanent) = self.permanent { + map.serialize_entry("permanent", &permanent)?; + } + map.end() + } +} + +// ---------- Clés et durées du cache ---------- + +/// generateCacheKey : `${groupId}_${référence.trim().toLowerCase()}` +pub fn cache_key(invoice_reference: &str, group_key: &str) -> String { + format!("{group_key}_{}", js::trim(invoice_reference).to_lowercase()) +} + +/// Clé du cache par BL : `bl_${groupId}_${bl.trim().toLowerCase()}_${fournisseur.toLowerCase()}` +pub fn bl_cache_key(bl_number: &str, group_key: &str, supplier_name: &str) -> String { + format!( + "bl_{group_key}_{}_{}", + js::trim(bl_number).to_lowercase(), + supplier_name.to_lowercase() + ) +} + +/// Durée de vie d'une entrée du cache +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CacheLifetime { + /// Facture trouvée, pas encore validée : 6 h, pour permettre les corrections + Found, + /// Facture non trouvée ou erreur : 12 h, pour éviter de rappeler NocoDB + NotFound, + /// Livraison validée : 50 ans (« permanent ») + Reconciled, + /// Recherche par numéro de BL, quel qu'en soit le résultat : 24 h + BlNumber, +} + +/// Date d'expiration comme Node la calcule (setHours / setFullYear, en UTC) : +/// un 29 février plus 50 ans tombe le 1er mars d'une année non bissextile +pub fn expires_at(now: DateTime, lifetime: CacheLifetime) -> DateTime { + match lifetime { + CacheLifetime::Found => now + Duration::hours(6), + CacheLifetime::NotFound => now + Duration::hours(12), + CacheLifetime::BlNumber => now + Duration::hours(24), + CacheLifetime::Reconciled => { + let date = now.date_naive(); + NaiveDate::from_ymd_opt(date.year() + 50, date.month(), 1) + .and_then(|first| first.checked_add_signed(Duration::days(i64::from(date.day()) - 1))) + .map(|day| day.and_time(now.time()).and_utc()) + .unwrap_or(now) + } + } +} + +/// storage.getInvoiceVerificationCache : l'entrée si elle n'a pas expiré ; une +/// entrée expirée est supprimée au passage +pub async fn read_cache(db: &PgPool, key: &str) -> Result, sqlx::Error> { + let cached = sqlx::query_as::<_, InvoiceVerificationCacheRow>(&InvoiceVerificationCacheRow::select( + "WHERE cache_key = $1", + )) + .bind(key) + .fetch_optional(db) + .await?; + match cached { + Some(cached) if Utc::now().naive_utc() < cached.expires_at => Ok(Some(cached)), + Some(_) => { + sqlx::query("DELETE FROM invoice_verification_cache WHERE cache_key = $1") + .bind(key) + .execute(db) + .await?; + Ok(None) + } + None => Ok(None), + } +} + +/// checkCache : résultat en cache pour une référence de facture +async fn check_cache(db: &PgPool, key: &str) -> Option { + let cached = match read_cache(db, key).await { + Ok(cached) => cached?, + Err(error) => { + tracing::error!(%error, "lecture du cache des factures"); + return None; + } + }; + if Utc::now().naive_utc() >= cached.expires_at { + return None; + } + let amount = |amount: Option| match amount { + Some(amount) => JsField::Set(js::parse_float(&amount.to_string())), + None => JsField::Absent, + }; + let optional_text = |text: Option| match text { + Some(text) => JsField::Set(Value::String(text)), + None => JsField::Null, + }; + Some(Verification { + exists: cached.exists, + match_type: cached.match_type, + error_message: match cached.error_message { + Some(message) => JsField::Set(message), + None => JsField::Null, + }, + invoice_reference: JsField::Set(Value::String(cached.invoice_reference)), + invoice_amount: amount(cached.invoice_amount), + invoice_amount_ttc: amount(cached.invoice_amount_ttc), + supplier_name: optional_text(cached.supplier_name), + due_date: optional_text(cached.due_date.map(|date| crate::json::format_ts(&date))), + from_cache: Some(true), + // Facture validée (coche verte) : jamais revérifiée + permanent: Some(cached.is_reconciled == Some(true) && cached.exists), + }) +} + +/// Entrée à enregistrer ; un champ None n'est ni inséré ni mis à jour (undefined +/// pour Drizzle), Some(None) l'écrit à NULL +struct CacheEntry { + key: String, + group_param: Option, + invoice_reference: Option, + supplier_name: Option, + invoice_amount: Option>, + invoice_amount_ttc: Option>, + due_date: Option>, + exists: bool, + match_type: String, + error_message: Option, + is_reconciled: bool, + expires_at: DateTime, +} + +/// storage.saveInvoiceVerificationCache : insertion, ou mise à jour de l'entrée de +/// même clé. Comme l'upsert de Node, la mise à jour ne touche ni invoice_amount_ttc +/// ni expires_at. Une erreur est journalisée, jamais remontée. +async fn save_cache(db: &PgPool, entry: CacheEntry) { + let now = crate::modules::now_iso(); + let mut columns: Vec<(&str, Option, &str, bool)> = vec![ + ("cache_key", Some(entry.key), "varchar", false), + ("group_id", entry.group_param, "int4", false), + ("invoice_reference", entry.invoice_reference, "varchar", true), + ("supplier_name", entry.supplier_name, "varchar", true), + ]; + if let Some(amount) = entry.invoice_amount { + columns.push(("invoice_amount", amount, "numeric", true)); + } + if let Some(amount) = entry.invoice_amount_ttc { + columns.push(("invoice_amount_ttc", amount, "numeric", false)); + } + if let Some(due_date) = entry.due_date { + columns.push(("due_date", due_date, "timestamp", true)); + } + columns.extend([ + ("exists", Some(entry.exists.to_string()), "bool", true), + ("match_type", Some(entry.match_type), "varchar", true), + ("error_message", entry.error_message, "text", true), + ("cache_hit", Some("false".to_string()), "bool", true), + ("api_call_time", None, "int4", true), + ( + "is_reconciled", + Some(entry.is_reconciled.to_string()), + "bool", + true, + ), + ("expires_at", Some(js::iso(&entry.expires_at)), "timestamp", false), + ]); + let mut arguments = PgArguments::default(); + let mut names = Vec::new(); + let mut placeholders = Vec::new(); + let mut updates = Vec::new(); + for (index, (column, value, sql_type, updated)) in columns.into_iter().enumerate() { + if let Err(error) = sqlx::Arguments::add(&mut arguments, value) { + tracing::error!(%error, "cache des factures : paramètre"); + return; + } + names.push(format!("\"{column}\"")); + placeholders.push(format!("${}::{sql_type}", index + 1)); + if updated { + updates.push(format!("\"{column}\" = EXCLUDED.\"{column}\"")); + } + } + let _ = sqlx::Arguments::add(&mut arguments, now); + let sql = format!( + "INSERT INTO invoice_verification_cache ({}) VALUES ({}) ON CONFLICT (cache_key) DO UPDATE SET {}, \"updated_at\" = ${}::timestamp", + names.join(", "), + placeholders.join(", "), + updates.join(", "), + names.len() + 1 + ); + if let Err(error) = sqlx::query_with(&sql, arguments).execute(db).await { + tracing::error!(%error, "cache des factures : enregistrement"); + } +} + +// ---------- Vérification ---------- + +/// Identifiant de magasin tel que reçu : texte de la clé de cache (String(groupId)) +/// et paramètre SQL (texte converti par PostgreSQL, comme node-pg) +#[derive(Debug, Clone)] +struct GroupRef { + key: String, + param: Option, +} + +impl GroupRef { + fn id(group_id: i32) -> Self { + Self { + key: group_id.to_string(), + param: Some(group_id.to_string()), + } + } + fn raw(value: &Value) -> Self { + Self { + key: js::to_js_string(value), + param: crate::db::pg_text(value), + } + } +} + +/// Texte d'un champ du magasin, ou sa valeur par défaut s'il est vide +fn column_or(value: &Option, default: &str) -> String { + value + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +/// Le magasin a-t-il une configuration de rapprochement (NocoDB ou webhook) ? +fn has_reconciliation_config(group: &GroupsRow) -> bool { + group.nocodb_config_id.is_some_and(|id| id != 0) + || group + .nocodb_table_name + .as_deref() + .is_some_and(|name| !name.is_empty()) + || group.webhook_url.as_deref().is_some_and(|url| !url.is_empty()) +} + +/// Ligne NocoDB → résultat trouvé : montants parseFloat(cellule || '0'), +/// échéance `cellule || null`, colonnes du magasin ou celles par défaut de Node +fn found_result( + data: &Value, + group: &GroupsRow, + match_type: &str, + reference: JsField, + supplier_name: Value, +) -> Verification { + let cell = |column: &Option, default: &str| { + data.get(column_or(column, default)) + .filter(|value| truthy(value)) + .cloned() + }; + let amount = |column: &Option, default: &str| { + cell(column, default).map_or(0.0, |value| js::parse_float(&js::to_js_string(&value))) + }; + Verification { + exists: true, + match_type: match_type.to_string(), + invoice_reference: reference, + invoice_amount: JsField::Set(amount(&group.nocodb_amount_column_name, "amount")), + invoice_amount_ttc: JsField::Set(amount( + &group.nocodb_invoice_amount_ttc_column_name, + "Montant TTC", + )), + supplier_name: JsField::Set(supplier_name), + due_date: JsField::Set(cell(&group.nocodb_due_date_column_name, "Echeance").unwrap_or(Value::Null)), + ..Default::default() + } +} + +/// montant.toString() d'un nombre JavaScript, NaN et Infinity compris (texte +/// écrit en base pour une colonne numeric) +pub fn js_number_text(number: f64) -> String { + if number.is_nan() { + "NaN".to_string() + } else if number.is_infinite() { + if number > 0.0 { "Infinity" } else { "-Infinity" }.to_string() + } else { + js::number_to_string(number) + } +} + +/// Montant écrit en cache : `montant || null` (0 et NaN donnent null) +fn cached_amount(field: &JsField) -> Option { + field + .get() + .filter(|amount| **amount != 0.0 && !amount.is_nan()) + .map(|amount| js_number_text(*amount)) +} + +/// Paramètre texte d'une valeur JSON « vraie » (`valeur || null`) +fn truthy_param(value: Option<&Value>) -> Option { + value.filter(|value| truthy(value)).and_then(crate::db::pg_text) +} + +/// saveToCache : durée selon le résultat ; une échéance que new Date() ne lit pas +/// fait échouer l'enregistrement (RangeError de toISOString côté Node) +async fn save_to_cache( + db: &PgPool, + invoice_reference: &str, + group: &GroupRef, + result: &Verification, + is_reconciled: bool, +) { + let due_date = match result.due_date.get().filter(|value| truthy(value)) { + None => None, + Some(value) => match crate::services::dates::js_date(value) { + Some(date) => Some(js::iso(&date)), + None => { + tracing::error!("cache des factures : échéance illisible"); + return; + } + }, + }; + let lifetime = if is_reconciled { + CacheLifetime::Reconciled + } else if result.exists { + CacheLifetime::Found + } else { + CacheLifetime::NotFound + }; + let supplier_name = result.supplier_name.get(); + save_cache( + db, + CacheEntry { + key: cache_key(invoice_reference, &group.key), + group_param: group.param.clone(), + invoice_reference: Some(invoice_reference.to_string()), + supplier_name: truthy_param(supplier_name), + invoice_amount: Some(cached_amount(&result.invoice_amount)), + invoice_amount_ttc: Some(cached_amount(&result.invoice_amount_ttc)), + due_date: Some(due_date), + exists: result.exists, + match_type: result.match_type.clone(), + error_message: result.error_message.get().filter(|m| !m.is_empty()).cloned(), + is_reconciled, + expires_at: expires_at(Utc::now(), lifetime), + }, + ) + .await; +} + +/// Magasin, configuration NocoDB active et table : ou le résultat d'échec de Node +async fn reconciliation_setup( + state: &AppState, + group: &GroupRef, +) -> Result<(GroupsRow, crate::schema::NocodbConfigRow, String), Verification> { + let group = match nocodb::group_by_text_id(&state.db, group.param.as_deref()).await { + Ok(Some(group)) => group, + Ok(None) => return Err(Verification::failure("Groupe non trouvé")), + Err(message) => return Err(Verification::failure(message)), + }; + if !has_reconciliation_config(&group) { + return Err(Verification::failure( + "Configuration NocoDB manquante pour ce magasin", + )); + } + let Some(config) = nocodb::active_config(state).await else { + return Err(Verification::failure("Configuration NocoDB non trouvée")); + }; + let table_id = column_or(&group.nocodb_table_id, FALLBACK_TABLE_ID); + Ok((group, config, table_id)) +} + +async fn run_verify_invoice( + state: &AppState, + invoice_reference: &str, + group_ref: &GroupRef, + force_refresh: bool, + is_reconciled: bool, +) -> Verification { + if js::trim(invoice_reference).is_empty() { + return Verification::failure("Référence de facture vide"); + } + if !force_refresh { + if let Some(cached) = check_cache(&state.db, &cache_key(invoice_reference, &group_ref.key)).await { + return cached; + } + } + let (group, config, table_id) = match reconciliation_setup(state, group_ref).await { + Ok(setup) => setup, + Err(failure) => return failure, + }; + + // Par référence de facture, puis par numéro de BL si le magasin a la colonne + let mut searches = vec![( + column_or(&group.invoice_column_name, "RefFacture"), + MATCH_INVOICE_REFERENCE, + )]; + if let Some(bl_column) = group + .nocodb_bl_column_name + .clone() + .filter(|name| !name.is_empty()) + { + searches.push((bl_column, MATCH_BL_NUMBER)); + } + for (column, match_type) in searches { + let result = match nocodb::search(&config, &table_id, &column, invoice_reference).await { + Search::Error(message) => Verification::failure(message), + Search::Found(Value::Null) => Verification::failure( + "Erreur NocoDB: Cannot read properties of null (reading 'invoice_reference')", + ), + Search::Found(data) => { + let reference = data + .get("invoice_reference") + .filter(|value| truthy(value)) + .cloned() + .unwrap_or_else(|| { + Value::String(if match_type == MATCH_BL_NUMBER { + format!("BL_{invoice_reference}") + } else { + invoice_reference.to_string() + }) + }); + let supplier = data + .get(column_or(&group.nocodb_supplier_column_name, "supplier")) + .filter(|value| truthy(value)) + .cloned() + .unwrap_or_else(|| Value::String("Inconnu".to_string())); + found_result(&data, &group, match_type, JsField::Set(reference), supplier) + } + Search::NotFound => continue, + }; + save_to_cache(&state.db, invoice_reference, group_ref, &result, is_reconciled).await; + return result; + } + let result = Verification::failure("Aucune correspondance trouvée dans NocoDB"); + save_to_cache(&state.db, invoice_reference, group_ref, &result, is_reconciled).await; + result +} + +/// verifyInvoice : la référence existe-t-elle dans la table NocoDB du magasin ? +/// Cache lu d'abord, sauf `force_refresh` ; `is_reconciled` (livraison validée) +/// rend l'entrée enregistrée permanente. +pub async fn verify_invoice( + state: &AppState, + invoice_reference: &str, + group_id: i32, + force_refresh: bool, + is_reconciled: bool, +) -> Verification { + run_verify_invoice( + state, + invoice_reference, + &GroupRef::id(group_id), + force_refresh, + is_reconciled, + ) + .await +} + +/// verifyInvoice appelé avec les valeurs brutes d'un corps de requête +/// (POST /api/verify-invoice) : référence qui n'est pas une chaîne (TypeError de +/// Node) et identifiant de magasin quelconque (texte de la clé de cache, erreur de +/// PostgreSQL s'il n'est pas un entier) +pub async fn verify_invoice_raw( + state: &AppState, + invoice_reference: &Value, + group_id: &Value, + force_refresh: bool, + is_reconciled: bool, +) -> Verification { + let Some(reference) = invoice_reference.as_str() else { + return Verification::failure(nocodb::not_a_string_error( + "invoiceReference", + "trim", + Some(invoice_reference), + )); + }; + run_verify_invoice( + state, + reference, + &GroupRef::raw(group_id), + force_refresh, + is_reconciled, + ) + .await +} + +/// Règle de rapprochement du fournisseur d'un BL : l'un des noms contient l'autre, +/// sans tenir compte de la casse (un nom vide correspond donc toujours) +pub fn bl_supplier_matches(found: &str, expected: &str) -> bool { + let (found, expected) = (found.to_lowercase(), expected.to_lowercase()); + found.contains(&expected) || expected.contains(&found) +} + +/// Enregistrement du cache par BL (24 h) : montants et échéance jamais écrits +async fn save_bl_cache( + db: &PgPool, + key: &str, + group: &GroupRef, + invoice_reference: Option<&Value>, + supplier_name: &Value, + result: &Verification, + is_reconciled: bool, +) { + save_cache( + db, + CacheEntry { + key: key.to_string(), + group_param: group.param.clone(), + invoice_reference: invoice_reference.and_then(crate::db::pg_text), + supplier_name: crate::db::pg_text(supplier_name), + invoice_amount: None, + invoice_amount_ttc: None, + due_date: None, + exists: result.exists, + match_type: result.match_type.clone(), + error_message: result.error_message.get().cloned(), + is_reconciled, + expires_at: expires_at(Utc::now(), CacheLifetime::BlNumber), + }, + ) + .await; +} + +/// verifyInvoiceByBL : un BL de ce numéro existe-t-il pour ce fournisseur ? +pub async fn verify_invoice_by_bl( + state: &AppState, + bl_number: &str, + supplier_name: &str, + group_id: i32, + force_refresh: bool, + is_reconciled: bool, +) -> Verification { + if js::trim(bl_number).is_empty() { + return Verification::failure("Numéro BL vide"); + } + let group_ref = GroupRef::id(group_id); + let key = bl_cache_key(bl_number, &group_ref.key, supplier_name); + if !force_refresh { + match read_cache(&state.db, &key).await { + Ok(Some(cached)) if Utc::now().naive_utc() < cached.expires_at => { + // `valeur || undefined` : un texte vide ou null est omis + let text = |text: Option| match text.filter(|text| !text.is_empty()) { + Some(text) => JsField::Set(Value::String(text)), + None => JsField::Absent, + }; + return Verification { + exists: cached.exists, + match_type: cached.match_type, + error_message: match cached.error_message.filter(|message| !message.is_empty()) { + Some(message) => JsField::Set(message), + None => JsField::Absent, + }, + invoice_reference: text(Some(cached.invoice_reference)), + supplier_name: text(cached.supplier_name), + from_cache: Some(true), + ..Default::default() + }; + } + Ok(_) => {} + Err(error) => tracing::error!(%error, "lecture du cache BL"), + } + } + let (group, config, table_id) = match reconciliation_setup(state, &group_ref).await { + Ok(setup) => setup, + Err(failure) => return failure, + }; + let bl_value = Value::String(bl_number.to_string()); + let supplier_value = Value::String(supplier_name.to_string()); + let supplier_column = column_or(&group.nocodb_supplier_column_name, "supplier"); + let column = column_or(&group.nocodb_bl_column_name, "Numero_BL"); + match nocodb::search(&config, &table_id, &column, bl_number).await { + Search::Error(message) => { + let result = Verification::failure(message); + save_bl_cache( + &state.db, + &key, + &group_ref, + Some(&bl_value), + &supplier_value, + &result, + is_reconciled, + ) + .await; + result + } + Search::Found(data) => { + // Erreurs JavaScript de Node sur une ligne inattendue (null, fournisseur non textuel) + let found_supplier = match &data { + Value::Null => Err(format!( + "Cannot read properties of null (reading '{supplier_column}')" + )), + data => match data.get(&supplier_column).filter(|value| truthy(value)) { + None => Ok(String::new()), + Some(Value::String(text)) => Ok(text.clone()), + Some(_) => Err("foundSupplier.toLowerCase is not a function".to_string()), + }, + }; + let found_supplier = match found_supplier { + Ok(found) => found, + Err(message) => { + let result = Verification::failure(format!("Erreur NocoDB BL: {message}")); + save_bl_cache( + &state.db, + &key, + &group_ref, + Some(&bl_value), + &supplier_value, + &result, + is_reconciled, + ) + .await; + return result; + } + }; + if !bl_supplier_matches(&found_supplier, supplier_name) { + tracing::info!( + expected = supplier_name, + found = found_supplier, + "fournisseur du BL différent" + ); + return Verification::failure(format!( + "BL trouvé mais fournisseur différent: {found_supplier} vs {supplier_name}" + )); + } + let reference = data + .get(column_or(&group.invoice_column_name, "RefFacture")) + .filter(|value| truthy(value)) + .cloned() + .unwrap_or_else(|| Value::String(format!("BL_{bl_number}"))); + let supplier = Value::String(if found_supplier.is_empty() { + supplier_name.to_string() + } else { + found_supplier + }); + let result = found_result( + &data, + &group, + MATCH_BL_NUMBER, + JsField::Set(reference.clone()), + supplier.clone(), + ); + save_bl_cache( + &state.db, + &key, + &group_ref, + Some(&reference), + &supplier, + &result, + is_reconciled, + ) + .await; + result + } + Search::NotFound => { + let result = Verification::failure("Aucun BL correspondant trouvé dans NocoDB"); + save_bl_cache( + &state.db, + &key, + &group_ref, + Some(&bl_value), + &supplier_value, + &result, + is_reconciled, + ) + .await; + result + } + } +} + +// ---------- Marquage « rapproché » ---------- + +async fn mark_reconciled(db: &PgPool, key: &str) { + let cached = match read_cache(db, key).await { + Ok(Some(cached)) => cached, + Ok(None) => { + tracing::info!(key, "cache des factures : aucune entrée à marquer"); + return; + } + Err(error) => { + tracing::error!(%error, "cache des factures : marquage"); + return; + } + }; + if cached.is_reconciled == Some(true) { + return; + } + // L'upsert de Node réécrit l'entrée à l'identique sauf is_reconciled et + // updated_at : expires_at n'est pas prolongé (la colonne n'est pas dans son SET) + let result = sqlx::query( + "UPDATE invoice_verification_cache SET is_reconciled = true, updated_at = $2::timestamp WHERE cache_key = $1", + ) + .bind(key) + .bind(crate::modules::now_iso()) + .execute(db) + .await; + if let Err(error) = result { + tracing::error!(%error, "cache des factures : marquage"); + } +} + +/// updateCacheAsReconciled : marque « rapprochée » l'entrée d'une référence (ou +/// d'un numéro de BL) de ce magasin, si elle existe +pub async fn update_cache_as_reconciled(db: &PgPool, invoice_reference: &str, group_id: i32) { + mark_reconciled(db, &cache_key(invoice_reference, &group_id.to_string())).await; +} + +/// updateCacheAsReconciled avec les valeurs brutes d'un corps de requête +/// (POST /api/cache/mark-reconciled) : rien n'est fait si la référence n'est pas une +/// chaîne (TypeError interceptée par Node) +pub async fn update_cache_as_reconciled_raw(db: &PgPool, invoice_reference: &Value, group_id: &Value) { + match invoice_reference.as_str() { + Some(reference) => mark_reconciled(db, &cache_key(reference, &js::to_js_string(group_id))).await, + None => tracing::error!("cache des factures : référence qui n'est pas une chaîne"), + } +} + +/// Blancs ASCII retirés par String.prototype.trim(), pour reproduire les clés en SQL +const ASCII_WHITESPACE: &str = " \t\n\u{0B}\u{0C}\r"; + +/// updateExistingReconciledCaches : marque « rapprochées » les entrées non expirées +/// des références et numéros de BL de toutes les livraisons validées, en une +/// requête ; rend le nombre d'entrées modifiées (les erreurs sont journalisées) +pub async fn update_existing_reconciled_caches(db: &PgPool) -> u64 { + let result = sqlx::query( + "UPDATE invoice_verification_cache + SET is_reconciled = true, updated_at = $1::timestamp + WHERE is_reconciled IS NOT TRUE + AND expires_at > $1::timestamp + AND cache_key IN ( + SELECT group_id::text || '_' || lower(btrim(invoice_reference, $2)) + FROM deliveries + WHERE reconciled = true AND btrim(invoice_reference, $2) <> '' + UNION + SELECT group_id::text || '_' || lower(btrim(bl_number, $2)) + FROM deliveries + WHERE reconciled = true AND btrim(bl_number, $2) <> '' + )", + ) + .bind(crate::modules::now_iso()) + .bind(ASCII_WHITESPACE) + .execute(db) + .await; + match result { + Ok(done) => done.rows_affected(), + Err(error) => { + tracing::error!(%error, "cache des factures : mise à jour en lot"); + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn utc(text: &str) -> DateTime { + DateTime::parse_from_rfc3339(text).unwrap().with_timezone(&Utc) + } + + fn group() -> GroupsRow { + let mut group = empty_group(); + group.nocodb_amount_column_name = Some("MontantHT".into()); + group.nocodb_invoice_amount_ttc_column_name = Some("MontantTTC".into()); + group.nocodb_supplier_column_name = Some("Fournisseur".into()); + group.nocodb_due_date_column_name = Some("Echeance".into()); + group + } + + fn empty_group() -> GroupsRow { + GroupsRow { + id: 1, + name: "Magasin".into(), + color: None, + nocodb_config_id: None, + nocodb_table_name: None, + nocodb_table_id: None, + invoice_column_name: None, + nocodb_bl_column_name: None, + nocodb_amount_column_name: None, + nocodb_invoice_amount_ttc_column_name: None, + nocodb_supplier_column_name: None, + nocodb_due_date_column_name: None, + webhook_url: None, + address: None, + phone: None, + logo: None, + smtp_enabled: None, + smtp_host: None, + smtp_port: None, + smtp_secure: None, + smtp_user: None, + smtp_password: None, + smtp_sender_email: None, + smtp_sender_name: None, + created_at: None, + updated_at: None, + } + } + + #[test] + fn cles_du_cache_comme_node() { + // generateCacheKey et la clé BL de verifyInvoiceByBL + assert_eq!(cache_key(" FAC-2001 ", "1"), "1_fac-2001"); + assert_eq!(cache_key("Fac\u{a0}É", "01"), "01_fac\u{a0}é"); + assert_eq!( + bl_cache_key(" BL-1002 ", "1", "Bonduelle SA"), + "bl_1_bl-1002_bonduelle sa" + ); + } + + #[test] + fn durees_du_cache_comme_node() { + // new Date() puis setHours / setFullYear, dans Node 22 (TZ=UTC) + let now = utc("2026-10-15T20:00:00.000Z"); + assert_eq!( + js::iso(&expires_at(now, CacheLifetime::Found)), + "2026-10-16T02:00:00.000Z" + ); + assert_eq!( + js::iso(&expires_at(now, CacheLifetime::NotFound)), + "2026-10-16T08:00:00.000Z" + ); + assert_eq!( + js::iso(&expires_at(now, CacheLifetime::BlNumber)), + "2026-10-16T20:00:00.000Z" + ); + assert_eq!( + js::iso(&expires_at(now, CacheLifetime::Reconciled)), + "2076-10-15T20:00:00.000Z" + ); + let leap = utc("2028-02-29T10:00:00.000Z"); + assert_eq!( + js::iso(&expires_at(leap, CacheLifetime::Reconciled)), + "2078-03-01T10:00:00.000Z" + ); + } + + #[test] + fn resultat_trouve_depuis_une_ligne_nocodb() { + let row = json!({ "Id": 1, "RefFacture": "FAC-2001", "MontantHT": 1250.5, "MontantTTC": "1 500,60", + "Fournisseur": "Lactalis", "Echeance": "2026-10-25" }); + let result = found_result( + &row, + &group(), + MATCH_INVOICE_REFERENCE, + JsField::Set(json!("FAC-2001")), + json!("Lactalis"), + ); + assert_eq!( + serde_json::to_value(&result).unwrap(), + json!({ "exists": true, "matchType": "invoice_reference", "invoiceReference": "FAC-2001", + "invoiceAmount": 1250.5, "invoiceAmountTTC": 1, "supplierName": "Lactalis", + "dueDate": "2026-10-25" }) + ); + // Cellules absentes : montants à 0, échéance null ; colonnes par défaut de Node + let empty = found_result( + &json!({}), + &empty_group(), + MATCH_BL_NUMBER, + JsField::Set(json!("BL_X")), + json!("Inconnu"), + ); + assert_eq!(empty.invoice_amount, JsField::Set(0.0)); + assert_eq!(empty.due_date, JsField::Set(Value::Null)); + let nan = found_result( + &json!({ "amount": "abc" }), + &empty_group(), + MATCH_BL_NUMBER, + JsField::Absent, + json!("x"), + ); + assert!(nan.invoice_amount.get().unwrap().is_nan()); + assert_eq!(serde_json::to_value(&nan).unwrap()["invoiceAmount"], Value::Null); + assert_eq!(cached_amount(&nan.invoice_amount), None); + assert_eq!(cached_amount(&JsField::Set(975.0)).as_deref(), Some("975")); + } + + #[test] + fn echec_et_resultat_du_cache() { + assert_eq!( + serde_json::to_string(&Verification::failure("Référence de facture vide")).unwrap(), + r#"{"exists":false,"matchType":"none","errorMessage":"Référence de facture vide"}"# + ); + let cached = Verification { + exists: true, + match_type: MATCH_INVOICE_REFERENCE.into(), + error_message: JsField::Null, + invoice_reference: JsField::Set(json!("FAC-2004")), + invoice_amount: JsField::Set(975.0), + invoice_amount_ttc: JsField::Absent, + supplier_name: JsField::Set(json!("Brico Pro")), + due_date: JsField::Set(json!("2026-11-09T00:00:00.000Z")), + from_cache: Some(true), + permanent: Some(false), + }; + assert_eq!( + serde_json::to_string(&cached).unwrap(), + r#"{"exists":true,"matchType":"invoice_reference","errorMessage":null,"invoiceReference":"FAC-2004","supplierName":"Brico Pro","invoiceAmount":975,"dueDate":"2026-11-09T00:00:00.000Z","fromCache":true,"permanent":false}"# + ); + assert_eq!(cached.normalized_due_date().as_deref(), Some("2026-11-09")); + } + + #[test] + fn rapprochement_du_fournisseur_d_un_bl() { + assert!(bl_supplier_matches("LACTALIS France", "lactalis")); + assert!(bl_supplier_matches("Lactalis", "Lactalis France")); + assert!(bl_supplier_matches("", "Bonduelle")); + assert!(!bl_supplier_matches("Brico Pro", "Bonduelle")); + } + + #[test] + fn configuration_de_rapprochement() { + let mut group = empty_group(); + assert!(!has_reconciliation_config(&group)); + group.webhook_url = Some(String::new()); + assert!(!has_reconciliation_config(&group)); + group.nocodb_table_name = Some("factures".into()); + assert!(has_reconciliation_config(&group)); + assert_eq!(column_or(&Some(String::new()), "RefFacture"), "RefFacture"); + } +} diff --git a/server-rs/src/services/mail.rs b/server-rs/src/services/mail.rs new file mode 100644 index 0000000..a0a6134 --- /dev/null +++ b/server-rs/src/services/mail.rs @@ -0,0 +1,548 @@ +// Courriel de demande de documents aux fournisseurs (rapprochement BL / factures) : +// - contenu : shared/supplierMail.ts (objet, texte, HTML avec la signature et le +// logo du magasin), reproduit caractère pour caractère ; +// - envoi : server/emailService.ts, par le serveur SMTP propre à chaque magasin +// (mot de passe chiffré en base, déchiffré au moment de l'envoi), logo joint +// en pièce intégrée « cid:logo » ; +// - journal : table supplier_mail_logs (storage.createSupplierMailLog). + +use std::time::Duration; + +use chrono::{Datelike, NaiveDate, NaiveDateTime}; +use lettre::message::header::{ContentDisposition, ContentId, ContentType}; +use lettre::message::{Body, Mailbox, Mailboxes, MultiPart, SinglePart, header}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; +use rand::RngCore; +use rust_decimal::Decimal; +use serde_json::{Map, Value, json}; +use sqlx::PgPool; + +use crate::schema::{GroupsRow, SupplierMailLogsRow}; + +/// Livraison telle que la lit le contenu du mail (SupplierMailDelivery) +#[derive(Debug, Clone, Default)] +pub struct MailDelivery { + pub bl_number: Option, + pub bl_amount: Option, + pub invoice_reference: Option, + pub scheduled_date: Option, + pub delivered_date: Option, + pub supplier_name: Option, + pub group_name: Option, +} + +/// Coordonnées du magasin reprises dans la signature (StoreSignature) +#[derive(Debug, Clone, Default)] +pub struct StoreSignature { + pub name: Option, + pub address: Option, + pub phone: Option, + pub email: Option, +} + +impl StoreSignature { + /// Signature d'un magasin : nom, adresse, téléphone, adresse d'expédition + pub fn of_group(group: &GroupsRow) -> Self { + Self { + name: Some(group.name.clone()), + address: group.address.clone(), + phone: group.phone.clone(), + email: group.smtp_sender_email.clone(), + } + } +} + +/// Valeur « vraie » en JavaScript : présente et non vide +fn filled(value: &Option) -> Option<&str> { + value.as_deref().filter(|text| !text.is_empty()) +} + +/// formatDate : jj/mm/aaaa de la date de livraison, sinon de la date prévue +fn formatted_date(delivery: &MailDelivery) -> Option { + let date = match (delivery.delivered_date, delivery.scheduled_date) { + (Some(delivered), _) => delivered.date(), + (None, Some(scheduled)) => scheduled, + (None, None) => return None, + }; + Some(format!("{:02}/{:02}/{}", date.day(), date.month(), date.year())) +} + +/// buildSupplierMailSubject : identifie la livraison concernée +pub fn subject(delivery: &MailDelivery) -> String { + let date = formatted_date(delivery); + let bl_number = filled(&delivery.bl_number); + let reference = match (bl_number, &date) { + (Some(bl), _) => format!("BL n° {bl}"), + (None, Some(date)) => format!("Livraison du {date}"), + (None, None) => "Livraison".to_string(), + }; + let mut parts = vec!["Demande de facture (PDF) ou BL (Excel)".to_string()]; + parts.push(match (bl_number, &date) { + (Some(_), Some(date)) => format!("{reference} du {date}"), + _ => reference, + }); + if let Some(name) = filled(&delivery.group_name) { + parts.push(name.to_string()); + } + parts.join(" - ") +} + +/// buildDeliveryDetails : seules les informations renseignées sont listées +fn details(delivery: &MailDelivery) -> Vec { + let mut lines = Vec::new(); + if let Some(name) = filled(&delivery.supplier_name) { + lines.push(format!("Fournisseur : {name}")); + } + if let Some(name) = filled(&delivery.group_name) { + lines.push(format!("Magasin : {name}")); + } + if let Some(date) = formatted_date(delivery) { + lines.push(format!("Date de livraison : {date}")); + } + if let Some(bl) = filled(&delivery.bl_number) { + lines.push(format!("N° de BL : {bl}")); + } + if let Some(amount) = delivery.bl_amount { + // parseFloat(montant).toFixed(2) + lines.push(format!("Montant du BL : {} €", to_fixed_2(amount))); + } + if let Some(reference) = filled(&delivery.invoice_reference) { + lines.push(format!("Référence facture : {reference}")); + } + if lines.is_empty() { + lines.push("(informations de livraison à préciser)".to_string()); + } + lines +} + +/// parseFloat(montant).toFixed(2) d'un montant à deux décimales au plus +/// (« -0 » s'écrit « 0.00 ») +fn to_fixed_2(amount: Decimal) -> String { + let amount = amount.round_dp(2); + let amount = if amount.is_zero() { Decimal::ZERO } else { amount }; + format!("{amount:.2}") +} + +/// buildSupplierMailText : version texte (repli des clients sans HTML) +pub fn text(delivery: &MailDelivery, store: Option<&StoreSignature>) -> String { + let signature = store + .map(|store| { + [ + filled(&store.name).map(str::to_string), + filled(&store.address).map(str::to_string), + filled(&store.phone).map(|phone| format!("Tél. {phone}")), + filled(&store.email).map(str::to_string), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let mut lines: Vec = [ + "Bonjour,", + "", + "Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :", + "", + "- la facture au format PDF, ou", + "- le bon de livraison au format Excel", + "", + "Livraison concernée :", + ] + .iter() + .map(|line| line.to_string()) + .collect(); + lines.extend(details(delivery).into_iter().map(|line| format!("- {line}"))); + lines.extend( + ["", "Vous en remerciant par avance.", "", "Cordialement,"] + .iter() + .map(|line| line.to_string()), + ); + if !signature.is_empty() { + lines.push(signature); + } + lines.join("\n") +} + +/// escapeHtml : & < > " (l'apostrophe reste telle quelle) +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// buildSupplierMailHtml : version HTML, signature et logo (référencé par cid:logo) +pub fn html(delivery: &MailDelivery, store: Option<&StoreSignature>, has_logo: bool) -> String { + let details: String = details(delivery) + .iter() + .map(|line| format!("
  • {}
  • ", escape_html(line))) + .collect(); + let signature_lines: Vec = store + .map(|store| { + [ + filled(&store.name).map(|name| format!("{}", escape_html(name))), + filled(&store.address).map(|address| escape_html(address).replace('\n', "
    ")), + filled(&store.phone).map(|phone| format!("Tél. {}", escape_html(phone))), + filled(&store.email).map(|email| { + let email = escape_html(email); + format!("{email}") + }), + ] + .into_iter() + .flatten() + .collect() + }) + .unwrap_or_default(); + let logo_block = if has_logo { + "

    \"\"

    " + } else { + "" + }; + let signature_block = if !signature_lines.is_empty() || !logo_block.is_empty() { + format!( + "{logo_block}

    {}

    ", + signature_lines.join("
    ") + ) + } else { + String::new() + }; + format!( + "
    +

    Bonjour,

    +

    Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :

    +
      +
    • la facture au format PDF, ou
    • +
    • le bon de livraison au format Excel
    • +
    +

    Livraison concernée :

    +
      {details}
    +

    Vous en remerciant par avance.

    +

    Cordialement,

    + {signature_block} +
    " + ) +} + +// ---------- Envoi ---------- + +/// getMissingSmtpFields : ce qu'il reste à renseigner sur la fiche du magasin +pub fn missing_smtp_fields(group: &GroupsRow) -> Vec<&'static str> { + let blank = |value: &Option| crate::js::trim(value.as_deref().unwrap_or_default()).is_empty(); + let mut missing = Vec::new(); + if blank(&group.smtp_host) { + missing.push("serveur SMTP"); + } + if group.smtp_port.is_none_or(|port| port == 0) { + missing.push("port SMTP"); + } + if blank(&group.smtp_sender_email) { + missing.push("adresse expéditeur"); + } + missing +} + +/// Logo du magasin en data URI (parseLogoDataUri) : contenu, type, nom de fichier +struct Logo { + content: Vec, + content_type: String, + filename: String, +} + +/// /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/ sur le logo sans blancs autour +fn parse_logo(logo: Option<&str>) -> Option { + let logo = crate::js::trim(logo.filter(|logo| !logo.is_empty())?); + let rest = logo.strip_prefix("data:image/")?; + let (subtype, data) = rest.split_once(";base64,")?; + let subtype_ok = !subtype.is_empty() + && subtype + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".+-".contains(c)); + // « . » de JavaScript : tout sauf les fins de ligne + let data_ok = !data.is_empty() && !data.contains(['\n', '\r', '\u{2028}', '\u{2029}']); + if !subtype_ok || !data_ok { + return None; + } + let extension = subtype + .split('+') + .next() + .filter(|ext| !ext.is_empty()) + .unwrap_or("png"); + Some(Logo { + content: crate::js::buffer_from_base64(data), + content_type: format!("image/{subtype}"), + filename: format!("logo.{extension}"), + }) +} + +/// Erreur d'envoi : le message que nodemailer donnerait pour les cas courants +/// (serveur injoignable, nom inconnu), sinon celui de lettre +fn smtp_error_message(error: &lettre::transport::smtp::Error, host: &str, port: u16) -> String { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = source { + if let Some(io) = current.downcast_ref::() { + if io.kind() == std::io::ErrorKind::ConnectionRefused { + return format!("connect ECONNREFUSED {host}:{port}"); + } + if io.to_string().contains("failed to lookup address") { + return format!("getaddrinfo ENOTFOUND {host}"); + } + } + source = current.source(); + } + error.to_string() +} + +/// Transporteur SMTP du magasin (createTransporter) : authentification seulement +/// si un utilisateur et un mot de passe sont renseignés +fn transport( + group: &GroupsRow, + encryption_key: &str, +) -> Result<(AsyncSmtpTransport, String, u16), String> { + let missing = missing_smtp_fields(group); + if !missing.is_empty() { + return Err(format!("Configuration SMTP incomplète : {}", missing.join(", "))); + } + let password = match group.smtp_password.as_deref() { + Some(value) if !value.is_empty() => Some(crate::crypto::decrypt(value, encryption_key)?), + _ => None, + }; + let host = crate::js::trim(group.smtp_host.as_deref().unwrap_or_default()).to_string(); + let port = u16::try_from(group.smtp_port.unwrap_or_default()) + .map_err(|_| format!("Port SMTP invalide : {:?}", group.smtp_port))?; + // secure : TLS dès la connexion ; sinon STARTTLS si le serveur le propose (un + // nom d'hôte inutilisable en TLS laisse alors la connexion échouer d'elle-même) + let tls = match (group.smtp_secure == Some(true), TlsParameters::new(host.clone())) { + (true, parameters) => Tls::Wrapper(parameters.map_err(|error| error.to_string())?), + (false, Ok(parameters)) => Tls::Opportunistic(parameters), + (false, Err(_)) => Tls::None, + }; + let mut builder = AsyncSmtpTransport::::builder_dangerous(host.clone()) + .port(port) + .tls(tls) + .timeout(Some(Duration::from_secs(60))); + let user = group + .smtp_user + .as_deref() + .map(crate::js::trim) + .unwrap_or_default(); + if let Some(password) = password.filter(|password| !password.is_empty() && !user.is_empty()) { + builder = builder.credentials(Credentials::new(user.to_string(), password)); + } + Ok((builder.build(), host, port)) +} + +/// verifySmtpConfig : connexion, présentation et authentification, sans envoi +pub async fn verify_smtp_config(group: &GroupsRow, encryption_key: &str) -> Result<(), String> { + let (transport, host, port) = transport(group, encryption_key)?; + transport + .test_connection() + .await + .map(|_| ()) + .map_err(|error| smtp_error_message(&error, &host, port)) +} + +/// Identifiant du message, au format de nodemailer : +fn message_id(sender_email: &str) -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let domain = sender_email.rsplit('@').next().unwrap_or("localhost"); + format!( + "<{}-{}-{}-{}-{}@{domain}>", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +/// sendSupplierDocumentRequest : envoie la demande de facture PDF / BL Excel et +/// rend l'identifiant du message, ou le message d'erreur à journaliser +pub async fn send_supplier_document_request( + group: &GroupsRow, + delivery: &MailDelivery, + supplier_email: &str, + encryption_key: &str, +) -> Result { + let (transport, host, port) = transport(group, encryption_key)?; + let sender_email = crate::js::trim(group.smtp_sender_email.as_deref().unwrap_or_default()).to_string(); + let sender_name = group + .smtp_sender_name + .as_deref() + .map(crate::js::trim) + .filter(|name| !name.is_empty()) + .or_else(|| Some(crate::js::trim(&group.name)).filter(|name| !name.is_empty())); + let invalid = |error: lettre::address::AddressError| error.to_string(); + let from = Mailbox::new( + sender_name.map(str::to_string), + sender_email.parse().map_err(invalid)?, + ); + let reply_to = Mailbox::new(None, sender_email.parse().map_err(invalid)?); + let to: Mailboxes = crate::js::trim(supplier_email).parse().map_err(invalid)?; + + let store = StoreSignature::of_group(group); + let logo = parse_logo(group.logo.as_deref()); + let html_part = SinglePart::html(html(delivery, Some(&store), logo.is_some())); + let text_part = SinglePart::plain(text(delivery, Some(&store))); + let alternative = match logo { + Some(logo) => { + let content_type = ContentType::parse(&format!("{}; name={}", logo.content_type, logo.filename)) + .map_err(|error| error.to_string())?; + let image = SinglePart::builder() + .header(content_type) + .header(ContentId::from("".to_string())) + .header(ContentDisposition::inline_with_name(&logo.filename)) + .body(Body::new(logo.content)); + MultiPart::alternative() + .singlepart(text_part) + .multipart(MultiPart::related().singlepart(html_part).singlepart(image)) + } + None => MultiPart::alternative() + .singlepart(text_part) + .singlepart(html_part), + }; + let id = message_id(&sender_email); + let message = Message::builder() + .from(from) + .mailbox(header::To::from(to)) + .reply_to(reply_to) + .subject(subject(delivery)) + .message_id(Some(id.clone())) + .multipart(alternative) + .map_err(|error| error.to_string())?; + transport + .send(message) + .await + .map_err(|error| smtp_error_message(&error, &host, port))?; + Ok(id) +} + +/// Ligne du journal des relances (insertSupplierMailLogSchema) +#[derive(Debug, Clone)] +pub struct MailLog { + pub delivery_id: i32, + pub group_id: i32, + pub supplier_id: Option, + pub supplier_name: Option, + pub sent_to: String, + pub subject: String, + /// « sent » ou « failed » + pub status: &'static str, + pub error_message: Option, + pub message_id: Option, + pub sent_by: String, + pub sent_by_name: String, +} + +/// storage.createSupplierMailLog +pub async fn record_mail_log(db: &PgPool, log: &MailLog) -> Result { + let mut data = Map::new(); + data.insert("deliveryId".into(), json!(log.delivery_id)); + data.insert("groupId".into(), json!(log.group_id)); + data.insert("supplierId".into(), json!(log.supplier_id)); + data.insert("supplierName".into(), json!(log.supplier_name)); + data.insert("sentTo".into(), json!(log.sent_to)); + data.insert("subject".into(), json!(log.subject)); + data.insert("status".into(), json!(log.status)); + data.insert("errorMessage".into(), json!(log.error_message)); + data.insert("messageId".into(), json!(log.message_id)); + data.insert("sentBy".into(), json!(log.sent_by)); + data.insert("sentByName".into(), Value::String(log.sent_by_name.clone())); + crate::db::insert::(db, &data).await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn delivery() -> MailDelivery { + MailDelivery { + bl_number: Some("BL-1002".into()), + bl_amount: Some(Decimal::from_str("310.00").unwrap()), + invoice_reference: None, + scheduled_date: NaiveDate::from_ymd_opt(2026, 10, 10), + delivered_date: NaiveDate::from_ymd_opt(2026, 10, 10).and_then(|d| d.and_hms_opt(14, 0, 0)), + supplier_name: Some("Bonduelle".into()), + group_name: Some("Magasin Nancy".into()), + } + } + + fn store() -> StoreSignature { + StoreSignature { + name: Some("Magasin Nancy".into()), + address: Some("12 rue des Dominicains, 54000 Nancy".into()), + phone: Some("03 83 00 00 01".into()), + email: Some("nancy@banc.test".into()), + } + } + + // Valeurs produites par shared/supplierMail.ts (Node 22, TZ=UTC) + #[test] + fn objet_comme_node() { + assert_eq!( + subject(&delivery()), + "Demande de facture (PDF) ou BL (Excel) - BL n° BL-1002 du 10/10/2026 - Magasin Nancy" + ); + let scheduled_only = MailDelivery { + scheduled_date: NaiveDate::from_ymd_opt(2026, 10, 10), + ..Default::default() + }; + assert_eq!( + subject(&scheduled_only), + "Demande de facture (PDF) ou BL (Excel) - Livraison du 10/10/2026" + ); + assert_eq!( + subject(&MailDelivery::default()), + "Demande de facture (PDF) ou BL (Excel) - Livraison" + ); + } + + #[test] + fn texte_comme_node() { + assert_eq!( + text(&delivery(), Some(&store())), + "Bonjour,\n\nDans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :\n\n- la facture au format PDF, ou\n- le bon de livraison au format Excel\n\nLivraison concernée :\n- Fournisseur : Bonduelle\n- Magasin : Magasin Nancy\n- Date de livraison : 10/10/2026\n- N° de BL : BL-1002\n- Montant du BL : 310.00 €\n\nVous en remerciant par avance.\n\nCordialement,\nMagasin Nancy\n12 rue des Dominicains, 54000 Nancy\nTél. 03 83 00 00 01\nnancy@banc.test" + ); + assert_eq!( + text(&MailDelivery::default(), None), + "Bonjour,\n\nDans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :\n\n- la facture au format PDF, ou\n- le bon de livraison au format Excel\n\nLivraison concernée :\n- (informations de livraison à préciser)\n\nVous en remerciant par avance.\n\nCordialement," + ); + } + + #[test] + fn html_comme_node() { + let html = html(&delivery(), Some(&store()), true); + assert!(html.contains("
    • Fournisseur : Bonduelle
    • Magasin : Magasin Nancy
    • Date de livraison : 10/10/2026
    • N° de BL : BL-1002
    • Montant du BL : 310.00 €
    ")); + assert!(html.contains("

    \"\"

    Magasin Nancy
    12 rue des Dominicains, 54000 Nancy
    Tél. 03 83 00 00 01
    nancy@banc.test

    \n")); + let escaped = MailDelivery { + supplier_name: Some("A & B ".into()), + ..Default::default() + }; + let signature = StoreSignature { + name: Some("S\"1".into()), + address: Some("l1\nl2".into()), + ..Default::default() + }; + assert_eq!( + super::html(&escaped, Some(&signature), false), + "
    \n

    Bonjour,

    \n

    Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :

    \n
      \n
    • la facture au format PDF, ou
    • \n
    • le bon de livraison au format Excel
    • \n
    \n

    Livraison concernée :

    \n
    • Fournisseur : A & B <x>
    \n

    Vous en remerciant par avance.

    \n

    Cordialement,

    \n

    S"1
    l1
    l2

    \n
    " + ); + } + + #[test] + fn logo_en_data_uri() { + let logo = parse_logo(Some(" data:image/svg+xml;base64,PHN2Zz4= ")).unwrap(); + assert_eq!(logo.content_type, "image/svg+xml"); + assert_eq!(logo.filename, "logo.svg"); + assert_eq!(logo.content, b""); + assert!(parse_logo(Some("data:text/plain;base64,QQ==")).is_none()); + assert!(parse_logo(Some("data:image/png;base64,QQ\n==")).is_none()); + assert!(parse_logo(None).is_none()); + } +} diff --git a/server-rs/src/services/mod.rs b/server-rs/src/services/mod.rs new file mode 100644 index 0000000..8c4764e --- /dev/null +++ b/server-rs/src/services/mod.rs @@ -0,0 +1,5 @@ +// Services partagés par plusieurs modules (lot 4). +pub mod dates; +pub mod invoice_verification; +pub mod mail; +pub mod nocodb; diff --git a/server-rs/src/services/nocodb.rs b/server-rs/src/services/nocodb.rs new file mode 100644 index 0000000..6fbe98c --- /dev/null +++ b/server-rs/src/services/nocodb.rs @@ -0,0 +1,397 @@ +// Client NocoDB (table des factures), au comportement du serveur Node : +// - recherche d'une facture par colonne (searchInNocoDB, server/invoiceVerification.ts) : +// GET {baseUrl}/api/v1/db/data/v1/{projectId}/{tableId}?where=(colonne,eq,valeur), +// jeton dans l'en-tête xc-token, délai maximal de 10 s ; +// - configuration active et déchiffrement du jeton (storage.getActiveNocodbConfig, +// decryptNocodbConfig) ; +// - ancien client de server/nocodbService.ts (POST /api/verify-invoices), qui lit +// toute la table et compare les références sans tenir compte de la casse. +// +// Node appelle NocoDB avec le fetch intégré (undici) : toute erreur réseau donne +// « fetch failed », un délai dépassé « Timeout - API NocoDB non accessible ». +// Les proxys de l'environnement sont ignorés, comme par Node. + +use std::sync::LazyLock; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{Map, Value}; +use sqlx::PgPool; + +use crate::AppState; +use crate::db::Table; +use crate::schema::{GroupsRow, NocodbConfigRow}; + +/// Délai maximal d'un appel NocoDB (AbortController de searchInNocoDB) +pub const TIMEOUT: Duration = Duration::from_secs(10); + +static CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("client HTTP NocoDB") +}); + +/// decryptNocodbConfig : jeton en clair ; illisible, il devient une chaîne vide +pub fn decrypt_config(mut config: NocodbConfigRow, encryption_key: &str) -> NocodbConfigRow { + config.api_token = crate::crypto::decrypt(&config.api_token, encryption_key).unwrap_or_else(|error| { + tracing::error!(config = config.id, error, "jeton NocoDB illisible"); + String::new() + }); + config +} + +/// storage.getActiveNocodbConfig : première configuration active (sans ordre, +/// comme Node), jeton déchiffré ; None aussi en cas d'erreur de base +pub async fn active_config(state: &AppState) -> Option { + let row = + sqlx::query_as::<_, NocodbConfigRow>(&NocodbConfigRow::select("WHERE is_active = true LIMIT 1")) + .fetch_optional(&state.db) + .await; + match row { + Ok(row) => row.map(|config| decrypt_config(config, &state.config.encryption_key)), + Err(error) => { + tracing::error!(%error, "lecture de la configuration NocoDB active"); + None + } + } +} + +/// Résultat d'une recherche dans la table des factures +#[derive(Debug, Clone, PartialEq)] +pub enum Search { + /// Première ligne trouvée, telle que NocoDB la renvoie + Found(Value), + NotFound, + /// Message d'erreur repris dans le résultat de la vérification + Error(String), +} + +/// Message de JSON.parse (V8) pour une réponse illisible, dans les cas simples +fn json_parse_error(text: &str) -> String { + let trimmed = text.trim_start(); + match trimmed.chars().next() { + None => "Unexpected end of JSON input".to_string(), + Some(first) => { + let excerpt: String = text.chars().take(10).collect(); + let more = if text.chars().count() > 10 { "..." } else { "" }; + format!("Unexpected token '{first}', \"{excerpt}\"{more} is not valid JSON") + } + } +} + +/// Réponse JSON de NocoDB, ou le message d'erreur que Node en tirerait +async fn read_json(response: reqwest::Response) -> Result { + let text = response.text().await.map_err(|_| "fetch failed".to_string())?; + serde_json::from_str(&text).map_err(|_| json_parse_error(&text)) +} + +/// Envoi d'une requête GET avec le jeton ; erreurs au format d'undici +async fn get(url: &str, token: &str, timeout: Option) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|_| format!("Failed to parse URL from {url}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("fetch failed".to_string()); + } + let mut request = CLIENT + .get(parsed) + .header("Content-Type", "application/json") + .header("xc-token", token); + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + request.send().await.map_err(|error| { + if error.is_timeout() { + "Timeout - API NocoDB non accessible".to_string() + } else { + tracing::warn!(%error, "appel NocoDB"); + "fetch failed".to_string() + } + }) +} + +/// searchInNocoDB : première ligne dont la colonne vaut exactement la valeur +pub async fn search(config: &NocodbConfigRow, table_id: &str, column: &str, value: &str) -> Search { + if value.is_empty() || column.is_empty() || table_id.is_empty() { + return Search::Error("Paramètres invalides".to_string()); + } + tracing::debug!(table_id, column, "recherche NocoDB"); + let url = format!( + "{}/api/v1/db/data/v1/{}/{}?where=({},eq,{})", + config.base_url, + config.project_id, + table_id, + column, + crate::js::encode_uri_component(value) + ); + let response = match get(&url, &config.api_token, Some(TIMEOUT)).await { + Ok(response) => response, + Err(message) => return Search::Error(message), + }; + let status = response.status(); + if !status.is_success() { + return Search::Error(format!( + "HTTP {}: {}", + status.as_u16(), + status.canonical_reason().unwrap_or_default() + )); + } + let result = match read_json(response).await { + Ok(result) => result, + Err(message) => return Search::Error(message), + }; + // result.list && result.list.length > 0 + match &result { + Value::Null => Search::Error("Cannot read properties of null (reading 'list')".to_string()), + _ => match result.get("list") { + Some(Value::Array(list)) if !list.is_empty() => Search::Found(list[0].clone()), + Some(Value::String(text)) if !text.is_empty() => { + Search::Found(Value::String(text.chars().take(1).collect())) + } + _ => Search::NotFound, + }, + } +} + +// ---------- Ancien client (server/nocodbService.ts) ---------- + +/// InvoiceVerificationResult de nocodbService : { exists, error? } +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LegacyResult { + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl LegacyResult { + fn error(message: impl Into) -> Self { + Self { + exists: false, + error: Some(message.into()), + } + } +} + +/// Valeur « vraie » en JavaScript +pub fn truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(flag) => *flag, + Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()), + Value::String(text) => !text.is_empty(), + Value::Array(_) | Value::Object(_) => true, + } +} + +/// TypeError de JavaScript pour `nom.methode()` appelé sur une valeur qui n'est pas une chaîne +pub fn not_a_string_error(name: &str, method: &str, value: Option<&Value>) -> String { + match value { + None => format!("Cannot read properties of undefined (reading '{method}')"), + Some(Value::Null) => format!("Cannot read properties of null (reading '{method}')"), + Some(_) => format!("{name}.{method} is not a function"), + } +} + +/// storage.getGroup avec l'identifiant tel que reçu (texte converti par PostgreSQL, +/// comme node-pg) : Err avec le message de PostgreSQL s'il n'est pas un entier +pub async fn group_by_raw_id(db: &PgPool, group_id: Option<&Value>) -> Result, String> { + group_by_text_id(db, group_id.and_then(crate::db::pg_text).as_deref()).await +} + +/// storage.getGroup avec l'identifiant en texte (None : aucune ligne, comme `id = NULL`) +pub async fn group_by_text_id(db: &PgPool, group_id: Option<&str>) -> Result, String> { + sqlx::query_as::<_, GroupsRow>(&GroupsRow::select("WHERE id = $1::int4")) + .bind(group_id) + .fetch_optional(db) + .await + .map_err(|error| database_message(&error)) +} + +/// Message d'une erreur PostgreSQL tel que node-pg le donne (error.message) +pub fn database_message(error: &sqlx::Error) -> String { + match error { + sqlx::Error::Database(database) => database.message().to_string(), + other => other.to_string(), + } +} + +/// verifyInvoiceReference : la référence existe-t-elle dans la table du magasin ? +pub async fn verify_invoice_reference( + state: &AppState, + group_id: Option<&Value>, + invoice_reference: Option<&Value>, + supplier_name: Option<&Value>, +) -> LegacyResult { + let group = match group_by_raw_id(&state.db, group_id).await { + Ok(group) => group, + Err(message) => return LegacyResult::error(message), + }; + let Some((group, config_id, table_id)) = group.and_then(|group| { + let config_id = group.nocodb_config_id.filter(|id| *id != 0)?; + let table_id = group.nocodb_table_id.clone().filter(|id| !id.is_empty())?; + Some((group, config_id, table_id)) + }) else { + return LegacyResult::error("No NocoDB configuration for this group"); + }; + let config = match crate::db::find_by_id::(&state.db, config_id).await { + Ok(Some(config)) => decrypt_config(config, &state.config.encryption_key), + Ok(None) => return LegacyResult::error("NocoDB configuration not found"), + Err(error) => return LegacyResult::error(database_message(&error)), + }; + let column = group + .invoice_column_name + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Ref Facture".to_string()); + let Some(reference) = invoice_reference.and_then(Value::as_str) else { + return LegacyResult::error(not_a_string_error("invoiceReference", "trim", invoice_reference)); + }; + let normalized_reference = crate::js::trim(reference).to_lowercase(); + + // Sans délai maximal côté Node : 10 s ici, comme tout appel NocoDB (plan, lot 2) + let url = format!( + "{}/api/v1/db/data/noco/{}/{}", + config.base_url, config.project_id, table_id + ); + let response = match get(&url, &config.api_token, Some(TIMEOUT)).await { + Ok(response) => response, + Err(message) => return LegacyResult::error(message), + }; + if !response.status().is_success() { + return LegacyResult::error(format!("NocoDB API error: {}", response.status().as_u16())); + } + let data = match read_json(response).await { + Ok(Value::Null) => return LegacyResult::error("Cannot read properties of null (reading 'pageInfo')"), + Ok(data) => data, + Err(message) => return LegacyResult::error(message), + }; + let records = match data.get("list") { + Some(Value::Array(records)) => records.as_slice(), + Some(Value::String(text)) if !text.is_empty() => { + return LegacyResult::error("data.list.filter is not a function"); + } + _ => &[], + }; + let supplier = supplier_name.filter(|name| truthy(name)); + let mut exists = false; + for record in records { + let cell = match record { + Value::Null => { + return LegacyResult::error(format!("Cannot read properties of null (reading '{column}')")); + } + record => record.get(&column).filter(|cell| truthy(cell)), + }; + let Some(cell) = cell else { continue }; + if crate::js::to_js_string(cell).to_lowercase() != normalized_reference { + continue; + } + // Avec un fournisseur : l'un des deux noms doit contenir l'autre + let record_supplier = ["Fournisseur", "Supplier", "fournisseur", "supplier"] + .iter() + .filter_map(|key| record.get(*key)) + .find(|value| truthy(value)); + let matched = match (supplier, record_supplier) { + (Some(searched), Some(found)) => { + let Some(searched) = searched.as_str() else { + return LegacyResult::error("supplierName.toLowerCase is not a function"); + }; + let (searched, found) = ( + searched.to_lowercase(), + crate::js::to_js_string(found).to_lowercase(), + ); + found.contains(&searched) || searched.contains(&found) + } + _ => true, + }; + // Comme filter() : toutes les lignes sont lues, même après une correspondance + exists |= matched; + } + LegacyResult { exists, error: None } +} + +/// Clé d'objet JavaScript « indice de tableau » : rangée avant les autres, par ordre croissant +pub fn array_index(key: &str) -> Option { + let index: u32 = key.parse().ok()?; + (index != u32::MAX && index.to_string() == key).then_some(index) +} + +/// verifyMultipleInvoiceReferences : résultats rangés par identifiant de livraison, +/// dans l'ordre des clés d'un objet JavaScript. Node traite les références en +/// parallèle ; elles le sont ici l'une après l'autre (même résultat, la dernière +/// référence d'un identifiant répété l'emportant). None si un élément est null +/// (TypeError côté Node, avant tout appel). +pub async fn verify_multiple_invoice_references( + state: &AppState, + references: &[Value], +) -> Option> { + if references.iter().any(Value::is_null) { + return None; + } + let mut results: Vec<(String, Value)> = Vec::new(); + for reference in references { + let field = |name: &str| reference.as_object().and_then(|object| object.get(name)); + let result = verify_invoice_reference( + state, + field("groupId"), + field("invoiceReference"), + field("supplierName"), + ) + .await; + let key = field("deliveryId") + .map(crate::js::to_js_string) + .unwrap_or_else(|| "undefined".to_string()); + let value = serde_json::to_value(result).unwrap_or(Value::Null); + match results.iter_mut().find(|(existing, _)| *existing == key) { + Some(entry) => entry.1 = value, + None => results.push((key, value)), + } + } + let (mut indexes, others): (Vec<_>, Vec<_>) = results + .into_iter() + .partition(|(key, _)| array_index(key).is_some()); + indexes.sort_by_key(|(key, _)| array_index(key)); + Some(indexes.into_iter().chain(others).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn messages_d_erreur_de_node() { + assert_eq!(json_parse_error(""), "Unexpected end of JSON input"); + assert_eq!( + json_parse_error("abc"), + "Unexpected token 'a', \"abc\" is not valid JSON" + ); + assert_eq!( + not_a_string_error("invoiceReference", "trim", Some(&json!(12))), + "invoiceReference.trim is not a function" + ); + assert_eq!( + not_a_string_error("invoiceReference", "trim", None), + "Cannot read properties of undefined (reading 'trim')" + ); + } + + #[test] + fn ordre_des_cles_d_un_objet_javascript() { + assert_eq!(array_index("12"), Some(12)); + assert_eq!(array_index("012"), None); + assert_eq!(array_index("1.5"), None); + assert_eq!(array_index("undefined"), None); + assert!(truthy(&json!("x")) && !truthy(&json!("")) && !truthy(&json!(0)) && truthy(&json!([]))); + } + + #[test] + fn resultat_sans_erreur_omet_le_champ() { + assert_eq!( + serde_json::to_value(LegacyResult { + exists: true, + error: None + }) + .unwrap(), + json!({ "exists": true }) + ); + } +} diff --git a/server-rs/src/startup.rs b/server-rs/src/startup.rs new file mode 100644 index 0000000..73d5f16 --- /dev/null +++ b/server-rs/src/startup.rs @@ -0,0 +1,494 @@ +// Migrations de démarrage, reprises de server/migrations.production.ts et du CMD +// de l'ancien Dockerfile : toutes idempotentes (IF NOT EXISTS), une erreur est +// journalisée sans empêcher le démarrage, comme dans Node. Les index de +// performance se créent ensuite en arrière-plan (CREATE INDEX CONCURRENTLY). + +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use sqlx::{PgPool, Row}; + +/// Schéma attendu par le code : colonnes et tables ajoutées depuis init.sql +const SCHEMA_STATEMENTS: &[(&str, &str)] = &[ + ( + "colonne priority des tickets SAV", + "ALTER TABLE sav_tickets ADD COLUMN IF NOT EXISTS priority varchar(50) NOT NULL DEFAULT 'normale'", + ), + ( + "rupture de stock des DLC", + "ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255), + ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp", + ), + ( + "index de rupture de stock des DLC", + "CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise)", + ), + ( + "client prévenu des commandes clients", + "ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS notified_comment TEXT", + ), + ( + "code fournisseur", + "ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR", + ), + ( + "coordonnées et SMTP des magasins", + "ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT, + ADD COLUMN IF NOT EXISTS phone VARCHAR(50), + ADD COLUMN IF NOT EXISTS logo TEXT, + ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255), + ADD COLUMN IF NOT EXISTS smtp_port INTEGER, + ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255), + ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255), + ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255)", + ), + ( + "historique des courriels fournisseurs", + "CREATE TABLE IF NOT EXISTS supplier_mail_logs ( + id SERIAL PRIMARY KEY, + delivery_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + supplier_id INTEGER, + supplier_name VARCHAR(255), + sent_to VARCHAR(255) NOT NULL, + subject TEXT, + status VARCHAR(20) NOT NULL, + error_message TEXT, + message_id VARCHAR(255), + sent_by VARCHAR NOT NULL, + sent_by_name VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ), + ( + "index des courriels fournisseurs par livraison", + "CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id)", + ), + ( + "index des courriels fournisseurs par magasin", + "CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id)", + ), + ( + "clés de l'API externe", + "CREATE TABLE IF NOT EXISTS external_api_keys ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(16) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, + created_by VARCHAR, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP + )", + ), + ( + "configuration du webhook BAP", + "CREATE TABLE IF NOT EXISTS webhook_bap_config ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP', + webhook_url TEXT NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + ), + ( + "sessions de connexion", + "CREATE TABLE IF NOT EXISTS sessions ( + sid VARCHAR PRIMARY KEY, + sess JSONB NOT NULL, + expire TIMESTAMP NOT NULL + )", + ), + ( + "index d'expiration des sessions", + "CREATE INDEX IF NOT EXISTS \"IDX_session_expire\" ON sessions (expire)", + ), +]; + +/// Applique les migrations de schéma, puis chiffre les secrets encore en clair +pub async fn run_migrations(db: &PgPool, encryption_key: &str) { + for (label, statement) in SCHEMA_STATEMENTS { + if let Err(error) = sqlx::query(statement).execute(db).await { + tracing::error!(%error, "migration « {label} » en échec"); + } + } + match encrypt_plain_secrets(db, encryption_key).await { + Ok(0) => {} + Ok(count) => tracing::info!(count, "secrets en clair chiffrés"), + Err(error) => tracing::error!(%error, "chiffrement des secrets en clair en échec"), + } + tracing::info!("migrations de démarrage terminées"); +} + +/// Mots de passe SMTP des magasins et jetons NocoDB encore en clair → enc:v1 +async fn encrypt_plain_secrets(db: &PgPool, key: &str) -> Result { + let mut count = 0; + let groups: Vec<(i32, String)> = sqlx::query_as( + "SELECT id, smtp_password FROM groups + WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'", + ) + .fetch_all(db) + .await?; + for (id, secret) in groups { + sqlx::query("UPDATE groups SET smtp_password = $1 WHERE id = $2") + .bind(crate::crypto::encrypt(&secret, key)) + .bind(id) + .execute(db) + .await?; + count += 1; + } + let tokens: Vec<(i32, String)> = sqlx::query_as( + "SELECT id, api_token FROM nocodb_config + WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'", + ) + .fetch_all(db) + .await?; + for (id, secret) in tokens { + sqlx::query("UPDATE nocodb_config SET api_token = $1 WHERE id = $2") + .bind(crate::crypto::encrypt(&secret, key)) + .bind(id) + .execute(db) + .await?; + count += 1; + } + Ok(count) +} + +struct PerformanceIndex { + name: &'static str, + table: &'static str, + columns: &'static str, + predicate: Option<&'static str>, +} + +const fn index(name: &'static str, table: &'static str, columns: &'static str) -> PerformanceIndex { + PerformanceIndex { + name, + table, + columns, + predicate: None, + } +} + +const fn partial( + name: &'static str, + table: &'static str, + columns: &'static str, + predicate: &'static str, +) -> PerformanceIndex { + PerformanceIndex { + name, + table, + columns, + predicate: Some(predicate), + } +} + +/// Index de performance garantis à chaque démarrage (même liste que Node) +const PERFORMANCE_INDEXES: &[PerformanceIndex] = &[ + index("idx_user_groups_user_id", "user_groups", "user_id"), + index("idx_user_groups_group_id", "user_groups", "group_id"), + index("idx_orders_group_id", "orders", "group_id"), + index("idx_orders_supplier_id", "orders", "supplier_id"), + index("idx_orders_created_by", "orders", "created_by"), + index("idx_orders_planned_date", "orders", "planned_date"), + index( + "idx_orders_group_planned_date", + "orders", + "group_id, planned_date", + ), + index("idx_orders_created_at", "orders", "created_at DESC"), + index("idx_orders_status", "orders", "status"), + index("idx_orders_group_status", "orders", "group_id, status"), + index("idx_deliveries_order_id", "deliveries", "order_id"), + index("idx_deliveries_group_id", "deliveries", "group_id"), + index("idx_deliveries_supplier_id", "deliveries", "supplier_id"), + index("idx_deliveries_created_by", "deliveries", "created_by"), + index("idx_deliveries_scheduled_date", "deliveries", "scheduled_date"), + index( + "idx_deliveries_group_scheduled_date", + "deliveries", + "group_id, scheduled_date", + ), + index("idx_deliveries_created_at", "deliveries", "created_at DESC"), + partial( + "idx_deliveries_invoice_reference", + "deliveries", + "invoice_reference", + "invoice_reference IS NOT NULL", + ), + index("idx_deliveries_status", "deliveries", "status"), + index("idx_deliveries_bl_number", "deliveries", "bl_number"), + index("idx_deliveries_due_date", "deliveries", "due_date"), + index("idx_deliveries_group_status", "deliveries", "group_id, status"), + index( + "idx_deliveries_group_delivered_date", + "deliveries", + "group_id, delivered_date", + ), + index( + "idx_reconciliation_comments_delivery_id", + "reconciliation_comments", + "delivery_id", + ), + index( + "idx_reconciliation_comments_group_id", + "reconciliation_comments", + "group_id", + ), + index("idx_contacts_group_id", "contacts", "group_id"), + index("idx_customer_orders_group_id", "customer_orders", "group_id"), + index( + "idx_customer_orders_supplier_id", + "customer_orders", + "supplier_id", + ), + index( + "idx_customer_orders_created_at", + "customer_orders", + "created_at DESC", + ), + index( + "idx_customer_orders_group_status", + "customer_orders", + "group_id, status", + ), + partial( + "idx_customer_orders_pending_calls", + "customer_orders", + "group_id, created_at DESC", + "customer_notified = false", + ), + index("idx_dlc_products_group_id", "dlc_products", "group_id"), + index("idx_dlc_products_supplier_id", "dlc_products", "supplier_id"), + index("idx_dlc_products_expiry_date", "dlc_products", "expiry_date"), + index("idx_dlc_products_status", "dlc_products", "status"), + index( + "idx_dlc_products_group_status", + "dlc_products", + "group_id, status", + ), + index( + "idx_dlc_products_group_expiry", + "dlc_products", + "group_id, expiry_date", + ), + index("idx_tasks_group_id", "tasks", "group_id"), + index("idx_tasks_created_by", "tasks", "created_by"), + index("idx_tasks_due_date", "tasks", "due_date"), + index("idx_tasks_group_status", "tasks", "group_id, status"), + index("idx_tasks_group_created", "tasks", "group_id, created_at DESC"), + index("idx_avoirs_group_id", "avoirs", "group_id"), + index("idx_avoirs_supplier_id", "avoirs", "supplier_id"), + index("idx_avoirs_created_at", "avoirs", "created_at DESC"), + index("idx_avoirs_group_status", "avoirs", "group_id, status"), + index("idx_sav_tickets_group_id", "sav_tickets", "group_id"), + index("idx_sav_tickets_supplier_id", "sav_tickets", "supplier_id"), + index("idx_sav_tickets_created_at", "sav_tickets", "created_at DESC"), + index("idx_sav_tickets_status", "sav_tickets", "status"), + index("idx_sav_tickets_priority", "sav_tickets", "priority"), + index("idx_sav_tickets_group_status", "sav_tickets", "group_id, status"), + index( + "idx_sav_tickets_group_created", + "sav_tickets", + "group_id, created_at DESC", + ), + index( + "idx_invoice_cache_expires_at", + "invoice_verification_cache", + "expires_at", + ), + index( + "idx_invoice_cache_group_id", + "invoice_verification_cache", + "group_id", + ), + index( + "idx_publicity_participations_group_id", + "publicity_participations", + "group_id", + ), + index("idx_publicities_year", "publicities", "year"), + index("idx_publicities_start_date", "publicities", "start_date"), + index( + "idx_dashboard_messages_store_id", + "dashboard_messages", + "store_id", + ), + index( + "idx_dashboard_messages_created_at", + "dashboard_messages", + "created_at DESC", + ), + index("idx_weather_data_date_location", "weather_data", "date, location"), + index( + "idx_weather_data_date_year", + "weather_data", + "date, is_current_year", + ), + index( + "idx_supplier_mail_logs_delivery", + "supplier_mail_logs", + "delivery_id", + ), + index("idx_supplier_mail_logs_group", "supplier_mail_logs", "group_id"), + index( + "idx_supplier_mail_logs_group_created", + "supplier_mail_logs", + "group_id, created_at DESC", + ), +]; + +static INDEXES_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Lance la création des index manquants en arrière-plan : ni le démarrage ni le +/// contrôle de santé ne l'attendent. Un seul passage à la fois. +pub fn spawn_performance_indexes(db: PgPool) { + if INDEXES_RUNNING.swap(true, Ordering::SeqCst) { + return; + } + tokio::spawn(async move { + if let Err(error) = ensure_performance_indexes(&db).await { + tracing::error!(%error, "vérification des index en échec"); + } + INDEXES_RUNNING.store(false, Ordering::SeqCst); + }); +} + +async fn ensure_performance_indexes(db: &PgPool) -> Result<(), sqlx::Error> { + let started = Instant::now(); + + // 1. Une création CONCURRENTLY interrompue laisse un index invalide, que IF NOT + // EXISTS prendrait pour présent : purge des reliquats (sauf en construction) + let invalid: Vec<(String,)> = sqlx::query_as( + "SELECT c.relname::text + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%' + AND NOT EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = i.indexrelid)", + ) + .fetch_all(db) + .await + .unwrap_or_default(); + for (name,) in invalid { + let statement = format!( + "DROP INDEX CONCURRENTLY IF EXISTS public.\"{}\"", + name.replace('"', "\"\"") + ); + if let Err(error) = sqlx::query(&statement).execute(db).await { + tracing::warn!(%error, index = %name, "index invalide non supprimé"); + } + } + + // 2. Tables présentes, index valides par nom, colonnes déjà indexées sous un autre nom + let tables: HashSet = sqlx::query( + "SELECT c.relname::text AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')", + ) + .fetch_all(db) + .await? + .iter() + .map(|row| row.get("name")) + .collect(); + let indexes = sqlx::query( + "SELECT t.relname::text AS table_name, c.relname::text AS index_name, i.indisvalid AS valid, + (i.indpred IS NULL AND i.indexprs IS NULL) AS plain, + array_to_string(ARRAY( + SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum + WHERE k.ord <= i.indnkeyatts ORDER BY k.ord + ), ',') AS columns + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_class t ON t.oid = i.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'public'", + ) + .fetch_all(db) + .await?; + let mut existing = HashSet::new(); + let mut invalid_names = HashSet::new(); + let mut indexed_columns = HashSet::new(); + for row in &indexes { + let name: String = row.get("index_name"); + if !row.get::("valid") { + invalid_names.insert(name); + continue; + } + existing.insert(name); + if row.get::("plain") { + let table: String = row.get("table_name"); + let columns: String = row.get("columns"); + indexed_columns.insert(format!("{table}({columns})")); + } + } + + // 3. Index manquants, une requête chacun (CONCURRENTLY interdit en transaction) + let (mut created, mut present, mut skipped, mut failed) = (0, 0, 0, Vec::new()); + let mut to_analyze = Vec::new(); + for index in PERFORMANCE_INDEXES { + let key_columns: Vec<&str> = index + .columns + .split(',') + .map(|column| column.split_whitespace().next().unwrap_or("")) + .collect(); + if existing.contains(index.name) + || indexed_columns.contains(&format!("{}({})", index.table, key_columns.join(","))) + { + present += 1; + continue; + } + if !tables.contains(index.table) { + skipped += 1; + continue; + } + if invalid_names.contains(index.name) { + failed.push(index.name); + continue; + } + let mut statement = format!( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} ({})", + index.name, index.table, index.columns + ); + if let Some(predicate) = index.predicate { + statement.push_str(&format!(" WHERE {predicate}")); + } + match sqlx::query(&statement).execute(db).await { + Ok(_) => { + created += 1; + if !to_analyze.contains(&index.table) { + to_analyze.push(index.table); + } + } + Err(error) => { + tracing::warn!(%error, index = index.name, "création d'index en échec"); + failed.push(index.name); + } + } + } + + // 4. Statistiques à jour pour que le planificateur utilise les nouveaux index + for table in to_analyze { + if let Err(error) = sqlx::query(&format!("ANALYZE {table}")).execute(db).await { + tracing::warn!(%error, table, "ANALYZE en échec"); + } + } + tracing::info!( + created, + present, + skipped, + failed = failed.len(), + seconds = started.elapsed().as_secs_f32(), + "index de performance vérifiés" + ); + Ok(()) +} diff --git a/server-rs/src/static_files.rs b/server-rs/src/static_files.rs new file mode 100644 index 0000000..5b0cfd9 --- /dev/null +++ b/server-rs/src/static_files.rs @@ -0,0 +1,294 @@ +// Fichiers de l'interface (dist/public), comme server/index.production.ts : +// - /assets/… : noms hachés par Vite, cache d'un an ; +// - autres fichiers publics : revalidés à chaque visite (index.html : no-cache) ; +// - toute autre adresse hors /api : index.html, que l'application React route. +// Mêmes en-têtes qu'Express (type, ETag faible taille-date, Last-Modified, 304). + +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, Utc}; + +use crate::{AppState, express_routes}; + +const ASSETS_CACHE: &str = "public, max-age=31536000, immutable"; +const DEFAULT_CACHE: &str = "public, max-age=0"; +const NO_CACHE: &str = "no-cache"; + +/// Tout ce que le routeur ne sert pas : interface, variante d'une route, ou 404 +pub async fn fallback(state: State, request: Request) -> Response { + let path = request.uri().path().to_string(); + if path.starts_with("/api/") || path == "/api" || !matches!(*request.method(), Method::GET | Method::HEAD) + { + // Adresse servie par Express malgré la casse ou une « / » finale + let request = match express_routes::redispatch(request).await { + Ok(response) => return response, + Err(request) => request, + }; + return not_found(request).await; + } + serve( + &state.config.static_dir, + &path, + request.method(), + request.headers(), + ) + .await +} + +async fn serve(root: &Path, path: &str, method: &Method, headers: &HeaderMap) -> Response { + if let Some(relative) = safe_relative(path) { + let is_asset = relative.starts_with("assets"); + let file = root.join(&relative); + if let Some(response) = send_file(&file, method, headers, |name| { + if is_asset { + ASSETS_CACHE + } else if name.ends_with(".html") { + NO_CACHE + } else { + DEFAULT_CACHE + } + }) + .await + { + return response; + } + } + // Retour à l'application : index.html, jamais mis en cache + send_file(&root.join("index.html"), method, headers, |_| NO_CACHE) + .await + .unwrap_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, "Error loading application").into_response()) +} + +/// Aucune route : 404 d'Express, avec l'adresse demandée avant toute réécriture +pub async fn not_found(request: Request) -> Response { + let path = request + .extensions() + .get::() + .map(|original| original.0.clone()) + .unwrap_or_else(|| request.uri().path().to_string()); + express_not_found(request.method(), &path) +} + +/// Réponse 404 d'Express (finalhandler) pour une adresse qu'aucune route ne sert : +/// « Cannot GET /chemin », sans la chaîne de requête, chemin encodé puis échappé +pub fn express_not_found(method: &Method, path: &str) -> Response { + let resource = escape_html(&encode_url(path)); + let body = format!( + "\n\n\n\nError\n\n\n
    Cannot {method} {resource}
    \n\n\n" + ); + let mut headers = HeaderMap::new(); + headers.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'"), + ); + headers.insert("x-content-type-options", HeaderValue::from_static("nosniff")); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + if path.starts_with("/api") { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-cache"), + ); + } + (StatusCode::NOT_FOUND, headers, body).into_response() +} + +/// Paquet encodeurl : encode ce qui n'est pas permis dans une adresse, sans +/// réencoder les séquences %XX déjà présentes +fn encode_url(path: &str) -> String { + let bytes = path.as_bytes(); + let mut out = String::with_capacity(path.len()); + for (i, &byte) in bytes.iter().enumerate() { + let allowed = byte.is_ascii_alphanumeric() || b"!#$&'()*+,-./:;=?@[]_~".contains(&byte); + let escape = byte == b'%' + && bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit) + && bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit); + if allowed || escape { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Paquet escape-html +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Chemin relatif sûr : décodé, sans « .. », sans fichier caché (comme express.static) +fn safe_relative(path: &str) -> Option { + let decoded = percent_decode(path)?; + let mut relative = PathBuf::new(); + for component in Path::new(&decoded).components() { + match component { + Component::RootDir => {} + Component::Normal(part) => { + if part.to_string_lossy().starts_with('.') { + return None; + } + relative.push(part); + } + _ => return None, + } + } + if relative.as_os_str().is_empty() { + None + } else { + Some(relative) + } +} + +fn percent_decode(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +async fn send_file( + file: &Path, + method: &Method, + request_headers: &HeaderMap, + cache_control: impl Fn(&str) -> &'static str, +) -> Option { + let metadata = tokio::fs::metadata(file).await.ok()?; + if !metadata.is_file() { + return None; + } + let name = file.file_name()?.to_string_lossy().to_string(); + let modified = metadata.modified().ok()?; + let mtime_ms = modified.duration_since(UNIX_EPOCH).ok()?.as_millis(); + let etag = format!("W/\"{:x}-{:x}\"", metadata.len(), mtime_ms); + let last_modified = DateTime::::from(modified) + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string(); + + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control(&name)), + ); + headers.insert(header::LAST_MODIFIED, HeaderValue::from_str(&last_modified).ok()?); + headers.insert(header::ETAG, HeaderValue::from_str(&etag).ok()?); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(&name)), + ); + + if is_fresh(request_headers, &etag, modified) { + return Some((StatusCode::NOT_MODIFIED, headers).into_response()); + } + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(metadata.len())); + if *method == Method::HEAD { + return Some((StatusCode::OK, headers).into_response()); + } + let content = tokio::fs::read(file).await.ok()?; + Some((StatusCode::OK, headers, Body::from(content)).into_response()) +} + +/// Même règle que le paquet « fresh » d'Express : If-None-Match, sinon If-Modified-Since +fn is_fresh(request: &HeaderMap, etag: &str, modified: std::time::SystemTime) -> bool { + let cache_control = request + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if cache_control.contains("no-cache") { + return false; + } + if let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) { + let bare = etag.trim_start_matches("W/"); + return none_match.trim() == "*" + || none_match + .split(',') + .any(|candidate| candidate.trim().trim_start_matches("W/") == bare); + } + if let Some(since) = request + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + { + if let Ok(since) = DateTime::parse_from_rfc2822(since) { + let modified = DateTime::::from(modified); + return modified.timestamp() <= since.timestamp(); + } + } + false +} + +/// Types renvoyés par express.static (paquet mime 1.x, avec charset pour texte, JS et JSON) +fn content_type(name: &str) -> &'static str { + match name.rsplit('.').next().unwrap_or("") { + "html" | "htm" => "text/html; charset=UTF-8", + "js" | "mjs" => "application/javascript; charset=UTF-8", + "css" => "text/css; charset=UTF-8", + "json" | "map" => "application/json; charset=UTF-8", + "txt" => "text/plain; charset=UTF-8", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "webmanifest" => "application/manifest+json", + "woff" => "font/woff", + "woff2" => "font/woff2", + "pdf" => "application/pdf", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuse_les_chemins_hors_du_dossier() { + assert_eq!(safe_relative("/../etc/passwd"), None); + assert_eq!(safe_relative("/assets/%2e%2e/x"), None); + assert_eq!(safe_relative("/.env"), None); + assert_eq!(safe_relative("/"), None); + assert_eq!( + safe_relative("/assets/app.js"), + Some(PathBuf::from("assets/app.js")) + ); + } + + #[test] + fn page_404_d_express() { + assert_eq!(encode_url("/api/x y<é>"), "/api/x%20y%3C%C3%A9%3E"); + assert_eq!(escape_html("/a&'b"), "/a&'b"); + assert_eq!(encode_url("/déjà%20là"), "/d%C3%A9j%C3%A0%20l%C3%A0"); + } + + #[test] + fn types_comme_express() { + assert_eq!( + content_type("index-abc.js"), + "application/javascript; charset=UTF-8" + ); + assert_eq!(content_type("favicon.ico"), "image/x-icon"); + } +} diff --git a/server-rs/src/zod.rs b/server-rs/src/zod.rs new file mode 100644 index 0000000..9b617bc --- /dev/null +++ b/server-rs/src/zod.rs @@ -0,0 +1,693 @@ +// Validation des corps de requête, au comportement de zod 3 (schémas de +// shared/schema.ts) : mêmes contrôles dans le même ordre, mêmes erreurs au même +// format ({ code, expected, received, path, message }), champs inconnus retirés. +// L'interface affiche ces erreurs ; les reproduire évite tout écart visible. + +use serde_json::{Map, Value, json}; + +use crate::js; + +const INT32_MIN: f64 = -2_147_483_648.0; +const INT32_MAX: f64 = 2_147_483_647.0; +const SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + +// Toute l'API de zod utilisée par shared/schema.ts n'est pas encore appelée par +// un module : elle reste disponible (et testée) pour les schémas à venir. +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub enum Kind { + String, + Number, + /// z.coerce.number() : Number(valeur) + CoerceNumber, + Boolean, + /// z.date() : jamais satisfait par du JSON + Date, + /// z.coerce.date() : new Date(valeur), rendu en chaîne ISO + CoerceDate, + Enum(&'static [&'static str]), + /// N'importe quelle valeur JSON (colonnes jsonb, z.any()) + Json, + /// z.union([…]) : options essayées dans l'ordre (leurs noms et leurs + /// .optional() / .nullable() ne servent pas : z.null() s'écrit Field::null) + Union(Vec), + /// z.literal(valeur) + Literal(Value), + /// z.null() + Null, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +enum Check { + MinLen(usize), + MaxLen(usize), + Email, + Min(f64, bool), + Max(f64, bool), + Int, + /// .min(n, "message") d'une chaîne + MinLenMessage(usize, String), +} + +#[derive(Clone, Debug)] +pub struct Field { + name: String, + kind: Kind, + checks: Vec, + optional: bool, + nullable: bool, + default: Option, +} + +#[allow(dead_code)] +impl Field { + pub fn new(name: &str, kind: Kind) -> Self { + Self { + name: name.to_string(), + kind, + checks: Vec::new(), + optional: false, + nullable: false, + default: None, + } + } + pub fn string(name: &str) -> Self { + Self::new(name, Kind::String) + } + pub fn number(name: &str) -> Self { + Self::new(name, Kind::Number) + } + pub fn coerce_number(name: &str) -> Self { + Self::new(name, Kind::CoerceNumber) + } + pub fn boolean(name: &str) -> Self { + Self::new(name, Kind::Boolean) + } + pub fn date(name: &str) -> Self { + Self::new(name, Kind::Date) + } + pub fn coerce_date(name: &str) -> Self { + Self::new(name, Kind::CoerceDate) + } + pub fn json(name: &str) -> Self { + Self::new(name, Kind::Json) + } + pub fn union(name: &str, options: Vec) -> Self { + Self::new(name, Kind::Union(options)) + } + pub fn literal(name: &str, value: Value) -> Self { + Self::new(name, Kind::Literal(value)) + } + pub fn null(name: &str) -> Self { + Self::new(name, Kind::Null) + } + pub fn enumeration(name: &str, values: &'static [&'static str]) -> Self { + Self::new(name, Kind::Enum(values)) + } + /// Colonne integer / serial pour drizzle-zod : number().min().max().int() + pub fn int32(name: &str) -> Self { + Self::number(name).min(INT32_MIN).max(INT32_MAX).int() + } + /// Colonne bigint (mode number) pour drizzle-zod + pub fn safe_integer(name: &str) -> Self { + Self::number(name).min(-SAFE_INTEGER).max(SAFE_INTEGER).int() + } + + pub fn min_len(mut self, length: usize) -> Self { + self.checks.push(Check::MinLen(length)); + self + } + /// .min(n, "message") : message choisi au lieu de celui de zod + pub fn min_len_message(mut self, length: usize, message: &str) -> Self { + self.checks + .push(Check::MinLenMessage(length, message.to_string())); + self + } + pub fn max_len(mut self, length: usize) -> Self { + self.checks.push(Check::MaxLen(length)); + self + } + pub fn email(mut self) -> Self { + self.checks.push(Check::Email); + self + } + pub fn min(mut self, value: f64) -> Self { + self.checks.push(Check::Min(value, true)); + self + } + pub fn max(mut self, value: f64) -> Self { + self.checks.push(Check::Max(value, true)); + self + } + pub fn positive(mut self) -> Self { + self.checks.push(Check::Min(0.0, false)); + self + } + pub fn int(mut self) -> Self { + self.checks.push(Check::Int); + self + } + pub fn optional(mut self) -> Self { + self.optional = true; + self + } + pub fn nullable(mut self) -> Self { + self.nullable = true; + self + } + /// .default(valeur) : appliquée quand le champ est absent + pub fn default(mut self, value: Value) -> Self { + self.default = Some(value); + self + } +} + +#[derive(Clone, Debug)] +pub struct Object { + fields: Vec, +} + +/// Corps validé : seuls les champs du schéma, avec leurs valeurs converties +pub type Parsed = Map; + +#[allow(dead_code)] +impl Object { + pub fn new(fields: Vec) -> Self { + Self { fields } + } + + /// .omit({ … }) + pub fn omit(mut self, names: &[&str]) -> Self { + self.fields.retain(|field| !names.contains(&field.name.as_str())); + self + } + + /// .pick({ … }) : l'ordre reste celui du schéma + pub fn pick(mut self, names: &[&str]) -> Self { + self.fields.retain(|field| names.contains(&field.name.as_str())); + self + } + + /// .extend({ … }) : un champ existant garde sa place, un nouveau va à la fin + pub fn extend(mut self, fields: Vec) -> Self { + for field in fields { + match self + .fields + .iter_mut() + .find(|existing| existing.name == field.name) + { + Some(existing) => *existing = field, + None => self.fields.push(field), + } + } + self + } + + /// .partial() : tout devient facultatif, et les valeurs par défaut ne s'appliquent plus + pub fn partial(mut self) -> Self { + for field in &mut self.fields { + field.optional = true; + field.default = None; + } + self + } + + pub fn parse(&self, input: &Value) -> Result> { + let Some(object) = input.as_object() else { + return Err(vec![invalid_type("object", input, &[])]); + }; + let mut issues = Vec::new(); + let mut output = Map::new(); + for field in &self.fields { + let path = [Value::String(field.name.clone())]; + let value = match object.get(&field.name) { + None => match &field.default { + Some(default) => default.clone(), + None if field.optional => continue, + None => { + issues.push(json!({ + "code": "invalid_type", + "expected": expected_name(&field.kind), + "received": "undefined", + "path": path, + "message": "Required", + })); + continue; + } + }, + Some(value) => value.clone(), + }; + if value.is_null() && field.nullable { + output.insert(field.name.clone(), Value::Null); + continue; + } + match check_value(field, &value, &path) { + Ok(parsed) => { + output.insert(field.name.clone(), parsed); + } + Err(mut field_issues) => issues.append(&mut field_issues), + } + } + if issues.is_empty() { + Ok(output) + } else { + Err(issues) + } + } +} + +fn check_value(field: &Field, value: &Value, path: &[Value]) -> Result> { + let parsed = match &field.kind { + Kind::String => match value { + Value::String(_) => value.clone(), + _ => return Err(vec![invalid_type("string", value, path)]), + }, + Kind::Number => match value { + Value::Number(_) => value.clone(), + _ => return Err(vec![invalid_type("number", value, path)]), + }, + Kind::CoerceNumber => { + let number = js::to_number(value); + if number.is_nan() { + return Err(vec![issue_nan(path)]); + } + js::number_value(number) + } + Kind::Boolean => match value { + Value::Bool(_) => value.clone(), + _ => return Err(vec![invalid_type("boolean", value, path)]), + }, + Kind::Date => return Err(vec![invalid_type("date", value, path)]), + Kind::CoerceDate => match js::new_date(value) { + Some(date) => Value::String(js::iso(&date)), + None => { + return Err(vec![ + json!({ "code": "invalid_date", "path": path, "message": "Invalid date" }), + ]); + } + }, + Kind::Enum(options) => match value { + Value::String(text) if options.contains(&text.as_str()) => value.clone(), + Value::String(text) => { + return Err(vec![json!({ + "received": text, + "code": "invalid_enum_value", + "options": options, + "path": path, + "message": format!("Invalid enum value. Expected {}, received '{}'", join_values(options), text), + })]); + } + _ => return Err(vec![invalid_type(&join_values(options), value, path)]), + }, + Kind::Json => value.clone(), + Kind::Union(options) => return check_union(options, value, path), + Kind::Literal(expected) => { + if value != expected { + return Err(vec![json!({ + "received": value, + "code": "invalid_literal", + "expected": expected, + "path": path, + "message": format!("Invalid literal value, expected {expected}"), + })]); + } + value.clone() + } + Kind::Null => match value { + Value::Null => Value::Null, + _ => return Err(vec![invalid_type("null", value, path)]), + }, + }; + + let mut issues = Vec::new(); + for check in &field.checks { + match (check, &parsed) { + (Check::MinLen(min), Value::String(text)) => { + if js::length(text) < *min { + issues.push(json!({ + "code": "too_small", "minimum": min, "type": "string", "inclusive": true, "exact": false, + "message": format!("String must contain at least {min} character(s)"), "path": path, + })); + } + } + (Check::MaxLen(max), Value::String(text)) => { + if js::length(text) > *max { + issues.push(json!({ + "code": "too_big", "maximum": max, "type": "string", "inclusive": true, "exact": false, + "message": format!("String must contain at most {max} character(s)"), "path": path, + })); + } + } + (Check::Email, Value::String(text)) => { + if !is_email(text) { + issues.push(json!({ + "validation": "email", "code": "invalid_string", "message": "Invalid email", "path": path, + })); + } + } + (Check::Min(min, inclusive), Value::Number(number)) => { + let number = number.as_f64().unwrap_or(f64::NAN); + let too_small = if *inclusive { number < *min } else { number <= *min }; + if too_small { + let minimum = js::number_value(*min); + let message = if *inclusive { + format!( + "Number must be greater than or equal to {}", + js::number_to_string(*min) + ) + } else { + format!("Number must be greater than {}", js::number_to_string(*min)) + }; + issues.push(json!({ + "code": "too_small", "minimum": minimum, "type": "number", "inclusive": inclusive, + "exact": false, "message": message, "path": path, + })); + } + } + (Check::Max(max, inclusive), Value::Number(number)) => { + let number = number.as_f64().unwrap_or(f64::NAN); + let too_big = if *inclusive { number > *max } else { number >= *max }; + if too_big { + let maximum = js::number_value(*max); + let message = if *inclusive { + format!( + "Number must be less than or equal to {}", + js::number_to_string(*max) + ) + } else { + format!("Number must be less than {}", js::number_to_string(*max)) + }; + issues.push(json!({ + "code": "too_big", "maximum": maximum, "type": "number", "inclusive": inclusive, + "exact": false, "message": message, "path": path, + })); + } + } + (Check::Int, Value::Number(number)) => { + let number = number.as_f64().unwrap_or(f64::NAN); + if number.fract() != 0.0 { + issues.push(json!({ + "code": "invalid_type", "expected": "integer", "received": "float", + "message": "Expected integer, received float", "path": path, + })); + } + } + (Check::MinLenMessage(min, message), Value::String(text)) if js::length(text) < *min => { + issues.push(json!({ + "code": "too_small", "minimum": min, "type": "string", "inclusive": true, "exact": false, + "message": message, "path": path, + })); + } + _ => {} + } + } + if issues.is_empty() { + Ok(parsed) + } else { + Err(issues) + } +} + +fn expected_name(kind: &Kind) -> String { + match kind { + Kind::String => "string".into(), + Kind::Number | Kind::CoerceNumber => "number".into(), + Kind::Boolean => "boolean".into(), + Kind::Date | Kind::CoerceDate => "date".into(), + Kind::Enum(options) => join_values(options), + Kind::Json => "unknown".into(), + // Champ union ou littéral obligatoire et absent : aucun schéma porté n'en + // a (zod rendrait invalid_union / invalid_literal) + Kind::Union(_) => "union".into(), + Kind::Literal(value) => value.to_string(), + Kind::Null => "null".into(), + } +} + +/// ZodUnion : la valeur de la première option valide ; sinon les erreurs de la +/// première option du bon type dont un contrôle échoue (résultat « dirty » de +/// zod) ; sinon invalid_union avec les erreurs de chaque option (ZodError +/// sérialisée : { issues, name }) +fn check_union(options: &[Field], value: &Value, path: &[Value]) -> Result> { + let mut dirty: Option> = None; + let mut union_errors = Vec::new(); + for option in options { + match check_value(option, value, path) { + Ok(parsed) => return Ok(parsed), + Err(issues) => { + if dirty.is_none() && type_matches(&option.kind, value) { + dirty = Some(issues.clone()); + } + union_errors.push(json!({ "issues": issues, "name": "ZodError" })); + } + } + } + if let Some(issues) = dirty { + return Err(issues); + } + Err(vec![json!({ + "code": "invalid_union", + "unionErrors": union_errors, + "path": path, + "message": "Invalid input", + })]) +} + +/// La valeur a-t-elle le type de l'option (seuls ses contrôles peuvent alors échouer) ? +fn type_matches(kind: &Kind, value: &Value) -> bool { + match kind { + Kind::String => value.is_string(), + Kind::Number => value.is_number(), + Kind::CoerceNumber => !js::to_number(value).is_nan(), + Kind::Boolean => value.is_boolean(), + Kind::Date => false, + Kind::CoerceDate => js::new_date(value).is_some(), + Kind::Enum(options) => value.as_str().is_some_and(|text| options.contains(&text)), + Kind::Json => true, + Kind::Union(options) => options.iter().any(|option| type_matches(&option.kind, value)), + Kind::Literal(expected) => value == expected, + Kind::Null => value.is_null(), + } +} + +/// Type reçu selon zod (getParsedType) +fn received_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn invalid_type(expected: &str, value: &Value, path: &[Value]) -> Value { + let received = received_name(value); + json!({ + "code": "invalid_type", + "expected": expected, + "received": received, + "path": path, + "message": format!("Expected {expected}, received {received}"), + }) +} + +fn issue_nan(path: &[Value]) -> Value { + json!({ + "code": "invalid_type", + "expected": "number", + "received": "nan", + "path": path, + "message": "Expected number, received nan", + }) +} + +/// util.joinValues de zod : 'a' | 'b' +fn join_values(options: &[&str]) -> String { + options + .iter() + .map(|option| format!("'{option}'")) + .collect::>() + .join(" | ") +} + +/// Expression régulière des courriels de zod 3.25, écrite à la main +/// (/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i) +fn is_email(text: &str) -> bool { + if text.starts_with('.') || text.contains("..") { + return false; + } + let Some((local, domain)) = text.split_once('@') else { + return false; + }; + if domain.contains('@') { + return false; + } + let local_ok = local + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_'+-.".contains(c)) + && local + .chars() + .last() + .is_some_and(|c| c.is_ascii_alphanumeric() || "_+-".contains(c)); + let labels: Vec<&str> = domain.split('.').collect(); + if labels.len() < 2 { + return false; + } + let (tld, rest) = labels.split_last().unwrap(); + let rest_ok = rest.iter().all(|label| { + label.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()) + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + }); + local_ok && rest_ok && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contact_schema() -> Object { + Object::new(vec![ + Field::int32("id").optional(), + Field::int32("groupId"), + Field::string("name").max_len(255), + Field::string("email").max_len(255).nullable().optional(), + ]) + .omit(&["id"]) + } + + #[test] + fn champ_obligatoire_absent() { + let issues = contact_schema().parse(&json!({ "groupId": 1 })).unwrap_err(); + assert_eq!( + issues, + vec![json!({ + "code": "invalid_type", "expected": "string", "received": "undefined", + "path": ["name"], "message": "Required", + })] + ); + } + + #[test] + fn champs_inconnus_retires_et_null_accepte() { + let parsed = contact_schema() + .parse(&json!({ "groupId": 2, "name": "Metz", "email": null, "autre": 1 })) + .unwrap(); + assert_eq!( + Value::Object(parsed), + json!({ "groupId": 2, "name": "Metz", "email": null }) + ); + } + + #[test] + fn entier_hors_limites_et_decimal() { + let issues = contact_schema() + .parse(&json!({ "groupId": 2.5, "name": "x" })) + .unwrap_err(); + assert_eq!(issues[0]["message"], "Expected integer, received float"); + let issues = contact_schema() + .parse(&json!({ "groupId": "1", "name": 3 })) + .unwrap_err(); + assert_eq!(issues[0]["message"], "Expected number, received string"); + assert_eq!(issues[1]["message"], "Expected string, received number"); + } + + #[test] + fn partiel_sans_valeur_par_defaut() { + let schema = Object::new(vec![ + Field::coerce_number("quantity") + .int() + .positive() + .default(json!(1)), + ]); + assert_eq!( + Value::Object(schema.clone().parse(&json!({})).unwrap()), + json!({ "quantity": 1 }) + ); + assert_eq!( + Value::Object(schema.partial().parse(&json!({})).unwrap()), + json!({}) + ); + } + + #[test] + fn courriels() { + assert!(is_email("paul@lactalis.test")); + assert!(!is_email("paul@lactalis")); + assert!(!is_email(".paul@x.fr")); + assert!(!is_email("pa..ul@x.fr")); + } + + // z.union([z.string().email(), z.literal(""), z.null()]).optional() + fn email_union() -> Object { + Object::new(vec![ + Field::union( + "email", + vec![ + Field::string("").email(), + Field::literal("", json!("")), + Field::null(""), + ], + ) + .optional(), + ]) + } + + #[test] + fn union_premiere_option_valide() { + for value in [json!("a@b.fr"), json!(""), json!(null)] { + let parsed = email_union().parse(&json!({ "email": value })).unwrap(); + assert_eq!(parsed["email"], value); + } + assert!(email_union().parse(&json!({})).unwrap().is_empty()); + } + + #[test] + fn union_option_du_bon_type_en_erreur() { + // Le courriel invalide est une chaîne : seule l'erreur de format remonte + let issues = email_union() + .parse(&json!({ "email": "pas-un-courriel" })) + .unwrap_err(); + assert_eq!( + issues, + vec![ + json!({ "validation": "email", "code": "invalid_string", "message": "Invalid email", "path": ["email"] }) + ] + ); + } + + #[test] + fn union_sans_option_possible() { + // Valeur rendue par Node (POST /api/users, { "email": 5 }) + let issues = email_union().parse(&json!({ "email": 5 })).unwrap_err(); + let node: Value = serde_json::from_str( + r#"[{"code":"invalid_union","unionErrors":[{"issues":[{"code":"invalid_type","expected":"string","received":"number","path":["email"],"message":"Expected string, received number"}],"name":"ZodError"},{"issues":[{"received":5,"code":"invalid_literal","expected":"","path":["email"],"message":"Invalid literal value, expected \"\""}],"name":"ZodError"},{"issues":[{"code":"invalid_type","expected":"null","received":"number","path":["email"],"message":"Expected null, received number"}],"name":"ZodError"}],"path":["email"],"message":"Invalid input"}]"#, + ) + .unwrap(); + assert_eq!(serde_json::to_string(&issues).unwrap(), node.to_string()); + } + + #[test] + fn longueur_minimale_avec_message() { + let schema = Object::new(vec![ + Field::string("username").min_len_message(1, "L'identifiant est obligatoire"), + ]); + let issues = schema.parse(&json!({ "username": "" })).unwrap_err(); + assert_eq!( + serde_json::to_string(&issues).unwrap(), + r#"[{"code":"too_small","minimum":1,"type":"string","inclusive":true,"exact":false,"message":"L'identifiant est obligatoire","path":["username"]}]"# + ); + } + + #[test] + fn enumeration() { + let schema = Object::new(vec![Field::enumeration("status", &["nouveau", "resolu"])]); + let issues = schema.parse(&json!({ "status": "x" })).unwrap_err(); + assert_eq!( + issues[0]["message"], + "Invalid enum value. Expected 'nouveau' | 'resolu', received 'x'" + ); + } +} diff --git a/server/announcementStorage.ts b/server/announcementStorage.ts deleted file mode 100644 index 007503d..0000000 --- a/server/announcementStorage.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { DashboardMessage, InsertDashboardMessage, DashboardMessageWithRelations, User, Group } from "@shared/schema"; -import { db } from "./db"; -import { dashboardMessages, users, groups } from "@shared/schema"; -import { eq, desc } from "drizzle-orm"; - -// Type aliases pour compatibilité -type Announcement = DashboardMessage; -type InsertAnnouncement = InsertDashboardMessage; -type AnnouncementWithRelations = DashboardMessageWithRelations; - -// Interface commune pour le stockage -interface IAnnouncementStorage { - getAnnouncements(groupIds?: number[]): Promise; - getAnnouncement(id: number): Promise; - createAnnouncement(announcement: InsertAnnouncement): Promise; - updateAnnouncement(id: number, announcement: Partial): Promise; - deleteAnnouncement(id: number): Promise; -} - -// Stockage PostgreSQL pour la production -class AnnouncementDatabaseStorage implements IAnnouncementStorage { - async getAnnouncements(groupIds?: number[]): Promise { - let query = db - .select({ - id: dashboardMessages.id, - title: dashboardMessages.title, - content: dashboardMessages.content, - type: dashboardMessages.type, - storeId: dashboardMessages.storeId, - createdBy: dashboardMessages.createdBy, - createdAt: dashboardMessages.createdAt, - author: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - }, - group: { - id: groups.id, - name: groups.name, - }, - }) - .from(dashboardMessages) - .leftJoin(users, eq(dashboardMessages.createdBy, users.username)) - .leftJoin(groups, eq(dashboardMessages.storeId, groups.id)) - .orderBy(desc(dashboardMessages.createdAt)); - - const results = await query; - - return results.map((result: any) => ({ - id: result.id, - title: result.title, - content: result.content, - type: result.type, - storeId: result.storeId, - createdBy: result.createdBy, - createdAt: result.createdAt, - author: result.author as User, - group: result.group as Group | undefined, - })); - } - - async getAnnouncement(id: number): Promise { - console.log('🔍 [DB] Getting single announcement:', id); - - const result = await db - .select({ - id: dashboardMessages.id, - title: dashboardMessages.title, - content: dashboardMessages.content, - type: dashboardMessages.type, - storeId: dashboardMessages.storeId, - createdBy: dashboardMessages.createdBy, - createdAt: dashboardMessages.createdAt, - author: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - }, - group: { - id: groups.id, - name: groups.name, - }, - }) - .from(dashboardMessages) - .leftJoin(users, eq(dashboardMessages.createdBy, users.username)) - .leftJoin(groups, eq(dashboardMessages.storeId, groups.id)) - .where(eq(dashboardMessages.id, id)) - .limit(1); - - if (result.length === 0) { - console.log('❌ [DB] Announcement not found:', id); - return null; - } - - const announcement = result[0]; - console.log('✅ [DB] Found announcement:', announcement.id, announcement.title); - - return { - id: announcement.id, - title: announcement.title, - content: announcement.content, - type: announcement.type, - storeId: announcement.storeId, - createdBy: announcement.createdBy, - createdAt: announcement.createdAt, - author: announcement.author as User, - group: announcement.group as Group | undefined, - }; - } - - async createAnnouncement(announcement: InsertAnnouncement): Promise { - const [created] = await db - .insert(dashboardMessages) - .values(announcement) - .returning(); - return created; - } - - async updateAnnouncement(id: number, announcement: Partial): Promise { - console.log('📝 [DB] Updating announcement with data:', announcement); - - const [updated] = await db - .update(dashboardMessages) - .set(announcement) - .where(eq(dashboardMessages.id, id)) - .returning(); - - console.log('📝 [DB] Updated announcement:', updated); - - if (!updated) { - throw new Error(`Announcement with id ${id} not found`); - } - - return updated; - } - - async deleteAnnouncement(id: number): Promise { - const deleted = await db - .delete(dashboardMessages) - .where(eq(dashboardMessages.id, id)) - .returning({ id: dashboardMessages.id }); - return deleted.length > 0; - } -} - -// Stockage en mémoire pour le développement -class AnnouncementMemoryStorage implements IAnnouncementStorage { - private announcements = new Map(); - private idCounter = 1; - private readonly MAX_ANNOUNCEMENTS = 5; - - // Référence vers les utilisateurs et groupes pour les relations - private usersGetter: () => Promise; - private groupsGetter: () => Promise; - - constructor(usersGetter: () => Promise, groupsGetter: () => Promise) { - this.usersGetter = usersGetter; - this.groupsGetter = groupsGetter; - this.initializeTestData(); - } - - private initializeTestData() { - // Créer quelques annonces de test avec la nouvelle structure DASHBOARD_MESSAGES - const testAnnouncement: Announcement = { - id: this.idCounter++, - title: "Bienvenue dans le système d'informations", - content: "Ce nouveau système remplace l'ancienne carte des commandes clients. Vous pouvez maintenant créer et gérer des annonces importantes.", - type: 'warning', - createdBy: 'admin', - storeId: 1, - createdAt: new Date(), - }; - this.announcements.set(testAnnouncement.id, testAnnouncement); - - const testAnnouncement2: Announcement = { - id: this.idCounter++, - title: "Maintenance programmée", - content: "Une maintenance système aura lieu dimanche prochain de 2h à 4h du matin.", - type: 'info', - createdBy: 'admin', - storeId: null, // Annonce globale - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // Hier - }; - this.announcements.set(testAnnouncement2.id, testAnnouncement2); - } - - async getAnnouncements(groupIds?: number[]): Promise { - const announcements = Array.from(this.announcements.values()); - - let filteredAnnouncements = announcements; - - // Filtrer par groupIds si fourni (pour le filtrage admin par magasin) - if (groupIds && groupIds.length > 0) { - filteredAnnouncements = announcements.filter(announcement => - !announcement.storeId || groupIds.includes(announcement.storeId) - ); - } - - // Récupérer les utilisateurs et groupes pour les relations - const [users, groups] = await Promise.all([ - this.usersGetter(), - this.groupsGetter() - ]); - - const usersMap = new Map(users.map(u => [u.id, u])); - const groupsMap = new Map(groups.map(g => [g.id, g])); - - // Ajouter les relations - const announcementsWithRelations = filteredAnnouncements.map(announcement => { - const author = usersMap.get(announcement.createdBy); - const group = announcement.storeId ? groupsMap.get(announcement.storeId) : undefined; - - return { - ...announcement, - author: author!, - group, - }; - }).filter(a => a.author); // Filtrer celles sans auteur valide - - // Trier par type (error > warning > success > info) puis par date (plus récent d'abord) - return announcementsWithRelations.sort((a, b) => { - const typeOrder = { error: 4, warning: 3, success: 2, info: 1 }; - const typeDiff = (typeOrder[b.type as keyof typeof typeOrder] || 1) - - (typeOrder[a.type as keyof typeof typeOrder] || 1); - if (typeDiff !== 0) return typeDiff; - return new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime(); - }); - } - - async createAnnouncement(announcementData: InsertAnnouncement): Promise { - // Vérifier la limite de 5 annonces maximum - if (this.announcements.size >= this.MAX_ANNOUNCEMENTS) { - // Supprimer l'annonce la plus ancienne (par ordre de création) - const oldestAnnouncement = Array.from(this.announcements.values()) - .sort((a, b) => new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime())[0]; - - if (oldestAnnouncement) { - this.announcements.delete(oldestAnnouncement.id); - console.log(`🗑️ Annonce supprimée automatiquement (limite de ${this.MAX_ANNOUNCEMENTS}): ${oldestAnnouncement.title}`); - } - } - - const id = this.idCounter++; - const announcement: Announcement = { - id, - ...announcementData, - type: announcementData.type || 'info', - storeId: announcementData.storeId || null, - createdAt: new Date(), - }; - - this.announcements.set(id, announcement); - console.log(`✅ Nouvelle annonce créée: ${announcement.title} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS})`); - - return announcement; - } - - async getAnnouncement(id: number): Promise { - const announcement = this.announcements.get(id); - if (!announcement) { - return undefined; - } - - // Récupérer les utilisateurs et groupes pour les relations - const [users, groups] = await Promise.all([ - this.usersGetter(), - this.groupsGetter() - ]); - - const usersMap = new Map(users.map(u => [u.id, u])); - const groupsMap = new Map(groups.map(g => [g.id, g])); - - const author = usersMap.get(announcement.createdBy); - const group = announcement.storeId ? groupsMap.get(announcement.storeId) : undefined; - - if (!author) { - return undefined; - } - - return { - ...announcement, - author, - group, - }; - } - - async updateAnnouncement(id: number, announcementData: Partial): Promise { - const existingAnnouncement = this.announcements.get(id); - if (!existingAnnouncement) { - throw new Error(`Announcement with ID ${id} not found`); - } - - const updatedAnnouncement: Announcement = { - ...existingAnnouncement, - ...announcementData, - id, // Ensure ID doesn't change - // Note: DASHBOARD_MESSAGES table n'a pas de champ updatedAt - }; - - this.announcements.set(id, updatedAnnouncement); - console.log(`✏️ Annonce mise à jour: ${updatedAnnouncement.title} (ID: ${id})`); - - // Return the announcement with relations - const announcementWithRelations = await this.getAnnouncement(id); - if (!announcementWithRelations) { - throw new Error(`Failed to get updated announcement with relations`); - } - - return announcementWithRelations; - } - - async deleteAnnouncement(id: number): Promise { - const deleted = this.announcements.delete(id); - - if (deleted) { - console.log(`🗑️ Annonce supprimée: ID ${id} (${this.announcements.size}/${this.MAX_ANNOUNCEMENTS} restantes)`); - } - return deleted; - } -} - -// Instances globales pour les différents environnements -let memoryInstance: AnnouncementMemoryStorage | null = null; -let databaseInstance: AnnouncementDatabaseStorage | null = null; - -export function getAnnouncementStorage( - usersGetter?: () => Promise, - groupsGetter?: () => Promise -): IAnnouncementStorage { - const isProduction = process.env.NODE_ENV === 'production'; - - if (isProduction) { - if (!databaseInstance) { - databaseInstance = new AnnouncementDatabaseStorage(); - console.log('📢 Stockage PostgreSQL des annonces initialisé pour la production'); - } - return databaseInstance; - } else { - if (!memoryInstance) { - if (!usersGetter || !groupsGetter) { - throw new Error("usersGetter and groupsGetter are required for memory storage initialization"); - } - memoryInstance = new AnnouncementMemoryStorage(usersGetter, groupsGetter); - console.log('📢 Stockage mémoire des annonces initialisé pour le développement'); - } - return memoryInstance; - } -} \ No newline at end of file diff --git a/server/backupService.ts b/server/backupService.ts deleted file mode 100644 index f02838a..0000000 --- a/server/backupService.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -import fs from 'fs'; -import path from 'path'; -import { nanoid } from 'nanoid'; -import { eq, desc, sql } from "drizzle-orm"; -import { db } from "./db"; -import { databaseBackups, utilities, users } from "@shared/schema"; -import type { DatabaseBackup, InsertDatabaseBackup } from "@shared/schema"; - -const execAsync = promisify(exec); - -export class BackupService { - private backupDir: string; - private maxBackups = 10; - private lastAutomaticBackupDate: string | null = null; - // Vérification quotidienne en cours, partagée par les connexions simultanées - private dailyBackupCheck: Promise<{ backupPerformed: boolean; message: string }> | null = null; - - constructor() { - // Use /app/backups in production (with proper permissions), or use env variable - const isProduction = process.env.NODE_ENV === 'production'; - this.backupDir = isProduction - ? process.env.BACKUP_DIR || '/app/backups' - : path.join(process.cwd(), 'backups'); - - this.ensureBackupDirectory(); - this.scheduleAutomaticBackup(); - } - - private ensureBackupDirectory(): void { - try { - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }); - console.log('📁 Backup directory created:', this.backupDir); - } - } catch (error) { - console.error('❌ Failed to create backup directory:', error); - // Fallback to /tmp if current directory fails - if (this.backupDir !== '/tmp/backups') { - this.backupDir = '/tmp/backups'; - console.log('🔄 Falling back to /tmp/backups'); - try { - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }); - console.log('📁 Fallback backup directory created:', this.backupDir); - } - } catch (fallbackError) { - console.error('❌ Failed to create fallback directory:', fallbackError); - throw fallbackError; - } - } else { - throw error; - } - } - } - - private getDatabaseConfig() { - const dbUrl = process.env.DATABASE_URL; - if (!dbUrl) { - throw new Error('DATABASE_URL not found in environment variables'); - } - - // Parse PostgreSQL URL - const url = new URL(dbUrl); - return { - host: url.hostname, - port: url.port || '5432', - database: url.pathname.slice(1), // Remove leading slash - username: url.username, - password: url.password, - }; - } - - /** - * Resolve a valid user ID for system-initiated backups. - * Returns the first admin user ID, or the first user if no admin exists. - * This avoids FK violations on database_backups.created_by -> users.id. - */ - private async resolveSystemUserId(): Promise { - try { - // Try to find an admin user first - const [adminUser] = await db.select({ id: users.id }) - .from(users) - .where(eq(users.role, 'admin')) - .limit(1); - if (adminUser) return adminUser.id; - - // Fallback: any existing user - const [anyUser] = await db.select({ id: users.id }) - .from(users) - .limit(1); - return anyUser?.id ?? null; - } catch (error) { - console.error('❌ Failed to resolve system user ID for backup:', error); - return null; - } - } - - async createBackup(type: 'manual' | 'automatic' = 'manual', createdBy: string = 'system'): Promise { - try { - const id = nanoid(); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const filename = `backup_${type}_${timestamp}.sql`; - const filepath = path.join(this.backupDir, filename); - - // Create database record first with "creating" status - const [backupRecord] = await db.insert(databaseBackups).values({ - id, - filename, - description: `${type === 'manual' ? 'Manuel' : 'Automatique'} backup du ${new Date().toLocaleDateString('fr-FR')}`, - size: 0, - createdBy, - tablesCount: 0, - status: 'creating', - backupType: type, - }).returning(); - - const dbConfig = this.getDatabaseConfig(); - - // Set environment variable for password to avoid prompt - const env = { - ...process.env, - PGPASSWORD: dbConfig.password, - }; - - // Create pg_dump command - const command = `pg_dump -h ${dbConfig.host} -p ${dbConfig.port} -U ${dbConfig.username} -d ${dbConfig.database} --no-password --verbose --clean --if-exists --create > "${filepath}"`; - - console.log(`🔄 Starting ${type} backup...`); - - await execAsync(command, { env }); - - // Get file stats and count tables - // Tables comptées en base plutôt qu'en relisant tout le dump en mémoire : - // la lecture synchrone et la regex gelaient le serveur sur les grosses bases - const stats = await fs.promises.stat(filepath); - const tablesCount = await this.countDatabaseTables(); - - // Update database record with completion details - const [updatedBackup] = await db.update(databaseBackups) - .set({ - size: stats.size, - tablesCount, - status: 'completed', - }) - .where(eq(databaseBackups.id, id)) - .returning(); - - console.log(`✅ ${type} backup created: ${filename} (${this.formatFileSize(stats.size)}, ${tablesCount} tables)`); - - // Clean old backups - await this.cleanOldBackups(); - - return updatedBackup; - } catch (error) { - console.error('❌ Backup failed:', error); - throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - // Nombre de tables utilisateur (hors schémas système), proche de ce que - // pg_dump inclut dans la sauvegarde - private async countDatabaseTables(): Promise { - const result = await db.execute(sql` - SELECT count(*)::int AS count - FROM information_schema.tables - WHERE table_type = 'BASE TABLE' - AND table_schema NOT IN ('pg_catalog', 'information_schema') - `); - return Number(result.rows[0]?.count ?? 0); - } - - async getBackupList(): Promise { - try { - const backups = await db.select() - .from(databaseBackups) - .orderBy(desc(databaseBackups.createdAt)) - .limit(this.maxBackups); - - return backups; - } catch (error) { - console.error('❌ Failed to list backups:', error); - return []; - } - } - - async deleteBackup(filename: string): Promise { - try { - // Find backup record - const [backup] = await db.select() - .from(databaseBackups) - .where(eq(databaseBackups.filename, filename)) - .limit(1); - - if (!backup) { - throw new Error('Backup record not found'); - } - - // Delete physical file - const filepath = path.join(this.backupDir, filename); - if (fs.existsSync(filepath)) { - fs.unlinkSync(filepath); - } - - // Delete database record - await db.delete(databaseBackups) - .where(eq(databaseBackups.filename, filename)); - - console.log(`🗑️ Backup deleted: ${filename}`); - } catch (error) { - console.error('❌ Failed to delete backup:', error); - throw error; - } - } - - async downloadBackup(filename: string): Promise { - const filepath = path.join(this.backupDir, filename); - - if (!fs.existsSync(filepath)) { - throw new Error('Backup file not found'); - } - - return filepath; - } - - private async cleanOldBackups(): Promise { - try { - const allBackups = await db.select() - .from(databaseBackups) - .orderBy(desc(databaseBackups.createdAt)); - - if (allBackups.length > this.maxBackups) { - const toDelete = allBackups.slice(this.maxBackups); - - for (const backup of toDelete) { - await this.deleteBackup(backup.filename); - } - - console.log(`🧹 Cleaned ${toDelete.length} old backup(s)`); - } - } catch (error) { - console.error('❌ Failed to clean old backups:', error); - } - } - - private formatFileSize(bytes: number): string { - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - if (bytes === 0) return '0 Bytes'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; - } - - // Nouvelle méthode : Vérifier et effectuer une sauvegarde quotidienne si nécessaire - async checkAndPerformDailyBackup(userId: string = 'system'): Promise<{ backupPerformed: boolean; message: string }> { - // Plusieurs connexions simultanées (ouverture des magasins) partagent la - // même vérification : un seul pg_dump est lancé - if (!this.dailyBackupCheck) { - this.dailyBackupCheck = this.performDailyBackupCheck(userId).finally(() => { - this.dailyBackupCheck = null; - }); - } - return this.dailyBackupCheck; - } - - private async performDailyBackupCheck(userId: string): Promise<{ backupPerformed: boolean; message: string }> { - try { - // Vérifier si les backups automatiques sont activés - const [config] = await db.select() - .from(utilities) - .limit(1); - - // Si les backups automatiques sont désactivés, ne rien faire - if (config && config.automaticBackupsEnabled === false) { - console.log('ℹ️ Sauvegardes automatiques désactivées - Aucune action effectuée'); - return { - backupPerformed: false, - message: 'Sauvegardes automatiques désactivées' - }; - } - - const today = new Date().toISOString().split('T')[0]; // Format YYYY-MM-DD - - // Vérifier s'il y a déjà une sauvegarde automatique aujourd'hui - const existingBackupToday = await db.select() - .from(databaseBackups) - .where(eq(databaseBackups.backupType, 'automatic')) - .orderBy(desc(databaseBackups.createdAt)) - .limit(1); - - const lastBackup = existingBackupToday[0]; - const lastBackupDate = lastBackup ? lastBackup.createdAt.toISOString().split('T')[0] : null; - - // Only perform backup if no automatic backup exists for today - if (lastBackupDate !== today) { - // Resolve a real user ID if called with default 'system' - let resolvedUserId = userId; - if (userId === 'system') { - const systemId = await this.resolveSystemUserId(); - if (!systemId) { - console.warn('⚠️ No user found in database — skipping daily backup'); - return { backupPerformed: false, message: 'Aucun utilisateur trouvé pour la sauvegarde' }; - } - resolvedUserId = systemId; - } - console.log('🔄 Première connexion du jour - Création de la sauvegarde automatique...'); - await this.createBackup('automatic', resolvedUserId); - console.log('✅ Sauvegarde quotidienne effectuée avec succès'); - return { - backupPerformed: true, - message: 'Sauvegarde quotidienne effectuée avec succès' - }; - } else { - console.log('ℹ️ Sauvegarde quotidienne déjà effectuée aujourd\'hui'); - return { - backupPerformed: false, - message: 'Sauvegarde quotidienne déjà effectuée aujourd\'hui' - }; - } - } catch (error) { - console.error('❌ Erreur lors de la vérification de sauvegarde quotidienne:', error); - return { - backupPerformed: false, - message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}` - }; - } - } - - private scheduleAutomaticBackup(): void { - // Check for automatic backup every hour - const checkBackupNeeded = async () => { - try { - // Vérifier si les backups automatiques sont activés - const [config] = await db.select() - .from(utilities) - .limit(1); - - // Si les backups automatiques sont désactivés, ne rien faire - if (config && config.automaticBackupsEnabled === false) { - return; - } - - const now = new Date(); - const today = now.toISOString().split('T')[0]; // YYYY-MM-DD format - - // Only run automatic backup if: - // 1. It's after 2:00 AM - // 2. We haven't done an automatic backup today yet - if (now.getHours() >= 2 && this.lastAutomaticBackupDate !== today) { - // Check if there's already an automatic backup today in the database - const existingBackupToday = await db.select() - .from(databaseBackups) - .where(eq(databaseBackups.backupType, 'automatic')) - .orderBy(desc(databaseBackups.createdAt)) - .limit(1); - - const lastBackup = existingBackupToday[0]; - const lastBackupDate = lastBackup ? lastBackup.createdAt.toISOString().split('T')[0] : null; - - // Only proceed if no automatic backup exists for today - if (lastBackupDate !== today) { - // Resolve a real user ID to satisfy FK constraint - const systemUserId = await this.resolveSystemUserId(); - if (!systemUserId) { - console.warn('⚠️ No user found in database — skipping automatic backup'); - return; - } - console.log('🔄 Starting automatic backup...'); - await this.createBackup('automatic', systemUserId); - this.lastAutomaticBackupDate = today; - console.log('✅ Automatic backup completed'); - } - } - } catch (error) { - console.error('❌ Automatic backup check failed:', error); - } - }; - - // Initial check - checkBackupNeeded(); - - // Check every hour (3600000 ms) - setInterval(checkBackupNeeded, 3600000); - - console.log('⏰ Automatic backup scheduled for daily 2:00 AM+ (native timer)'); - } -} - -// Singleton instance -export const backupService = new BackupService(); \ No newline at end of file diff --git a/server/createWebhookTable.ts b/server/createWebhookTable.ts deleted file mode 100644 index ccdb3ac..0000000 --- a/server/createWebhookTable.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { sql } from 'drizzle-orm'; -import { db } from './db.js'; - -export async function ensureWebhookBapConfigTable() { - console.log('🔧 [STARTUP] Vérification table webhook_bap_config...'); - - try { - // Vérifier si la table existe - const tableExists = await db.execute(sql` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema='public' AND table_name='webhook_bap_config' - ); - `); - - const exists = tableExists.rows[0]?.exists; - - if (exists) { - console.log('✅ [STARTUP] Table webhook_bap_config existe déjà'); - return; - } - - console.log('🔧 [STARTUP] Création de la table webhook_bap_config...'); - - // Créer la table - await db.execute(sql` - CREATE TABLE webhook_bap_config ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP', - webhook_url TEXT NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - `); - - // Insérer la configuration par défaut - await db.execute(sql` - INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) - VALUES ( - 'Configuration BAP', - 'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d', - 'Configuration par défaut pour envoi des fichiers BAP vers n8n', - true - ); - `); - - // Ajouter un commentaire - await db.execute(sql` - COMMENT ON TABLE webhook_bap_config IS 'Configuration pour webhook BAP n8n'; - `); - - console.log('✅ [STARTUP] Table webhook_bap_config créée avec succès!'); - - } catch (error) { - console.error('❌ [STARTUP] Erreur lors de la création de la table webhook_bap_config:', error); - // Ne pas arrêter l'application si la création échoue - } -} \ No newline at end of file diff --git a/server/crypto.ts b/server/crypto.ts deleted file mode 100644 index 6542f1d..0000000 --- a/server/crypto.ts +++ /dev/null @@ -1,76 +0,0 @@ -import crypto from 'crypto'; - -/** - * Chiffrement au repos des secrets applicatifs (mot de passe SMTP des - * magasins, jeton API NocoDB). - * - * - AES-256-GCM (chiffrement authentifié : toute altération en base est détectée) - * - Clé dérivée de ENCRYPTION_KEY, avec repli sur SESSION_SECRET pour que la - * production existante fonctionne sans nouvelle variable d'environnement - * - Format stocké : enc:v1:::, tout en base64 - * - Les valeurs héritées en clair sont acceptées en lecture (déchiffrement - * transparent) et re-chiffrées par le balayage de démarrage - * - * ATTENTION : changer ENCRYPTION_KEY/SESSION_SECRET après coup rend les - * secrets déjà chiffrés illisibles — il faudrait alors les ressaisir. - */ - -const PREFIX = 'enc:v1:'; - -function getKey(): Buffer { - const secret = process.env.ENCRYPTION_KEY || process.env.SESSION_SECRET; - if (!secret) { - // Même repli figé que les sessions : mieux vaut un chiffrement à clé - // faible qu'un stockage en clair, et la production définit toujours - // SESSION_SECRET via docker-compose. - return crypto.createHash('sha256').update('logiflow-fallback-secret-key').digest(); - } - return crypto.createHash('sha256').update(secret).digest(); -} - -export function isEncryptedSecret(value: string | null | undefined): boolean { - return typeof value === 'string' && value.startsWith(PREFIX); -} - -/** - * Chiffre un secret. Les valeurs vides et les valeurs déjà chiffrées sont - * renvoyées telles quelles (idempotent : pas de double chiffrement possible). - */ -export function encryptSecret(value: string | null | undefined): string | null | undefined { - if (!value || isEncryptedSecret(value)) { - return value; - } - - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv); - const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - - return `${PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`; -} - -/** - * Déchiffre un secret. Une valeur non chiffrée (héritage d'avant le - * chiffrement) est renvoyée telle quelle. - */ -export function decryptSecret(value: string | null | undefined): string | null | undefined { - if (!value || !isEncryptedSecret(value)) { - return value; - } - - const parts = value.slice(PREFIX.length).split(':'); - if (parts.length !== 3) { - throw new Error('Secret chiffré illisible : format inattendu'); - } - - try { - const [iv, tag, data] = parts.map(p => Buffer.from(p, 'base64')); - const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); - } catch { - throw new Error( - 'Secret chiffré illisible : clé de chiffrement changée ou donnée altérée. Ressaisissez la valeur.' - ); - } -} diff --git a/server/dateUtils.ts b/server/dateUtils.ts deleted file mode 100644 index e3d63ce..0000000 --- a/server/dateUtils.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Normalisation des dates d'échéance renvoyées par NocoDB (formats variés) -// vers YYYY-MM-DD. Partagée par les routes web et l'API externe. -export function normalizeDateString(dateString: string | null | undefined): string | null { - if (!dateString || typeof dateString !== 'string') return null; - - const trimmed = dateString.trim(); - if (!trimmed) return null; - - try { - // Si déjà au format ISO (YYYY-MM-DD), le retourner tel quel - if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { - return trimmed; - } - - // Format slash ou tiret : DD/MM/YYYY, MM/DD/YYYY, DD-MM-YYYY, MM-DD-YYYY - const slashMatch = trimmed.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/); - if (slashMatch) { - const [, first, second, year] = slashMatch; - const firstNum = parseInt(first); - const secondNum = parseInt(second); - - // Validation basique des valeurs - if (firstNum > 31 || secondNum > 31 || firstNum === 0 || secondNum === 0) { - console.warn(`⚠️ Date invalide (valeurs hors limites): ${trimmed}`); - return null; - } - - // Déterminer le format en fonction des valeurs - let day: string, month: string; - - if (firstNum > 12) { - // first > 12 → forcément DD/MM (format français/européen) - day = first.padStart(2, '0'); - month = second.padStart(2, '0'); - } else if (secondNum > 12) { - // second > 12 → forcément MM/DD (format américain) - day = second.padStart(2, '0'); - month = first.padStart(2, '0'); - } else { - // Ambiguïté (les deux < 12) → on assume format français DD/MM par défaut - // Pour être plus sûr, on pourrait vérifier la configuration du groupe/locale - day = first.padStart(2, '0'); - month = second.padStart(2, '0'); - } - - // Validation finale : mois entre 1-12, jour entre 1-31 - const monthNum = parseInt(month); - const dayNum = parseInt(day); - if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) { - console.warn(`⚠️ Date invalide après parsing: ${trimmed} → month=${month}, day=${day}`); - return null; - } - - return `${year}-${month}-${day}`; - } - - // Essayer de parser avec Date (format ISO complet avec heures) - const date = new Date(trimmed); - if (!isNaN(date.getTime())) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - } - - // Si aucun format reconnu, retourner null (ne pas persister une date invalide) - console.warn(`⚠️ Format de date non reconnu: ${trimmed}`); - return null; - } catch (error) { - console.error('❌ Erreur normalisation date:', error); - return null; // Retourner null en cas d'erreur pour éviter de persister des données invalides - } -} diff --git a/server/db.production.ts b/server/db.production.ts deleted file mode 100644 index 2c382f9..0000000 --- a/server/db.production.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Pool } from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from "@shared/schema"; -import { runProductionMigrations } from './migrations.production.js'; - -console.log('🐳 PRODUCTION: Configuring standard PostgreSQL database'); - -if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL must be set for production"); -} - -// Log connection details (without password) -const dbUrl = new URL(process.env.DATABASE_URL); -console.log('🐳 Database connection:', { - host: dbUrl.hostname, - port: dbUrl.port, - database: dbUrl.pathname.slice(1), - user: dbUrl.username -}); - -export const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - ssl: false, // No SSL for local Docker - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 10000, -}); - -export const db = drizzle({ client: pool, schema }); - -// Test connection -pool.on('connect', () => { - console.log('✅ PostgreSQL connection established'); -}); - -pool.on('error', (err) => { - console.error('❌ PostgreSQL connection error:', err); -}); - -// Run migrations automatically when database is configured -(async () => { - try { - console.log('🔄 DB: Running automatic database migrations...'); - await runProductionMigrations(); - console.log('✅ DB: Database migrations completed successfully'); - } catch (error) { - console.error('❌ DB: Database migrations failed with error:', error); - console.error('❌ DB: Migration failure details:', { - name: (error as any)?.name, - message: (error as any)?.message, - code: (error as any)?.code - }); - // Don't fail the application startup for migration errors - } -})(); - -console.log('✅ Production PostgreSQL database configured'); \ No newline at end of file diff --git a/server/db.ts b/server/db.ts deleted file mode 100644 index ee1c4b6..0000000 --- a/server/db.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as schema from "@shared/schema"; - -// Simple environment detection -const isProduction = process.env.NODE_ENV === 'production'; -const dbUrl = process.env.DATABASE_URL; - -console.log('🔗 Database initialization:', { - NODE_ENV: process.env.NODE_ENV, - isProduction, - hasDbUrl: !!dbUrl, - dbHost: dbUrl ? new URL(dbUrl).hostname : 'none' -}); - -let db: any; -let pool: any; - -if (isProduction && dbUrl && (dbUrl.includes('logiflow-db') || dbUrl.includes('localhost') || dbUrl.includes('127.0.0.1'))) { - // Standard PostgreSQL for production Docker - console.log('🐳 PRODUCTION: Using standard PostgreSQL'); - const { Pool } = await import('pg'); - const { drizzle } = await import('drizzle-orm/node-postgres'); - - pool = new Pool({ - connectionString: dbUrl, - ssl: false, - max: 25, // Increased from 10 to handle concurrent verifications - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 15000, // Increased from 5000 to 15000 - }); - - db = drizzle({ client: pool, schema }); - - // Une connexion inactive coupée par Postgres (redémarrage ou redéploiement de - // la base, coupure réseau) fait émettre 'error' au pool. Sans écouteur, Node - // arrête tout le serveur ; le pool écarte déjà la connexion fautive et en - // ouvre une neuve à la requête suivante, il suffit donc de journaliser. - pool.on('error', (err: any) => { - console.error('❌ PostgreSQL idle connection error (connexion écartée):', err?.code || '', err?.message); - }); - - // Test connection - try { - await pool.connect().then((client: any) => { - console.log('✅ PostgreSQL connection test successful'); - client.release(); - }); - } catch (error) { - console.error('❌ PostgreSQL connection test failed:', error); - } -} else { - // Fallback: use Neon for development (but will be overridden by MemStorage) - console.log('🔧 DEV: Using Neon (fallback, will use MemStorage)'); - const { Pool, neonConfig } = await import('@neondatabase/serverless'); - const { drizzle } = await import('drizzle-orm/neon-serverless'); - const ws = await import('ws'); - - neonConfig.webSocketConstructor = ws.default; - - if (dbUrl) { - pool = new Pool({ connectionString: dbUrl }); - db = drizzle({ client: pool, schema }); - } else { - // Create dummy instances for development - pool = null; - db = null; - console.log('🔧 DEV: No DATABASE_URL, using MemStorage'); - } -} - -export { db, pool }; \ No newline at end of file diff --git a/server/emailService.ts b/server/emailService.ts deleted file mode 100644 index 1a8ec34..0000000 --- a/server/emailService.ts +++ /dev/null @@ -1,169 +0,0 @@ -import nodemailer, { type Transporter } from 'nodemailer'; -import { decryptSecret } from './crypto'; -import { - buildSupplierMailSubject, - buildSupplierMailText, - buildSupplierMailHtml, - type SupplierMailDelivery, -} from '@shared/supplierMail'; - -/** - * Envoi des mails fournisseurs via la configuration SMTP propre à chaque magasin. - * Chaque magasin renseigne son serveur, ses identifiants et son adresse - * d'expédition sur sa fiche : aucun compte global n'est utilisé. - */ - -export interface StoreSmtpConfig { - id?: number; - name?: string | null; - address?: string | null; - phone?: string | null; - logo?: string | null; - smtpEnabled?: boolean | null; - smtpHost?: string | null; - smtpPort?: number | null; - smtpSecure?: boolean | null; - smtpUser?: string | null; - smtpPassword?: string | null; - smtpSenderEmail?: string | null; - smtpSenderName?: string | null; -} - -export class SmtpConfigError extends Error { - constructor(message: string) { - super(message); - this.name = 'SmtpConfigError'; - } -} - -/** - * Vérifie que le magasin dispose d'une configuration exploitable. - * Renvoie la liste des champs manquants plutôt qu'un simple booléen, - * pour pouvoir dire à l'utilisateur ce qu'il reste à renseigner. - */ -export function getMissingSmtpFields(group: StoreSmtpConfig | null | undefined): string[] { - if (!group) return ['magasin']; - - const missing: string[] = []; - if (!group.smtpHost?.trim()) missing.push('serveur SMTP'); - if (!group.smtpPort) missing.push('port SMTP'); - if (!group.smtpSenderEmail?.trim()) missing.push('adresse expéditeur'); - return missing; -} - -/** - * Construit le transporteur nodemailer d'un magasin. - * L'authentification est optionnelle : certains relais internes acceptent - * les envois sans identifiants. - */ -export function createTransporter(group: StoreSmtpConfig): Transporter { - const missing = getMissingSmtpFields(group); - if (missing.length > 0) { - throw new SmtpConfigError(`Configuration SMTP incomplète : ${missing.join(', ')}`); - } - - // Le mot de passe est stocké chiffré (AES-256-GCM) : déchiffré uniquement - // ici, au moment de la connexion au serveur SMTP - const smtpPassword = decryptSecret(group.smtpPassword); - const hasAuth = Boolean(group.smtpUser?.trim() && smtpPassword); - - return nodemailer.createTransport({ - host: group.smtpHost!.trim(), - port: Number(group.smtpPort), - secure: Boolean(group.smtpSecure), - auth: hasAuth - ? { user: group.smtpUser!.trim(), pass: smtpPassword! } - : undefined, - }); -} - -/** Adresse "De :" du magasin, avec son nom affiché. */ -function buildFromAddress(group: StoreSmtpConfig): string { - const senderEmail = group.smtpSenderEmail!.trim(); - const senderName = group.smtpSenderName?.trim() || group.name?.trim(); - return senderName ? `"${senderName}" <${senderEmail}>` : senderEmail; -} - -/** - * Extrait le contenu binaire d'un logo stocké en data URI. - * Renvoie null si le logo est absent ou dans un format inattendu. - */ -function parseLogoDataUri(logo: string | null | undefined) { - if (!logo) return null; - - const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(logo.trim()); - if (!match) return null; - - const [, mimeType, base64] = match; - const extension = mimeType.split('/')[1]?.split('+')[0] || 'png'; - - try { - return { - content: Buffer.from(base64, 'base64'), - contentType: mimeType, - filename: `logo.${extension}`, - }; - } catch { - return null; - } -} - -/** - * Teste la configuration SMTP d'un magasin sans envoyer de message. - */ -export async function verifySmtpConfig(group: StoreSmtpConfig): Promise { - const transporter = createTransporter(group); - try { - await transporter.verify(); - } finally { - transporter.close(); - } -} - -export interface SendSupplierMailResult { - messageId: string; - accepted: string[]; - rejected: string[]; -} - -/** - * Envoie au fournisseur la demande de facture PDF / BL Excel pour une livraison. - */ -export async function sendSupplierDocumentRequest( - group: StoreSmtpConfig, - delivery: SupplierMailDelivery, - supplierEmail: string -): Promise { - const transporter = createTransporter(group); - - try { - const store = { - name: group.name, - address: group.address, - phone: group.phone, - email: group.smtpSenderEmail, - }; - - const logo = parseLogoDataUri(group.logo); - - const info = await transporter.sendMail({ - from: buildFromAddress(group), - to: supplierEmail.trim(), - replyTo: group.smtpSenderEmail!.trim(), - subject: buildSupplierMailSubject(delivery), - text: buildSupplierMailText(delivery, store), - html: buildSupplierMailHtml(delivery, store, { hasLogo: Boolean(logo) }), - attachments: logo - ? [{ ...logo, cid: 'logo', contentDisposition: 'inline' as const }] - : [], - }); - - return { - messageId: info.messageId, - accepted: (info.accepted || []).map(String), - rejected: (info.rejected || []).map(String), - }; - } finally { - transporter.close(); - } -} diff --git a/server/externalApi.ts b/server/externalApi.ts deleted file mode 100644 index 2edf2c7..0000000 --- a/server/externalApi.ts +++ /dev/null @@ -1,415 +0,0 @@ -// API externe de rapprochement BL / factures. -// -// Permet à un outil tiers (comptabilité, n8n, script...) de lire les magasins, -// les fournisseurs et les livraisons livrées (numéros de BL), puis d'écrire la -// référence, les montants et l'échéance de la facture sur une livraison (y -// compris déjà validée, pour les fournisseurs en rapprochement automatique). -// -// Authentification par clé d'API, indépendante des sessions du webUI : -// - clés créées par un admin dans Paramètres > API externe (server/externalApiKeys.ts) ; -// - et/ou variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés -// séparées par des virgules) ; -// - aucune clé active => API désactivée ; -// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer ". -// -// Documentation : docs/API-RAPPROCHEMENT.md - -import type { Express, Request, Response, NextFunction } from "express"; -import { createHash, timingSafeEqual } from "crypto"; -import { z } from "zod"; -import { storage } from "./storage"; -import { normalizeDateString } from "./dateUtils"; -import { invoiceVerificationService } from "./invoiceVerification"; -import { - countActiveApiKeys, - countEnvApiKeys, - createApiKey, - listApiKeys, - revokeApiKey, - verifyStoredApiKey, -} from "./externalApiKeys"; - -export const EXTERNAL_API_PREFIX = "/api/ext/v1"; - -const MAX_LIMIT = 500; -const DEFAULT_LIMIT = 100; - -function digest(value: string): Buffer { - return createHash("sha256").update(value).digest(); -} - -// Clés relues à chaque requête : une rotation via l'environnement ne demande -// qu'un redémarrage, et aucune clé n'est gardée en clair plus que nécessaire. -function configuredKeyDigests(): Buffer[] { - return (process.env.EXTERNAL_API_KEYS || "") - .split(",") - .map((k) => k.trim()) - .filter((k) => k.length > 0) - .map(digest); -} - -function extractKey(req: Request): string | null { - const header = req.headers["x-api-key"]; - if (typeof header === "string" && header.trim()) return header.trim(); - - const auth = req.headers.authorization; - if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) { - const token = auth.slice(7).trim(); - if (token) return token; - } - return null; -} - -async function requireApiKey(req: Request, res: Response, next: NextFunction) { - try { - const provided = extractKey(req); - if (provided) { - // Clés de l'environnement : comparaison à temps constant sur les empreintes - const providedDigest = digest(provided); - if (configuredKeyDigests().some((k) => timingSafeEqual(k, providedDigest))) return next(); - // Clés créées dans Paramètres (recherche par empreinte) - if (await verifyStoredApiKey(provided)) return next(); - } - - if (countEnvApiKeys() === 0 && (await countActiveApiKeys()) === 0) { - return res.status(503).json({ error: "API externe désactivée : aucune clé d'API active (Paramètres > API externe)" }); - } - - console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`); - return res.status(401).json({ error: "Clé d'API invalide ou absente" }); - } catch (error) { - console.error("❌ [EXT-API] vérification de la clé:", error); - return res.status(500).json({ error: "Erreur lors de la vérification de la clé d'API" }); - } -} - -// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56" -const amountSchema = z - .union([z.number(), z.string()]) - .nullable() - .transform((value, ctx) => { - if (value === null) return null; - const raw = typeof value === "number" ? String(value) : value.replace(/[\s ]/g, "").replace(",", "."); - if (raw === "") return null; - const num = Number(raw); - if (!Number.isFinite(num) || Math.abs(num) >= 1e8) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Montant invalide" }); - return z.NEVER; - } - return num.toFixed(2); // colonne decimal(10,2) - }); - -const updateInvoiceSchema = z - .object({ - invoiceReference: z.string().trim().max(100).nullable().optional(), - invoiceAmount: amountSchema.optional(), - invoiceAmountTTC: amountSchema.optional(), - dueDate: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, "Format attendu : YYYY-MM-DD") - .nullable() - .optional(), - reconciled: z.boolean().optional(), - }) - .strict() - .refine((body) => Object.keys(body).length > 0, { message: "Aucun champ à mettre à jour" }); - -const INVOICE_FIELDS = ["invoiceReference", "invoiceAmount", "invoiceAmountTTC", "dueDate"] as const; - -function toNumber(value: unknown): number | null { - if (value === null || value === undefined || value === "") return null; - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -function toIsoDate(value: unknown): string | null { - if (!value) return null; - const date = value instanceof Date ? value : new Date(value as string); - return isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10); -} - -function toIsoDateTime(value: unknown): string | null { - if (!value) return null; - const date = value instanceof Date ? value : new Date(value as string); - return isNaN(date.getTime()) ? null : date.toISOString(); -} - -// Format public d'une livraison : champs utiles au rapprochement uniquement -function toApiDelivery(d: any) { - return { - id: d.id, - storeId: d.groupId, - storeName: d.group?.name ?? null, - supplierId: d.supplierId, - supplierName: d.supplier?.name ?? null, - supplierCode: d.supplier?.codefou ?? null, - automaticReconciliation: !!d.supplier?.automaticReconciliation, - status: d.status, - scheduledDate: toIsoDate(d.scheduledDate), - deliveredDate: toIsoDateTime(d.deliveredDate), - blNumber: d.blNumber ?? null, - blAmount: toNumber(d.blAmount), - invoiceReference: d.invoiceReference ?? null, - invoiceAmount: toNumber(d.invoiceAmount), - invoiceAmountTTC: toNumber(d.invoiceAmountTTC), - dueDate: toIsoDate(d.dueDate), - reconciled: !!d.reconciled, - validatedAt: toIsoDateTime(d.validatedAt), - updatedAt: toIsoDateTime(d.updatedAt), - }; -} - -function parseIntParam(value: unknown): number | undefined | null { - if (value === undefined || value === "") return undefined; - const num = Number(value); - return Number.isInteger(num) && num > 0 ? num : null; // null = invalide -} - -function parseBoolParam(value: unknown): boolean | undefined | null { - if (value === undefined || value === "") return undefined; - if (value === "true") return true; - if (value === "false") return false; - return null; -} - -function parseDateParam(value: unknown): string | undefined | null { - if (value === undefined || value === "") return undefined; - return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; -} - -// Échéance lue dans NocoDB pour une référence facture (même logique que le webUI) -async function lookupDueDate(invoiceReference: string, groupId: number, reconciled: boolean): Promise { - try { - const result = await invoiceVerificationService.verifyInvoice(invoiceReference, groupId, true, reconciled); - const normalized = result.exists ? normalizeDateString(result.dueDate) : null; - return normalized ? new Date(normalized) : null; - } catch (error) { - console.error("❌ [EXT-API] Erreur récupération échéance NocoDB:", error); - return null; - } -} - -export function registerExternalApi(app: Express) { - const base = EXTERNAL_API_PREFIX; - app.use(base, requireApiKey); - - // Magasins - app.get(`${base}/stores`, async (_req, res) => { - try { - const groups = await storage.getGroups(); - res.json(groups.map((g: any) => ({ id: g.id, name: g.name }))); - } catch (error) { - console.error("❌ [EXT-API] stores:", error); - res.status(500).json({ error: "Erreur lors de la lecture des magasins" }); - } - }); - - // Fournisseurs - app.get(`${base}/suppliers`, async (_req, res) => { - try { - const suppliers = await storage.getSuppliers(); - res.json( - suppliers.map((s: any) => ({ - id: s.id, - name: s.name, - code: s.codefou ?? null, - paymentMethod: s.paymentMethod ?? null, - automaticReconciliation: !!s.automaticReconciliation, - })), - ); - } catch (error) { - console.error("❌ [EXT-API] suppliers:", error); - res.status(500).json({ error: "Erreur lors de la lecture des fournisseurs" }); - } - }); - - // Livraisons livrées (périmètre du rapprochement BL / factures) - app.get(`${base}/deliveries`, async (req, res) => { - const storeId = parseIntParam(req.query.storeId); - const supplierId = parseIntParam(req.query.supplierId); - const reconciled = parseBoolParam(req.query.reconciled); - const hasBl = parseBoolParam(req.query.hasBl); - const hasInvoice = parseBoolParam(req.query.hasInvoice); - const from = parseDateParam(req.query.from); - const to = parseDateParam(req.query.to); - const limit = parseIntParam(req.query.limit); - const offsetRaw = req.query.offset; - const offset = offsetRaw === undefined || offsetRaw === "" ? 0 : Number(offsetRaw); - const blNumber = typeof req.query.blNumber === "string" ? req.query.blNumber.trim().toLowerCase() : ""; - - const invalid = Object.entries({ storeId, supplierId, reconciled, hasBl, hasInvoice, from, to, limit }) - .filter(([, v]) => v === null) - .map(([k]) => k); - if (!Number.isInteger(offset) || offset < 0) invalid.push("offset"); - if (invalid.length > 0) { - return res.status(400).json({ error: `Paramètre(s) invalide(s) : ${invalid.join(", ")}` }); - } - - try { - let rows: any[] = await storage.getDeliveries(storeId ? [storeId] : undefined, { status: "delivered" }); - - rows = rows.filter((d) => { - if (supplierId && d.supplierId !== supplierId) return false; - if (reconciled !== undefined && !!d.reconciled !== reconciled) return false; - if (hasBl !== undefined && !!d.blNumber?.trim() !== hasBl) return false; - if (hasInvoice !== undefined && !!d.invoiceReference?.trim() !== hasInvoice) return false; - if (blNumber && (d.blNumber || "").trim().toLowerCase() !== blNumber) return false; - if (from || to) { - // Date de livraison effective, sinon date prévue - const day = toIsoDate(d.deliveredDate) ?? toIsoDate(d.scheduledDate); - if (!day) return false; - if (from && day < from) return false; - if (to && day > to) return false; - } - return true; - }); - - rows.sort((a, b) => { - const da = toIsoDateTime(a.deliveredDate) ?? toIsoDate(a.scheduledDate) ?? ""; - const db = toIsoDateTime(b.deliveredDate) ?? toIsoDate(b.scheduledDate) ?? ""; - return da < db ? 1 : da > db ? -1 : b.id - a.id; - }); - - const pageSize = Math.min(limit ?? DEFAULT_LIMIT, MAX_LIMIT); - res.json({ - total: rows.length, - limit: pageSize, - offset, - items: rows.slice(offset, offset + pageSize).map(toApiDelivery), - }); - } catch (error) { - console.error("❌ [EXT-API] deliveries:", error); - res.status(500).json({ error: "Erreur lors de la lecture des livraisons" }); - } - }); - - app.get(`${base}/deliveries/:id`, async (req, res) => { - const id = parseIntParam(req.params.id); - if (!id) return res.status(400).json({ error: "Identifiant invalide" }); - - try { - const delivery = await storage.getDelivery(id); - if (!delivery) return res.status(404).json({ error: "Livraison introuvable" }); - res.json(toApiDelivery(delivery)); - } catch (error) { - console.error("❌ [EXT-API] delivery:", error); - res.status(500).json({ error: "Erreur lors de la lecture de la livraison" }); - } - }); - - // Écriture de la facture (référence, montants, échéance) et validation optionnelle - app.patch(`${base}/deliveries/:id`, async (req, res) => { - const id = parseIntParam(req.params.id); - if (!id) return res.status(400).json({ error: "Identifiant invalide" }); - - const parsed = updateInvoiceSchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ - error: "Corps de requête invalide", - details: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })), - }); - } - const body = parsed.data; - - try { - const delivery = await storage.getDelivery(id); - if (!delivery) return res.status(404).json({ error: "Livraison introuvable" }); - - if (delivery.status !== "delivered") { - return res.status(409).json({ error: "Seules les livraisons livrées peuvent être rapprochées" }); - } - - const touchesInvoice = INVOICE_FIELDS.some((f) => body[f] !== undefined); - // Une livraison rapprochée est figée, comme dans le webUI : il faut la - // dévalider (reconciled: false) pour modifier sa facture. Exception : les - // fournisseurs en rapprochement automatique, validés d'office dès la - // saisie du BL, dont la facture arrive après et se complète sans dévalider. - const isAutomatic = !!delivery.supplier?.automaticReconciliation; - if (delivery.reconciled && !isAutomatic && touchesInvoice && body.reconciled !== false) { - return res.status(409).json({ - error: "Livraison déjà rapprochée : envoyer reconciled: false pour la dévalider avant de modifier la facture", - }); - } - - const update: Record = {}; - if (body.invoiceReference !== undefined) update.invoiceReference = body.invoiceReference || null; - if (body.invoiceAmount !== undefined) update.invoiceAmount = body.invoiceAmount; - if (body.invoiceAmountTTC !== undefined) update.invoiceAmountTTC = body.invoiceAmountTTC; - - if (body.dueDate !== undefined) { - update.dueDate = body.dueDate ? new Date(body.dueDate) : null; - } else if (update.invoiceReference !== undefined && update.invoiceReference !== delivery.invoiceReference) { - // Échéance non fournie : reprise depuis NocoDB, ou vidée avec la référence - update.dueDate = update.invoiceReference - ? await lookupDueDate(update.invoiceReference, delivery.groupId, !!delivery.reconciled) - : null; - } - - if (body.reconciled !== undefined) { - update.reconciled = body.reconciled; - update.validatedAt = body.reconciled ? new Date() : null; - } - - await storage.updateDelivery(id, update); - console.log(`🔌 [EXT-API] Livraison #${id} mise à jour : ${Object.keys(update).join(", ")}`); - - const updated = await storage.getDelivery(id); - res.json(toApiDelivery(updated)); - } catch (error) { - console.error("❌ [EXT-API] update delivery:", error); - res.status(500).json({ error: "Erreur lors de la mise à jour de la livraison" }); - } - }); - - // Toute autre route sous le préfixe : 404 JSON plutôt que la page du webUI - app.use(base, (_req, res) => { - res.status(404).json({ error: "Route inconnue" }); - }); -} - -// Gestion des clés depuis Paramètres > API externe : session webUI, admin -// uniquement. À enregistrer après la mise en place de l'authentification. -export function registerExternalApiKeyAdminRoutes(app: Express, requireSessionAdmin: Array<(req: any, res: any, next: any) => void>) { - const createKeySchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(100) }); - - app.get("/api/external-api/keys", ...requireSessionAdmin, async (_req, res) => { - try { - res.json({ - basePath: EXTERNAL_API_PREFIX, - envKeyCount: countEnvApiKeys(), - keys: await listApiKeys(), - }); - } catch (error) { - console.error("❌ [EXT-API] list keys:", error); - res.status(500).json({ error: "Erreur lors de la lecture des clés d'API" }); - } - }); - - app.post("/api/external-api/keys", ...requireSessionAdmin, async (req: any, res) => { - const parsed = createKeySchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.issues[0]?.message || "Nom invalide" }); - } - try { - const created = await createApiKey(parsed.data.name, req.user?.id ?? null); - console.log(`🔑 [EXT-API] Clé « ${parsed.data.name} » créée par ${req.user?.username ?? "?"}`); - res.status(201).json(created); - } catch (error) { - console.error("❌ [EXT-API] create key:", error); - res.status(500).json({ error: "Erreur lors de la création de la clé d'API" }); - } - }); - - app.delete("/api/external-api/keys/:id", ...requireSessionAdmin, async (req: any, res) => { - const id = Number(req.params.id); - if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "Identifiant invalide" }); - try { - if (!(await revokeApiKey(id))) return res.status(404).json({ error: "Clé introuvable ou déjà révoquée" }); - console.log(`🔑 [EXT-API] Clé #${id} révoquée par ${req.user?.username ?? "?"}`); - res.json({ success: true }); - } catch (error) { - console.error("❌ [EXT-API] revoke key:", error); - res.status(500).json({ error: "Erreur lors de la révocation de la clé d'API" }); - } - }); -} diff --git a/server/externalApiKeys.ts b/server/externalApiKeys.ts deleted file mode 100644 index 4ee1b2a..0000000 --- a/server/externalApiKeys.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Clés de l'API externe de rapprochement créées depuis Paramètres > API externe. -// -// La clé n'est montrée qu'une fois, à sa création : seule son empreinte SHA-256 -// est stockée (table external_api_keys), avec un préfixe lisible pour la -// reconnaître dans la liste. Une clé révoquée est conservée pour l'historique. -// Les clés de la variable d'environnement EXTERNAL_API_KEYS restent acceptées. - -import { createHash, randomBytes } from "crypto"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; -import { db } from "./db"; -import { externalApiKeys, type ExternalApiKey } from "@shared/schema"; - -const KEY_PREFIX = "lf_"; -const DISPLAY_PREFIX_LENGTH = 10; // "lf_" + 7 caractères -// last_used_at n'est réécrit qu'au plus une fois par minute et par clé -const LAST_USED_THROTTLE_MS = 60_000; - -export type PublicApiKey = Omit; - -// Sans base (développement), les clés sont gardées en mémoire -const hasDatabase = !!process.env.DATABASE_URL; -const memoryKeys: ExternalApiKey[] = []; -let memoryNextId = 1; - -export function hashApiKey(key: string): string { - return createHash("sha256").update(key).digest("hex"); -} - -function toPublic({ keyHash: _keyHash, ...key }: ExternalApiKey): PublicApiKey { - return key; -} - -export async function listApiKeys(): Promise { - if (!hasDatabase) return [...memoryKeys].reverse().map(toPublic); - const rows: ExternalApiKey[] = await db.select().from(externalApiKeys).orderBy(desc(externalApiKeys.createdAt)); - return rows.map(toPublic); -} - -// Crée une clé et la renvoie en clair : c'est la seule fois où elle est lisible -export async function createApiKey(name: string, createdBy: string | null): Promise<{ key: string; apiKey: PublicApiKey }> { - const key = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`; - const values = { - name, - keyPrefix: key.slice(0, DISPLAY_PREFIX_LENGTH), - keyHash: hashApiKey(key), - createdBy, - }; - - if (!hasDatabase) { - const row: ExternalApiKey = { id: memoryNextId++, ...values, createdAt: new Date(), lastUsedAt: null, revokedAt: null }; - memoryKeys.push(row); - return { key, apiKey: toPublic(row) }; - } - - const [row]: ExternalApiKey[] = await db.insert(externalApiKeys).values(values).returning(); - return { key, apiKey: toPublic(row) }; -} - -// Révoque une clé active ; false si elle n'existe pas ou l'était déjà -export async function revokeApiKey(id: number): Promise { - if (!hasDatabase) { - const row = memoryKeys.find((k) => k.id === id && !k.revokedAt); - if (!row) return false; - row.revokedAt = new Date(); - return true; - } - - const rows = await db - .update(externalApiKeys) - .set({ revokedAt: new Date() }) - .where(and(eq(externalApiKeys.id, id), isNull(externalApiKeys.revokedAt))) - .returning({ id: externalApiKeys.id }); - return rows.length > 0; -} - -export async function countActiveApiKeys(): Promise { - if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length; - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(externalApiKeys) - .where(isNull(externalApiKeys.revokedAt)); - return row?.count ?? 0; -} - -// Vérifie une clé reçue ; met à jour sa date de dernière utilisation -export async function verifyStoredApiKey(key: string): Promise { - const keyHash = hashApiKey(key); - - if (!hasDatabase) { - const row = memoryKeys.find((k) => k.keyHash === keyHash && !k.revokedAt); - if (row) row.lastUsedAt = new Date(); - return !!row; - } - - const [row]: ExternalApiKey[] = await db - .select() - .from(externalApiKeys) - .where(and(eq(externalApiKeys.keyHash, keyHash), isNull(externalApiKeys.revokedAt))) - .limit(1); - if (!row) return false; - - const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0; - if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) { - // Sans attendre : une erreur d'écriture ne doit pas refuser la requête - db.update(externalApiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(externalApiKeys.id, row.id)) - .catch((error: any) => console.error("❌ [EXT-API] last_used_at:", error?.message)); - } - return true; -} - -// Nombre de clés fournies par la variable d'environnement EXTERNAL_API_KEYS -export function countEnvApiKeys(): number { - return (process.env.EXTERNAL_API_KEYS || "").split(",").filter((k) => k.trim().length > 0).length; -} diff --git a/server/index.production.ts b/server/index.production.ts deleted file mode 100644 index 6b3ce25..0000000 --- a/server/index.production.ts +++ /dev/null @@ -1,194 +0,0 @@ -import express, { type Express, type Request, type Response, type NextFunction } from "express"; -import compression from "compression"; -import { createServer, type Server } from "http"; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -// Database will be configured in storage.js -import { storage } from "./storage.js"; -import { setupLocalAuth, requireAuth } from "./localAuth.production.js"; -import { runProductionMigrations } from "./migrations.production.js"; - -// Get directory paths -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -console.log('🐳 PRODUCTION: Starting LogiFlow application'); - -// Force production mode and PostgreSQL storage when deployed in Docker -process.env.NODE_ENV = 'production'; -console.log('🐳 Environment:', { - NODE_ENV: process.env.NODE_ENV, - DATABASE_URL: process.env.DATABASE_URL ? 'Present' : 'Missing', - PORT: process.env.PORT -}); - -const app = express(); - -// Compression gzip des réponses (assets JS/CSS, index.html et JSON de l'API). -// En premier pour couvrir toutes les réponses. -app.use(compression()); - -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: false, limit: '10mb' })); - -app.use((req, res, next) => { - const start = Date.now(); - res.on("finish", () => { - const duration = Date.now() - start; - if (req.path.startsWith("/api")) { - console.log(`${req.method} ${req.path} ${res.statusCode} in ${duration}ms`); - } - }); - next(); -}); - -// Import all routes for production -import { registerRoutes } from "./routes.js"; - -// Initialize backup service for automatic backups -import { backupService } from "./backupService.js"; -console.log('💾 [PRODUCTION] Backup service initialized for automatic daily backups'); - -async function registerProductionRoutes(app: Express): Promise { - console.log('🔧 Registering all production routes...'); - - // Run automatic migrations before registering routes - console.log('🔄 Running automatic production migrations...'); - await runProductionMigrations(); - - // Initialize weather system for production - console.log('🌤️ [PRODUCTION] Initializing weather system...'); - const { initializeWeatherConfig } = await import('./weatherAutoConfig.js'); - await initializeWeatherConfig(); - console.log('✅ [PRODUCTION] Weather system initialized'); - - // Register ALL API routes (same as development) - await registerRoutes(app); - console.log('✅ All routes registered successfully for production'); - - // Additional health check endpoint for production monitoring - app.get('/api/health', (req: Request, res: Response) => { - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - environment: 'production', - database: 'connected', - port: process.env.PORT || 3000, - publicPath: join(__dirname, 'public') - }); - }); - - // Emergency admin reset endpoint (production only) - // SÉCURITÉ : n'existe que si EMERGENCY_SECRET est défini dans l'environnement. - // L'ancien secret par défaut était codé en dur dans ce fichier, donc lisible - // par quiconque accède au dépôt — n'importe qui pouvait réinitialiser le - // compte admin en production. - app.post('/api/emergency-admin-reset', async (req: Request, res: Response) => { - try { - const emergencySecret = process.env.EMERGENCY_SECRET; - if (!emergencySecret) { - return res.status(404).json({ error: 'Not found' }); - } - - const { secret } = req.body; - if (!secret || secret !== emergencySecret) { - console.warn('🚨 Tentative de reset admin avec un secret invalide'); - return res.status(403).json({ error: 'Invalid emergency secret' }); - } - - console.log('🚨 EMERGENCY: Admin password reset requested'); - - // Find and reset admin - const existingAdmin = await storage.getUserByUsername('admin'); - if (existingAdmin) { - // Use simple hash for admin reset - const crypto = await import('crypto'); - const { promisify } = await import('util'); - const scryptAsync = promisify(crypto.scrypt); - const salt = crypto.randomBytes(16).toString("hex"); - const buf = (await scryptAsync('admin', salt, 64)) as Buffer; - const newPassword = `${buf.toString("hex")}.${salt}`; - - await storage.updateUser(existingAdmin.id, { - password: newPassword, - passwordChanged: false - }); - - console.log('✅ EMERGENCY: Admin password reset to admin/admin'); - return res.json({ - success: true, - message: 'Admin password reset to admin/admin', - adminId: existingAdmin.id - }); - } else { - return res.status(404).json({ error: 'Admin user not found' }); - } - } catch (error) { - console.error('❌ EMERGENCY: Admin reset failed:', error); - return res.status(500).json({ error: 'Reset failed', details: (error as Error).message }); - } - }); -} - -// Serve static files directly in production (no Vite) -// Déclarés AVANT les routes : les fichiers statiques et l'index.html ne passent -// ni par la session ni par passport (aucune requête SQL par fichier). -const publicPath = join(__dirname, 'public'); -console.log('🐳 Serving static files from:', publicPath); - -// Les fichiers de /assets ont un nom haché par Vite : cache navigateur d'un an -app.use('/assets', express.static(join(publicPath, 'assets'), { - maxAge: '1y', - immutable: true, - index: false, -})); -// Autres fichiers publics (favicon, manifest...). L'index.html est toujours -// revalidé pour qu'un nouveau déploiement soit pris en compte immédiatement. -app.use(express.static(publicPath, { - index: false, - setHeaders: (res, filePath) => { - if (filePath.endsWith('.html')) { - res.setHeader('Cache-Control', 'no-cache'); - } - }, -})); - -// SPA fallback - serve index.html for all non-API routes (y compris "/") -app.get('*', (req, res, next) => { - if (req.path.startsWith('/api/')) { - return next(); - } - res.set('Cache-Control', 'no-cache'); - res.sendFile(join(publicPath, 'index.html'), (err) => { - if (err) { - console.error(`❌ SPA: Error serving index.html for ${req.path}:`, err); - if (!res.headersSent) { - res.status(500).send('Error loading application'); - } - } - }); -}); - -await registerProductionRoutes(app); - -// Tâches de maintenance périodiques (purge du cache factures expiré) -const { startMaintenanceJobs } = await import('./maintenance.js'); -startMaintenanceJobs(); - -app.use((err: any, _req: Request, res: Response, next: NextFunction) => { - // Réponse déjà commencée : on laisse Express clore la connexion - if (res.headersSent) { - return next(err); - } - const status = err.status || err.statusCode || 500; - const message = err.message || "Internal Server Error"; - console.error('Server error:', { status, message, error: err }); - res.status(status).json({ message }); -}); - -const port = process.env.PORT ? parseInt(process.env.PORT) : 3000; -const server = createServer(app); -server.listen(port, "0.0.0.0", () => { - console.log(`🐳 PRODUCTION: LogiFlow serving on port ${port}`); -}); \ No newline at end of file diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index db325e4..0000000 --- a/server/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import express, { type Request, Response, NextFunction } from "express"; -import compression from "compression"; -import cookieParser from "cookie-parser"; -import { registerRoutes } from "./routes.js"; -import { setupVite, serveStatic } from "./vite.js"; -import { - setupSecurityHeaders, - setupRateLimiting, - setupInputSanitization, - setupCsrfProtection, - setupCsrfTokenEndpoint -} from "./security.js"; - -// Forcer la création de la table webhook_bap_config au démarrage de l'application -if (process.env.NODE_ENV === 'production') { - const { ensureWebhookBapConfigTable } = await import('./createWebhookTable.js'); - await ensureWebhookBapConfigTable(); -} - -// Initialize weather system -console.log('🌤️ [STARTUP] Initializing weather system...'); -const { initializeWeatherConfig } = await import('./weatherAutoConfig.js'); -await initializeWeatherConfig(); -console.log('✅ [STARTUP] Weather system initialized'); - -const app = express(); - -// Compression gzip des réponses, en premier pour couvrir toutes les réponses -app.use(compression()); - -// Parse cookies (required for CSRF) -app.use(cookieParser()); - -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: false, limit: '10mb' })); - -// Setup security middlewares -console.log('🔐 [STARTUP] Setting up security middlewares...'); -setupSecurityHeaders(app); -setupRateLimiting(app); -setupInputSanitization(app); - -// CSRF Protection (only in production to avoid dev friction) -if (process.env.NODE_ENV === 'production') { - setupCsrfProtection(app); - console.log('✅ [STARTUP] CSRF protection enabled'); -} - -// CSRF token endpoint (always available for frontend to fetch token) -setupCsrfTokenEndpoint(app); -console.log('✅ [STARTUP] Security middlewares configured'); - -app.use((req, res, next) => { - const start = Date.now(); - res.on("finish", () => { - const duration = Date.now() - start; - if (req.path.startsWith("/api")) { - console.log(`${req.method} ${req.path} ${res.statusCode} in ${duration}ms`); - } - }); - next(); -}); - -const server = await registerRoutes(app); - -// Tâches de maintenance périodiques (purge du cache factures expiré) -const { startMaintenanceJobs } = await import('./maintenance.js'); -startMaintenanceJobs(); - -app.use((err: any, _req: Request, res: Response, next: NextFunction) => { - // Réponse déjà commencée : on laisse Express clore la connexion - if (res.headersSent) { - return next(err); - } - const status = err.status || err.statusCode || 500; - const message = err.message || "Internal Server Error"; - console.error('Server error:', { status, message, error: err }); - res.status(status).json({ message }); -}); - -// Setup Vite in development -if (app.get("env") === "development") { - await setupVite(app, server); -} else { - serveStatic(app); -} - -const port = 5000; -server.listen(port, "0.0.0.0", () => { - console.log(`serving on port ${port}`); -}); diff --git a/server/invoiceVerification.ts b/server/invoiceVerification.ts deleted file mode 100644 index 1b94869..0000000 --- a/server/invoiceVerification.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { sql } from "drizzle-orm"; -import { db } from "./db.js"; -import { storage } from "./storage.js"; - -// Blancs ASCII retirés par String.prototype.trim(), pour reproduire en SQL les clés de cache -const ASCII_WHITESPACE = ' \t\n\v\f\r'; - -/** - * Service de vérification des factures avec NocoDB - * Gère la vérification automatique des références de factures - */ -export class InvoiceVerificationService { - - /** - * Générer une clé de cache unique - */ - private generateCacheKey(invoiceReference: string, groupId: number): string { - return `${groupId}_${invoiceReference.trim().toLowerCase()}`; - } - - /** - * Vérifier dans le cache d'abord - */ - async checkCache(invoiceReference: string, groupId: number): Promise { - try { - const cacheKey = this.generateCacheKey(invoiceReference, groupId); - const cached = await storage.getInvoiceVerificationCache(cacheKey); - - // ✅ CACHE PERMANENT : Facture validée avec coche verte = JAMAIS re-vérifier - if (cached && cached.isReconciled && cached.exists) { - return { - exists: cached.exists, - matchType: cached.matchType, - errorMessage: cached.errorMessage, - invoiceReference: cached.invoiceReference, - supplierName: cached.supplierName, - invoiceAmount: cached.invoiceAmount ? parseFloat(cached.invoiceAmount) : undefined, - invoiceAmountTTC: cached.invoiceAmountTTC ? parseFloat(cached.invoiceAmountTTC) : undefined, - dueDate: cached.dueDate ? new Date(cached.dueDate).toISOString() : null, - fromCache: true, - permanent: true - }; - } - - // Cache temporaire non expiré - if (cached && new Date() < new Date(cached.expiresAt)) { - return { - exists: cached.exists, - matchType: cached.matchType, - errorMessage: cached.errorMessage, - invoiceReference: cached.invoiceReference, - supplierName: cached.supplierName, - invoiceAmount: cached.invoiceAmount ? parseFloat(cached.invoiceAmount) : undefined, - invoiceAmountTTC: cached.invoiceAmountTTC ? parseFloat(cached.invoiceAmountTTC) : undefined, - dueDate: cached.dueDate ? new Date(cached.dueDate).toISOString() : null, - fromCache: true, - permanent: false - }; - } - - return null; - } catch (error) { - console.error('❌ [CACHE] Erreur lecture cache:', error); - return null; - } - } - - /** - * Sauvegarder dans le cache avec durée adaptative - */ - async saveToCache(invoiceReference: string, groupId: number, result: any, supplierName?: string, isReconciled: boolean = false): Promise { - try { - const cacheKey = this.generateCacheKey(invoiceReference, groupId); - - // Durée de cache adaptative selon les cas - const expiresAt = new Date(); - - if (isReconciled) { - // Cache PERMANENT pour factures validées - expire dans 50 ans - expiresAt.setFullYear(expiresAt.getFullYear() + 50); - } else if (result.exists) { - // Facture trouvée mais non validée - cache 6h pour permettre corrections - expiresAt.setHours(expiresAt.getHours() + 6); - } else { - // Facture non trouvée - cache 12h pour éviter spam - expiresAt.setHours(expiresAt.getHours() + 12); - } - - const cacheData = { - cacheKey, - groupId, - invoiceReference, - supplierName: supplierName || null, - invoiceAmount: result.invoiceAmount || null, - invoiceAmountTTC: result.invoiceAmountTTC || null, - dueDate: result.dueDate ? new Date(result.dueDate) : null, - exists: result.exists, - matchType: result.matchType, - errorMessage: result.errorMessage || null, - cacheHit: false, - apiCallTime: null, - isReconciled, // Nouveau champ pour cache permanent - expiresAt - }; - - await storage.saveInvoiceVerificationCache(cacheData); - } catch (error) { - // Gérer spécifiquement les erreurs de contrainte unique (duplicate key) - if (error && typeof error === 'object' && 'code' in error && error.code === '23505') { - console.log('🔄 [CACHE] Cache déjà existant pour cette clé, ignoré:', { invoiceReference, groupId }); - return; - } - console.error('❌ [CACHE] Erreur sauvegarde cache:', error); - console.error('❌ [CACHE] Détails erreur:', { - name: error instanceof Error ? error.name : 'Unknown', - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : 'No stack' - }); - } - } - - /** - * Met à jour le cache pour marquer une facture comme réconciliée (cache permanent) - */ - async updateCacheAsReconciled(invoiceReference: string, groupId: number): Promise { - try { - const cacheKey = this.generateCacheKey(invoiceReference, groupId); - console.log('🔄 [CACHE] Mise à jour cache comme réconcilié:', { invoiceReference, groupId, cacheKey }); - - // Récupérer le cache existant - const cached = await storage.getInvoiceVerificationCache(cacheKey); - if (!cached) { - console.log('❌ [CACHE] Aucun cache trouvé à mettre à jour'); - return; - } - - // Si déjà réconcilié, ne rien faire - if (cached.isReconciled) { - console.log('✅ [CACHE] Cache déjà marqué comme réconcilié'); - return; - } - - // Mettre à jour avec cache permanent (50 ans) - const expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 50); - - const updatedCacheData = { - ...cached, - isReconciled: true, - expiresAt - }; - - await storage.saveInvoiceVerificationCache(updatedCacheData); - - console.log('✅ [CACHE] Cache mis à jour comme PERMANENT:', { - invoiceReference, - groupId, - cacheKey, - newExpiresAt: expiresAt.toISOString() - }); - } catch (error) { - console.error('❌ [CACHE] Erreur mise à jour cache réconcilié:', error); - } - } - - /** - * Met à jour tous les caches des livraisons déjà validées pour les marquer comme permanents - */ - async updateExistingReconciledCaches(): Promise { - try { - console.log('🔄 [CACHE] Mise à jour en lot des caches pour livraisons validées...'); - - // Base PostgreSQL : une seule requête ensembliste au lieu de charger toutes - // les livraisons puis 2 requêtes (lecture + upsert) par référence - if (db) { - const updatedCount = await this.markReconciledCachesInDatabase(); - console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`); - return; - } - - // Stockage mémoire (développement sans base) : parcours livraison par livraison - // Récupérer toutes les livraisons validées (reconciled = true) - const deliveries = await storage.getDeliveries(); - const reconciledDeliveries = deliveries.filter(d => d.reconciled); - - console.log(`📊 [CACHE] ${reconciledDeliveries.length} livraisons validées trouvées`); - - let updatedCount = 0; - for (const delivery of reconciledDeliveries) { - try { - // Mettre à jour le cache pour la référence de facture - if (delivery.invoiceReference && delivery.invoiceReference.trim()) { - await this.updateCacheAsReconciled(delivery.invoiceReference, delivery.groupId); - updatedCount++; - } - - // Mettre à jour le cache pour le numéro BL - if (delivery.blNumber && delivery.blNumber.trim()) { - await this.updateCacheAsReconciled(delivery.blNumber, delivery.groupId); - updatedCount++; - } - } catch (error) { - console.error(`❌ [CACHE] Erreur mise à jour cache pour livraison ${delivery.id}:`, error); - } - } - - console.log(`✅ [CACHE] Mise à jour terminée: ${updatedCount} caches mis à jour comme permanents`); - } catch (error) { - console.error('❌ [CACHE] Erreur lors de la mise à jour en lot:', error); - } - } - - /** - * Équivalent SQL de updateCacheAsReconciled appliqué à toutes les livraisons validées. - * Mêmes clés que generateCacheKey (référence facture et numéro de BL non vides après - * trim), mêmes conditions (entrée présente, non expirée, pas encore réconciliée) et - * mêmes colonnes modifiées que l'upsert de DatabaseStorage : is_reconciled et - * updated_at (expires_at n'y est pas mis à jour). Écart accepté : seuls les blancs - * ASCII sont retirés, et lower() suit la locale de la base. - * Retourne le nombre d'entrées de cache mises à jour. - */ - private async markReconciledCachesInDatabase(): Promise { - // Heure UTC, comme drizzle écrit et relit les colonnes timestamp - const now = new Date().toISOString(); - const result = await db.execute(sql` - UPDATE invoice_verification_cache - SET is_reconciled = true, updated_at = ${now}::timestamp - WHERE is_reconciled IS NOT TRUE - AND expires_at > ${now}::timestamp - AND cache_key IN ( - SELECT group_id::text || '_' || lower(btrim(invoice_reference, ${ASCII_WHITESPACE})) - FROM deliveries - WHERE reconciled = true AND btrim(invoice_reference, ${ASCII_WHITESPACE}) <> '' - UNION - SELECT group_id::text || '_' || lower(btrim(bl_number, ${ASCII_WHITESPACE})) - FROM deliveries - WHERE reconciled = true AND btrim(bl_number, ${ASCII_WHITESPACE}) <> '' - ) - `); - return result?.rowCount ?? 0; - } - - /** - * Vérifie une référence de facture pour un groupe donné - */ - async verifyInvoice(invoiceReference: string, groupId: number, forceRefresh: boolean = false, isReconciled: boolean = false): Promise<{ - exists: boolean; - matchType: 'invoice_reference' | 'bl_number' | 'none'; - errorMessage?: string; - invoiceReference?: string; - invoiceAmount?: number; - invoiceAmountTTC?: number; - supplierName?: string; - dueDate?: string | null; - fromCache?: boolean; - }> { - try { - if (!invoiceReference || !invoiceReference.trim()) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Référence de facture vide' - }; - } - - // Vérifier le cache d'abord (sauf si refresh forcé) - if (!forceRefresh) { - const cachedResult = await this.checkCache(invoiceReference, groupId); - if (cachedResult) { - return cachedResult; - } - } - - // Récupérer la configuration du groupe - const group = await storage.getGroup(groupId); - if (!group) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Groupe non trouvé' - }; - } - - // Si pas de configuration NocoDB, retourner un résultat par défaut - if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Configuration NocoDB manquante pour ce magasin' - }; - } - - // Pour le développement, simuler une vérification - if (process.env.NODE_ENV === 'development') { - console.log('🔧 Mode développement - simulation vérification'); - - // Simuler différents cas selon la référence - if (invoiceReference.toLowerCase().includes('test')) { - const result = { - exists: true, - matchType: 'invoice_reference' as const, - invoiceReference: invoiceReference, - invoiceAmount: 123.45, - supplierName: 'Fournisseur Test' - }; - - // Sauvegarder en cache - await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled); - return result; - } - - if (invoiceReference.toLowerCase().includes('bl')) { - const result = { - exists: true, - matchType: 'bl_number' as const, - invoiceReference: `FACT_${invoiceReference}`, - invoiceAmount: 67.89, - supplierName: 'Fournisseur BL' - }; - - // Sauvegarder en cache - await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled); - return result; - } - - const result = { - exists: false, - matchType: 'none' as const, - errorMessage: 'Facture non trouvée (mode développement)' - }; - - // Sauvegarder même les résultats "non trouvé" pour éviter les rappels répétés - await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled); - return result; - } - - // En production, faire l'appel réel à NocoDB - try { - // Récupérer la configuration NocoDB active - const nocodbConfig = await storage.getActiveNocodbConfig(); - if (!nocodbConfig) { - console.log('⚠️ Pas de configuration NocoDB active'); - return { - exists: false, - matchType: 'none', - errorMessage: 'Configuration NocoDB non trouvée' - }; - } - - // Utiliser l'ID de table configuré dans le groupe - const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; // Fallback par défaut - - // Vérifier d'abord par référence de facture - let matchResult = await this.searchInNocoDB( - invoiceReference, - group.invoiceColumnName || 'RefFacture', - nocodbConfig, - tableId - ); - - // Gérer les erreurs d'API spécifiques - if (matchResult.error) { - const result = { - exists: false, - matchType: 'none' as const, - errorMessage: matchResult.error - }; - - // Sauvegarder en cache même les erreurs pour éviter les rappels répétés - await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled); - return result; - } - - if (matchResult.found) { - const result = { - exists: true, - matchType: 'invoice_reference' as const, - invoiceReference: matchResult.data.invoice_reference || invoiceReference, - invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'), - invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'), - supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu', - dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null - }; - - // Sauvegarder en cache si succès - await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled); - return result; - } - - // Si pas trouvé par référence de facture, chercher par numéro de BL - if (group.nocodbBlColumnName) { - matchResult = await this.searchInNocoDB( - invoiceReference, - group.nocodbBlColumnName || 'Numero_BL', - nocodbConfig, - tableId - ); - - // Gérer les erreurs d'API pour la recherche BL - if (matchResult.error) { - const result = { - exists: false, - matchType: 'none' as const, - errorMessage: matchResult.error - }; - - // Sauvegarder en cache même les erreurs - await this.saveToCache(invoiceReference, groupId, result, undefined, isReconciled); - return result; - } - - if (matchResult.found) { - const result = { - exists: true, - matchType: 'bl_number' as const, - invoiceReference: matchResult.data.invoice_reference || `BL_${invoiceReference}`, - invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'), - invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'), - supplierName: matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || 'Inconnu', - dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null - }; - - // Sauvegarder en cache si succès - await this.saveToCache(invoiceReference, groupId, result, result.supplierName, isReconciled); - return result; - } - } - - // Aucune correspondance trouvée - const notFoundResult = { - exists: false, - matchType: 'none' as const, - errorMessage: 'Aucune correspondance trouvée dans NocoDB' - }; - - // Sauvegarder même les résultats "non trouvé" pour éviter les rappels répétés - await this.saveToCache(invoiceReference, groupId, notFoundResult, undefined, isReconciled); - return notFoundResult; - - } catch (error) { - const errorMessage = `Erreur NocoDB: ${error instanceof Error ? error.message : 'Erreur inconnue'}`; - - // Log condensé en production - if (process.env.NODE_ENV === 'production') { - console.error('❌ Erreur vérification NocoDB:', error instanceof Error ? error.message : 'Erreur inconnue'); - } else { - console.error('❌ Erreur lors de la vérification NocoDB:', error); - } - - const errorResult = { - exists: false, - matchType: 'none' as const, - errorMessage - }; - - // Sauvegarder les erreurs en cache pour éviter les rappels répétés - await this.saveToCache(invoiceReference, groupId, errorResult, undefined, isReconciled); - return errorResult; - } - - } catch (error) { - console.error('❌ Erreur vérification facture:', error); - return { - exists: false, - matchType: 'none', - errorMessage: error instanceof Error ? error.message : 'Erreur inconnue' - }; - } - } - - /** - * Recherche dans NocoDB avec protection contre les erreurs - */ - private async searchInNocoDB( - searchValue: string, - columnName: string, - config: any, - tableId: string - ): Promise<{ found: boolean; data?: any; error?: string }> { - try { - // Protection contre les appels avec des paramètres invalides - if (!searchValue || !columnName || !config || !tableId) { - return { found: false, error: 'Paramètres invalides' }; - } - - // Utiliser directement l'ID de table fourni - const searchUrl = `${config.baseUrl}/api/v1/db/data/v1/${config.projectId}/${tableId}`; - - // Log condensé en production pour éviter le spam - if (process.env.NODE_ENV === 'production') { - console.log('🔍 Recherche NocoDB:', { tableId, column: columnName }); - } else { - console.log('🔍 Recherche NocoDB:', { - url: searchUrl, - tableId: tableId, - column: columnName, - value: searchValue - }); - } - - // Créer un AbortController pour le timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 secondes timeout - - try { - const response = await fetch(`${searchUrl}?where=(${columnName},eq,${encodeURIComponent(searchValue)})`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'xc-token': config.apiToken - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorMsg = `HTTP ${response.status}: ${response.statusText}`; - console.error('❌ Erreur réponse NocoDB:', errorMsg); - return { found: false, error: errorMsg }; - } - - const result = await response.json(); - - // Log condensé en production - if (process.env.NODE_ENV !== 'production') { - console.log('✅ Réponse NocoDB:', result); - } - - if (result.list && result.list.length > 0) { - return { - found: true, - data: result.list[0] - }; - } - - return { found: false }; - - } catch (fetchError: any) { - clearTimeout(timeoutId); - - // Gestion spécifique des erreurs réseau - if (fetchError.name === 'AbortError') { - const timeoutMsg = 'Timeout - API NocoDB non accessible'; - console.error('⏱️ Timeout NocoDB:', timeoutMsg); - return { found: false, error: timeoutMsg }; - } - - if (fetchError.code === 'ERR_CERT_VERIFIER_CHANGED' || fetchError.message?.includes('certificate')) { - const certMsg = 'Erreur certificat SSL - Vérifiez la configuration NocoDB'; - console.error('🔒 Erreur certificat NocoDB:', certMsg); - return { found: false, error: certMsg }; - } - - if (fetchError.code === 'ENOTFOUND' || fetchError.code === 'ECONNREFUSED') { - const connMsg = 'NocoDB non accessible - Vérifiez la connectivité réseau'; - console.error('🌐 Erreur connexion NocoDB:', connMsg); - return { found: false, error: connMsg }; - } - - throw fetchError; // Relancer les autres erreurs - } - - } catch (error: any) { - // Log d'erreur condensé en production - const errorMsg = error?.message || 'Erreur inconnue'; - if (process.env.NODE_ENV === 'production') { - console.error('❌ Erreur NocoDB:', errorMsg); - } else { - console.error('❌ Erreur appel NocoDB:', error); - } - - return { found: false, error: errorMsg }; - } - } - - /** - * Vérifie une facture par numéro de BL et nom de fournisseur - */ - async verifyInvoiceByBL(blNumber: string, supplierName: string, groupId: number, forceRefresh: boolean = false, isReconciled: boolean = false): Promise<{ - exists: boolean; - matchType: 'invoice_reference' | 'bl_number' | 'none'; - errorMessage?: string; - invoiceReference?: string; - invoiceAmount?: number; - invoiceAmountTTC?: number; - supplierName?: string; - dueDate?: string | null; - fromCache?: boolean; - }> { - try { - if (!blNumber || !blNumber.trim()) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Numéro BL vide' - }; - } - - // Générer une clé de cache pour le BL - const cacheKey = `bl_${groupId}_${blNumber.trim().toLowerCase()}_${supplierName.toLowerCase()}`; - - // Vérifier le cache d'abord (sauf si refresh forcé) - if (!forceRefresh) { - try { - const cached = await storage.getInvoiceVerificationCache(cacheKey); - if (cached && new Date() < new Date(cached.expiresAt)) { - return { - exists: cached.exists, - matchType: cached.matchType as 'invoice_reference' | 'bl_number' | 'none', - errorMessage: cached.errorMessage || undefined, - invoiceReference: cached.invoiceReference || undefined, - supplierName: cached.supplierName || undefined, - fromCache: true - }; - } - } catch (error) { - console.error('❌ Erreur lecture cache BL:', error); - } - } - - // Récupérer la configuration du groupe - const group = await storage.getGroup(groupId); - if (!group) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Groupe non trouvé' - }; - } - - // Si pas de configuration NocoDB, retourner un résultat par défaut - if (!group.nocodbConfigId && !group.nocodbTableName && !group.webhookUrl) { - return { - exists: false, - matchType: 'none', - errorMessage: 'Configuration NocoDB manquante pour ce magasin' - }; - } - - // Pour le développement, simuler une vérification - if (process.env.NODE_ENV === 'development') { - console.log('🔧 Mode développement - simulation vérification BL'); - - if (blNumber && blNumber.trim()) { - const result = { - exists: true, - matchType: 'bl_number' as const, - invoiceReference: `FACT_${blNumber}`, - invoiceAmount: 156.78, - supplierName: supplierName - }; - - // Sauvegarder en cache - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - await storage.saveInvoiceVerificationCache({ - cacheKey, - groupId, - invoiceReference: result.invoiceReference, - supplierName: supplierName, - exists: result.exists, - matchType: result.matchType, - errorMessage: null, - cacheHit: false, - apiCallTime: null, - expiresAt - }); - } catch (error) { - console.error('❌ Erreur sauvegarde cache BL:', error); - } - - return result; - } - - return { - exists: false, - matchType: 'none', - errorMessage: 'BL non trouvé (mode développement)' - }; - } - - // En production, faire l'appel réel à NocoDB - try { - // Récupérer la configuration NocoDB active - const nocodbConfig = await storage.getActiveNocodbConfig(); - if (!nocodbConfig) { - console.log('⚠️ Pas de configuration NocoDB active'); - return { - exists: false, - matchType: 'none', - errorMessage: 'Configuration NocoDB non trouvée' - }; - } - - // Utiliser l'ID de table configuré dans le groupe - const tableId = group.nocodbTableId || 'mrr733dfb8wtt9b'; - - // Rechercher par numéro de BL - const blColumnName = group.nocodbBlColumnName || 'Numero_BL'; - let matchResult = await this.searchInNocoDB( - blNumber, - blColumnName, - nocodbConfig, - tableId - ); - - // Gérer les erreurs d'API spécifiques - if (matchResult.error) { - const result = { - exists: false, - matchType: 'none' as const, - errorMessage: matchResult.error - }; - - // Sauvegarder en cache même les erreurs - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - await storage.saveInvoiceVerificationCache({ - cacheKey, - groupId, - invoiceReference: blNumber, - supplierName: supplierName, - exists: false, - matchType: 'none', - errorMessage: result.errorMessage, - cacheHit: false, - apiCallTime: null, - isReconciled, - expiresAt - }); - } catch (error) { - console.error('❌ Erreur sauvegarde cache erreur BL:', error); - } - - return result; - } - - if (matchResult.found) { - // Vérifier que le fournisseur correspond - const foundSupplier = matchResult.data[group.nocodbSupplierColumnName || 'supplier'] || ''; - const supplierMatch = foundSupplier.toLowerCase().includes(supplierName.toLowerCase()) || - supplierName.toLowerCase().includes(foundSupplier.toLowerCase()); - - if (!supplierMatch) { - console.log('⚠️ Fournisseur ne correspond pas:', { - expected: supplierName, - found: foundSupplier - }); - return { - exists: false, - matchType: 'none', - errorMessage: `BL trouvé mais fournisseur différent: ${foundSupplier} vs ${supplierName}` - }; - } - - const result = { - exists: true, - matchType: 'bl_number' as const, - invoiceReference: matchResult.data[group.invoiceColumnName || 'RefFacture'] || `BL_${blNumber}`, - invoiceAmount: parseFloat(matchResult.data[group.nocodbAmountColumnName || 'amount'] || '0'), - invoiceAmountTTC: parseFloat(matchResult.data[group.nocodbInvoiceAmountTTCColumnName || 'Montant TTC'] || '0'), - supplierName: foundSupplier || supplierName, - dueDate: matchResult.data[group.nocodbDueDateColumnName || 'Echeance'] || null - }; - - // Sauvegarder en cache si succès - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - await storage.saveInvoiceVerificationCache({ - cacheKey, - groupId, - invoiceReference: result.invoiceReference, - supplierName: result.supplierName, - exists: true, - matchType: 'bl_number', - errorMessage: null, - cacheHit: false, - apiCallTime: null, - isReconciled, - expiresAt - }); - } catch (error) { - console.error('❌ Erreur sauvegarde cache succès BL:', error); - } - - return result; - } - - // Aucune correspondance trouvée - const notFoundResult = { - exists: false, - matchType: 'none' as const, - errorMessage: 'Aucun BL correspondant trouvé dans NocoDB' - }; - - // Sauvegarder même les résultats "non trouvé" - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - await storage.saveInvoiceVerificationCache({ - cacheKey, - groupId, - invoiceReference: blNumber, - supplierName: supplierName, - exists: false, - matchType: 'none', - errorMessage: notFoundResult.errorMessage, - cacheHit: false, - apiCallTime: null, - isReconciled, - expiresAt - }); - } catch (error) { - console.error('❌ Erreur sauvegarde cache not found BL:', error); - } - - return notFoundResult; - - } catch (error) { - const errorMessage = `Erreur NocoDB BL: ${error instanceof Error ? error.message : 'Erreur inconnue'}`; - - console.error('❌ Erreur lors de la vérification BL NocoDB:', error); - - const errorResult = { - exists: false, - matchType: 'none' as const, - errorMessage - }; - - // Sauvegarder les erreurs en cache - try { - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + 24); - - await storage.saveInvoiceVerificationCache({ - cacheKey, - groupId, - invoiceReference: blNumber, - supplierName: supplierName, - exists: false, - matchType: 'none', - errorMessage: errorResult.errorMessage, - cacheHit: false, - apiCallTime: null, - isReconciled, - expiresAt - }); - } catch (error) { - console.error('❌ Erreur sauvegarde cache erreur BL:', error); - } - - return errorResult; - } - - } catch (error) { - console.error('❌ Erreur vérification facture par BL:', error); - return { - exists: false, - matchType: 'none', - errorMessage: error instanceof Error ? error.message : 'Erreur inconnue' - }; - } - } - - /** - * Recherche par numéro de BL (méthode simplifiée pour compatibilité) - */ - async searchByBLNumber(blNumber: string, groupId: number): Promise { - console.log('🔍 Recherche par BL (legacy):', { blNumber, groupId }); - - const result = await this.verifyInvoiceByBL(blNumber, '', groupId, false); - - if (result.exists) { - return { - found: true, - data: { - invoiceReference: result.invoiceReference, - amount: result.invoiceAmount, - supplier: result.supplierName - } - }; - } else { - return { - found: false, - error: result.errorMessage || 'BL non trouvé' - }; - } - } - - /** - * Validation d'une référence de facture - */ - validateInvoiceReference(reference: string): boolean { - if (!reference || typeof reference !== 'string') { - return false; - } - - const trimmed = reference.trim(); - return trimmed.length > 0 && trimmed.length <= 100; - } - - /** - * Nettoyage d'une référence de facture - */ - cleanInvoiceReference(reference: string): string { - if (!reference || typeof reference !== 'string') { - return ''; - } - - return reference.trim().toUpperCase(); - } -} - -// Instance unique du service -export const invoiceVerificationService = new InvoiceVerificationService(); \ No newline at end of file diff --git a/server/localAuth.production.ts b/server/localAuth.production.ts deleted file mode 100644 index 7afa4b6..0000000 --- a/server/localAuth.production.ts +++ /dev/null @@ -1,285 +0,0 @@ -import passport from "passport"; -import { Strategy as LocalStrategy } from "passport-local"; -import type { Express } from "express"; -import session from "express-session"; -import { storage } from "./storage.js"; -import connectPg from "connect-pg-simple"; -import { scrypt, randomBytes, timingSafeEqual } from "crypto"; -import { promisify } from "util"; -import { backupService } from "./backupService.js"; - -const scryptAsync = promisify(scrypt); - -console.log('🐳 PRODUCTION: Local auth configured with PostgreSQL sessions'); - -async function hashPassword(password: string) { - const salt = randomBytes(16).toString("hex"); - const buf = (await scryptAsync(password, salt, 64)) as Buffer; - return `${buf.toString("hex")}.${salt}`; -} - -async function comparePasswords(supplied: string, stored: string) { - console.log('🔐 Production password comparison', { suppliedLength: supplied.length, storedFormat: stored?.substring(0, 10) + '...' }); - - // Try different password formats for backward compatibility - - // 1. Try new format: hash.salt - if (stored && stored.includes('.')) { - const [hashed, salt] = stored.split("."); - if (hashed && salt) { - try { - const hashedBuf = Buffer.from(hashed, "hex"); - const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer; - const result = timingSafeEqual(hashedBuf, suppliedBuf); - if (result) { - console.log('✅ Password matched with hash.salt format'); - return true; - } - } catch (error) { - console.log('⚠️ hash.salt format failed, trying other formats'); - } - } - } - - // 2. Try bcrypt format (common in existing systems) - if (stored && stored.startsWith('$2')) { - try { - const bcrypt = await import('bcrypt'); - const result = await bcrypt.compare(supplied, stored); - if (result) { - console.log('✅ Password matched with bcrypt format'); - return true; - } - } catch (error) { - console.log('⚠️ bcrypt comparison failed'); - } - } - - // 3. Try plain text (for development/migration) - if (stored === supplied) { - console.log('✅ Password matched with plain text format'); - return true; - } - - // 4. Try simple hash format (legacy) - try { - const crypto = await import('crypto'); - const hash = crypto.createHash('sha256').update(supplied).digest('hex'); - if (stored === hash) { - console.log('✅ Password matched with SHA256 format'); - return true; - } - } catch (error) { - console.log('⚠️ SHA256 comparison failed'); - } - - console.log('❌ No password format matched'); - return false; -} - -async function createDefaultAdminUser() { - try { - // Check for force reset flag - const forceReset = process.env.FORCE_ADMIN_RESET === 'true'; - if (forceReset) { - console.log('🔄 FORCE_ADMIN_RESET detected, deleting existing admin user...'); - const existingAdmin = await storage.getUserByUsername('admin'); - if (existingAdmin) { - await storage.deleteUser(existingAdmin.id); - console.log('✅ Existing admin user deleted'); - } - } - - const existingAdmin = await storage.getUserByUsername('admin'); - if (!existingAdmin) { - const hashedPassword = await hashPassword('admin'); - await storage.createUser({ - id: 'admin_prod', - username: 'admin', - email: 'admin@logiflow.com', - firstName: 'Administrateur', - lastName: 'Production', - password: hashedPassword, - role: 'admin', - passwordChanged: false, - }); - console.log('✅ Production admin user created: admin/admin'); - } else { - console.log('✅ Production admin user found:', { - id: existingAdmin.id, - username: existingAdmin.username, - passwordFormat: existingAdmin.password ? 'present' : 'missing' - }); - - // Ne plus forcer la réinitialisation du mot de passe admin - // L'admin peut maintenant changer son mot de passe et il sera conservé - console.log('✅ Admin user exists, preserving current password'); - - // Seulement réinitialiser si explicitement demandé via FORCE_ADMIN_RESET - if (process.env.FORCE_ADMIN_RESET === 'true') { - console.log('🔄 FORCE_ADMIN_RESET env var set, resetting admin password...'); - try { - const newHashedPassword = await hashPassword('admin'); - await storage.updateUser(existingAdmin.id, { - password: newHashedPassword, - passwordChanged: false - }); - console.log('✅ Admin password manually reset to: admin/admin'); - } catch (error) { - console.error('❌ Failed to reset admin password:', (error as Error).message); - } - } - } - } catch (error) { - console.error('Error managing admin user:', error); - } -} - -export function setupLocalAuth(app: Express) { - // Create admin user on startup - createDefaultAdminUser(); - - const PostgresSessionStore = connectPg(session); - const sessionStore = new PostgresSessionStore({ - conString: process.env.DATABASE_URL, - createTableIfMissing: true, - tableName: 'session', - }); - - const sessionSettings: session.SessionOptions = { - secret: process.env.SESSION_SECRET || 'production-fallback-secret-key', - resave: false, - saveUninitialized: false, - store: sessionStore, - cookie: { - httpOnly: true, - secure: false, // Set to true with HTTPS proxy - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }, - }; - - app.set("trust proxy", 1); - app.use(session(sessionSettings)); - app.use(passport.initialize()); - app.use(passport.session()); - - passport.use( - new LocalStrategy( - { - usernameField: 'username', - passwordField: 'password', - }, - async (username, password, done) => { - try { - const user = await storage.getUserByUsername(username); - if (!user || !user.password) { - return done(null, false, { message: 'Invalid credentials' }); - } - - const isValidPassword = await comparePasswords(password, user.password); - if (!isValidPassword) { - return done(null, false, { message: 'Invalid credentials' }); - } - - return done(null, user); - } catch (error) { - return done(error); - } - } - ) - ); - - passport.serializeUser((user, done) => done(null, user.id)); - passport.deserializeUser(async (id: string, done) => { - try { - const user = await storage.getUserWithGroups(id); - done(null, user); - } catch (error) { - done(error); - } - }); - - // Login route - app.post("/api/login", (req, res, next) => { - passport.authenticate("local", (err: any, user: any, info: any) => { - if (err) return next(err); - if (!user) { - return res.status(400).json({ message: info?.message || "Invalid credentials" }); - } - - req.login(user, async (err) => { - if (err) return next(err); - - // Vérifier et effectuer une sauvegarde quotidienne si nécessaire - try { - const backupResult = await backupService.checkAndPerformDailyBackup(user.id); - if (backupResult.backupPerformed) { - console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username); - } - } catch (error) { - console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error); - // Ne pas faire échouer la connexion si la sauvegarde échoue - } - - res.json({ - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - passwordChanged: user.passwordChanged - }); - }); - })(req, res, next); - }); - - // Logout route - app.post("/api/logout", (req: any, res: any, next: any) => { - req.logout((err: any) => { - if (err) return next(err); - res.json({ message: "Logout successful" }); - }); - }); - - // Get current user - app.get("/api/user", (req: any, res) => { - if (req.isAuthenticated && req.isAuthenticated()) { - console.log("🔍 PRODUCTION /api/user - req.user:", { - id: req.user.id, - role: req.user.role, - hasUserGroups: !!req.user.userGroups, - userGroupsLength: req.user.userGroups?.length, - userGroups: req.user.userGroups?.map((ug: any) => ({ - groupId: ug.groupId || ug.group?.id, - groupName: ug.group?.name - })) - }); - - res.json({ - id: req.user.id, - username: req.user.username, - email: req.user.email, - firstName: req.user.firstName, - lastName: req.user.lastName, - role: req.user.role, - passwordChanged: req.user.passwordChanged, - userGroups: req.user.userGroups || [] // ✅ AJOUT DES USER GROUPS ! - }); - } else { - res.status(401).json({ message: "Not authenticated" }); - } - }); - - // Check default credentials endpoint - app.get("/api/default-credentials-check", (req, res) => { - res.json({ hasDefaultCredentials: true }); - }); -} - -export function requireAuth(req: any, res: any, next: any) { - if (req.isAuthenticated && req.isAuthenticated()) { - return next(); - } - res.status(401).json({ message: "Authentication required" }); -} \ No newline at end of file diff --git a/server/localAuth.ts b/server/localAuth.ts deleted file mode 100644 index 450482f..0000000 --- a/server/localAuth.ts +++ /dev/null @@ -1,253 +0,0 @@ -import passport from "passport"; -import { Strategy as LocalStrategy } from "passport-local"; -import { Express } from "express"; -import session from "express-session"; -import { scrypt, randomBytes, timingSafeEqual } from "crypto"; -import { promisify } from "util"; -import { storage } from "./storage"; -import { User as SelectUser, type UserWithGroups } from "@shared/schema"; -import connectPg from "connect-pg-simple"; -import { backupService } from "./backupService"; - -declare global { - namespace Express { - interface User extends SelectUser {} - } -} - -const scryptAsync = promisify(scrypt); - -export async function hashPassword(password: string) { - const salt = randomBytes(16).toString("hex"); - const buf = (await scryptAsync(password, salt, 64)) as Buffer; - return `${buf.toString("hex")}.${salt}`; -} - -async function comparePasswords(supplied: string, stored: string) { - console.log('🔐 comparePasswords:', { supplied: 'HIDDEN', stored: stored?.substring(0, 20) + '...', hasFormat: stored?.includes('.') }); - - // Simple password comparison for development (admin.salt format) - if (stored === 'admin.salt' && supplied === 'admin') { - console.log('🔐 Development auth: admin password accepted'); - return true; - } - - // Vérifier le format du mot de passe stocké pour production - if (!stored || !stored.includes('.')) { - console.error('❌ Invalid password format:', { stored }); - return false; - } - - const [hashed, salt] = stored.split("."); - if (!hashed || !salt) { - console.error('❌ Missing hash or salt:', { hasHash: !!hashed, hasSalt: !!salt }); - return false; - } - - try { - const hashedBuf = Buffer.from(hashed, "hex"); - const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer; - const result = timingSafeEqual(hashedBuf, suppliedBuf); - console.log('🔐 Password comparison result:', result); - return result; - } catch (error) { - console.error('❌ Error comparing passwords:', error); - return false; - } -} - -async function createDefaultAdminUser() { - try { - const existingAdmin = await storage.getUserByUsername('admin'); - if (!existingAdmin) { - const hashedPassword = await hashPassword('admin'); - await storage.createUser({ - id: 'admin_local', - username: 'admin', - email: 'admin@logiflow.com', - firstName: 'Administrateur', - lastName: 'Système', - password: hashedPassword, - role: 'admin', - passwordChanged: false, - }); - console.log('✅ Admin user created: admin / admin'); - } else { - // Corriger le mot de passe admin si le format est incorrect - if (!existingAdmin.password || !existingAdmin.password.includes('.')) { - console.log('🔧 Fixing admin password format...'); - const hashedPassword = await hashPassword('admin'); - await storage.updateUser(existingAdmin.id, { - password: hashedPassword, - passwordChanged: false - }); - console.log('✅ Admin password format fixed'); - } - } - } catch (error) { - console.error('Error managing admin user:', error); - } -} - -export function setupLocalAuth(app: Express) { - // Create admin user on startup - createDefaultAdminUser(); - - console.log('🔧 Using memory session store for development'); - - const sessionSettings: session.SessionOptions = { - secret: process.env.SESSION_SECRET || 'fallback-secret-key', - resave: false, - saveUninitialized: false, - // Using default memory store (no database needed) - cookie: { - httpOnly: true, - secure: false, // Keep false for development - maxAge: 24 * 60 * 60 * 1000, // 24 hours - }, - }; - - app.use(session(sessionSettings)); - app.use(passport.initialize()); - app.use(passport.session()); - - passport.use( - new LocalStrategy( - { - usernameField: 'username', - passwordField: 'password', - }, - async (username, password, done) => { - try { - const user = await storage.getUserByUsername(username); - if (!user || !user.password) { - return done(null, false, { message: 'Identifiant ou mot de passe incorrect' }); - } - - const isValidPassword = await comparePasswords(password, user.password); - if (!isValidPassword) { - return done(null, false, { message: 'Identifiant ou mot de passe incorrect' }); - } - - return done(null, user); - } catch (error) { - return done(error); - } - } - ) - ); - - passport.serializeUser((user, done) => done(null, user.id)); - passport.deserializeUser(async (id: string, done) => { - try { - const user = await storage.getUserWithGroups(id); - // Utilisateur supprimé depuis la connexion : false vide la session (401) - // au lieu de l'erreur « Failed to deserialize user out of session » (500) - done(null, user || false); - } catch (error) { - done(error); - } - }); - - - - // Login route - app.post("/api/login", (req, res, next) => { - passport.authenticate("local", (err: any, user: any, info: any) => { - if (err) return next(err); - if (!user) { - return res.status(400).json({ message: info?.message || "Invalid credentials" }); - } - - req.login(user, (err) => { - if (err) return next(err); - - res.json({ - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - passwordChanged: user.passwordChanged - }); - - // Vérifier et effectuer une sauvegarde quotidienne si nécessaire, après - // la réponse : la connexion n'attend plus le pg_dump - setImmediate(() => { - backupService.checkAndPerformDailyBackup(user.id) - .then((backupResult) => { - if (backupResult.backupPerformed) { - console.log('💾 Sauvegarde quotidienne effectuée lors de la connexion de', user.username); - } - }) - .catch((error) => { - // Ne pas faire échouer la connexion si la sauvegarde échoue - console.error('⚠️ Erreur lors de la vérification de sauvegarde:', error); - }); - }); - }); - })(req, res, next); - }); - - // Logout routes (both GET and POST for compatibility) - const logoutHandler = (req: any, res: any, next: any) => { - req.logout((err: any) => { - if (err) return next(err); - req.session.destroy((err: any) => { - if (err) return next(err); - res.clearCookie('connect.sid', { path: '/' }); - // Force redirect to login page for both GET and POST - res.redirect('/auth'); - }); - }); - }; - - app.post("/api/logout", logoutHandler); - app.get("/api/logout", logoutHandler); - - // Get current user - app.get("/api/user", (req, res) => { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Non authentifié" }); - } - - // req.user est déjà rechargé avec ses magasins par deserializeUser à chaque - // requête : inutile de relire l'utilisateur en base - const userWithGroups = req.user as UserWithGroups; - - res.json({ - id: userWithGroups.id, - username: userWithGroups.username, - email: userWithGroups.email, - firstName: userWithGroups.firstName, - lastName: userWithGroups.lastName, - role: userWithGroups.role, - passwordChanged: userWithGroups.passwordChanged, - userGroups: userWithGroups.userGroups || [] - }); - }); - - // Check if default credentials should be shown - app.get("/api/default-credentials-check", async (req, res) => { - try { - const adminUser = await storage.getUserByUsername('admin'); - const showDefault = adminUser && !adminUser.passwordChanged; - res.json({ showDefault: !!showDefault }); - } catch (error) { - res.json({ showDefault: true }); // Default to showing credentials if error - } - }); - - // Health check endpoint - app.get("/api/health", (req, res) => { - res.status(200).json({ status: "ok", auth: "local" }); - }); -} - -export const requireAuth = (req: any, res: any, next: any) => { - if (!req.isAuthenticated()) { - return res.status(401).json({ message: "Authentification requise" }); - } - next(); -}; \ No newline at end of file diff --git a/server/maintenance.ts b/server/maintenance.ts deleted file mode 100644 index ecc0979..0000000 --- a/server/maintenance.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { storage } from "./storage.js"; - -// Purge périodique du cache de vérification des factures. -// -// La table invoice_verification_cache porte une colonne expires_at et -// storage.clearExpiredCache() sait la purger, mais rien ne l'appelait : les -// entrées expirées s'accumulaient indéfiniment et alourdissaient chaque lecture -// du cache. On planifie donc la purge ici. - -const PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 heures -const STARTUP_DELAY_MS = 60 * 1000; // laisse le démarrage se terminer d'abord - -async function purgeExpiredInvoiceCache(): Promise { - try { - await storage.clearExpiredCache(); - console.log("🧹 [MAINTENANCE] Cache de vérification des factures purgé"); - } catch (error) { - // Une purge ratée ne doit jamais interrompre le service. - console.error("❌ [MAINTENANCE] Échec de la purge du cache factures:", error); - } -} - -export function startMaintenanceJobs(): void { - setTimeout(purgeExpiredInvoiceCache, STARTUP_DELAY_MS); - setInterval(purgeExpiredInvoiceCache, PURGE_INTERVAL_MS); - console.log("✅ [MAINTENANCE] Purge du cache factures planifiée (toutes les 6h)"); -} diff --git a/server/migrations.production.ts b/server/migrations.production.ts deleted file mode 100644 index 24b4b44..0000000 --- a/server/migrations.production.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { Client } from 'pg'; -import { encryptSecret, isEncryptedSecret } from './crypto.js'; -import { pool } from './db.js'; - -// Index de performance garantis à chaque démarrage. -// Union de migrations/20260814_add_performance_indexes.sql, des index d'init.sql -// et des index composites issus de l'audit base de données (DB-14, DB-15, DB-34, -// DB-35, TASKS-29, SAV-27). Tables et colonnes conformes à shared/schema.ts. -// Volontairement absents : IDX_session_expire (table sessions inutilisée), -// pg_trgm, et l'index unique de user_groups (dédoublonnage préalable requis). -type PerformanceIndex = { name: string; table: string; columns: string; where?: string }; - -const PERFORMANCE_INDEXES: PerformanceIndex[] = [ - // user_groups : lu à chaque requête authentifiée via getUserWithGroups() - { name: 'idx_user_groups_user_id', table: 'user_groups', columns: 'user_id' }, - { name: 'idx_user_groups_group_id', table: 'user_groups', columns: 'group_id' }, - - // orders - { name: 'idx_orders_group_id', table: 'orders', columns: 'group_id' }, - { name: 'idx_orders_supplier_id', table: 'orders', columns: 'supplier_id' }, - { name: 'idx_orders_created_by', table: 'orders', columns: 'created_by' }, - { name: 'idx_orders_planned_date', table: 'orders', columns: 'planned_date' }, - { name: 'idx_orders_group_planned_date', table: 'orders', columns: 'group_id, planned_date' }, - { name: 'idx_orders_created_at', table: 'orders', columns: 'created_at DESC' }, - { name: 'idx_orders_status', table: 'orders', columns: 'status' }, - // Comptage des commandes en attente par magasin (statistiques) - { name: 'idx_orders_group_status', table: 'orders', columns: 'group_id, status' }, - - // deliveries : order_id est la clé de jointure la plus sollicitée - { name: 'idx_deliveries_order_id', table: 'deliveries', columns: 'order_id' }, - { name: 'idx_deliveries_group_id', table: 'deliveries', columns: 'group_id' }, - { name: 'idx_deliveries_supplier_id', table: 'deliveries', columns: 'supplier_id' }, - { name: 'idx_deliveries_created_by', table: 'deliveries', columns: 'created_by' }, - { name: 'idx_deliveries_scheduled_date', table: 'deliveries', columns: 'scheduled_date' }, - { name: 'idx_deliveries_group_scheduled_date', table: 'deliveries', columns: 'group_id, scheduled_date' }, - { name: 'idx_deliveries_created_at', table: 'deliveries', columns: 'created_at DESC' }, - { name: 'idx_deliveries_invoice_reference', table: 'deliveries', columns: 'invoice_reference', where: 'invoice_reference IS NOT NULL' }, - { name: 'idx_deliveries_status', table: 'deliveries', columns: 'status' }, - { name: 'idx_deliveries_bl_number', table: 'deliveries', columns: 'bl_number' }, - { name: 'idx_deliveries_due_date', table: 'deliveries', columns: 'due_date' }, - // Livraisons livrées par magasin (rapprochement, statistiques) - { name: 'idx_deliveries_group_status', table: 'deliveries', columns: 'group_id, status' }, - { name: 'idx_deliveries_group_delivered_date', table: 'deliveries', columns: 'group_id, delivered_date' }, - - // reconciliation_comments - { name: 'idx_reconciliation_comments_delivery_id', table: 'reconciliation_comments', columns: 'delivery_id' }, - { name: 'idx_reconciliation_comments_group_id', table: 'reconciliation_comments', columns: 'group_id' }, - - // contacts - { name: 'idx_contacts_group_id', table: 'contacts', columns: 'group_id' }, - - // customer_orders - { name: 'idx_customer_orders_group_id', table: 'customer_orders', columns: 'group_id' }, - { name: 'idx_customer_orders_supplier_id', table: 'customer_orders', columns: 'supplier_id' }, - { name: 'idx_customer_orders_created_at', table: 'customer_orders', columns: 'created_at DESC' }, - { name: 'idx_customer_orders_group_status', table: 'customer_orders', columns: 'group_id, status' }, - // Appels clients en attente : même prédicat que getPendingClientCalls() - { name: 'idx_customer_orders_pending_calls', table: 'customer_orders', columns: 'group_id, created_at DESC', where: 'customer_notified = false' }, - - // dlc_products - { name: 'idx_dlc_products_group_id', table: 'dlc_products', columns: 'group_id' }, - { name: 'idx_dlc_products_supplier_id', table: 'dlc_products', columns: 'supplier_id' }, - { name: 'idx_dlc_products_expiry_date', table: 'dlc_products', columns: 'expiry_date' }, - { name: 'idx_dlc_products_status', table: 'dlc_products', columns: 'status' }, - { name: 'idx_dlc_products_group_status', table: 'dlc_products', columns: 'group_id, status' }, - { name: 'idx_dlc_products_group_expiry', table: 'dlc_products', columns: 'group_id, expiry_date' }, - - // tasks - { name: 'idx_tasks_group_id', table: 'tasks', columns: 'group_id' }, - { name: 'idx_tasks_created_by', table: 'tasks', columns: 'created_by' }, - { name: 'idx_tasks_due_date', table: 'tasks', columns: 'due_date' }, - { name: 'idx_tasks_group_status', table: 'tasks', columns: 'group_id, status' }, - { name: 'idx_tasks_group_created', table: 'tasks', columns: 'group_id, created_at DESC' }, - - // avoirs - { name: 'idx_avoirs_group_id', table: 'avoirs', columns: 'group_id' }, - { name: 'idx_avoirs_supplier_id', table: 'avoirs', columns: 'supplier_id' }, - { name: 'idx_avoirs_created_at', table: 'avoirs', columns: 'created_at DESC' }, - { name: 'idx_avoirs_group_status', table: 'avoirs', columns: 'group_id, status' }, - - // sav_tickets - { name: 'idx_sav_tickets_group_id', table: 'sav_tickets', columns: 'group_id' }, - { name: 'idx_sav_tickets_supplier_id', table: 'sav_tickets', columns: 'supplier_id' }, - { name: 'idx_sav_tickets_created_at', table: 'sav_tickets', columns: 'created_at DESC' }, - { name: 'idx_sav_tickets_status', table: 'sav_tickets', columns: 'status' }, - { name: 'idx_sav_tickets_priority', table: 'sav_tickets', columns: 'priority' }, - { name: 'idx_sav_tickets_group_status', table: 'sav_tickets', columns: 'group_id, status' }, - { name: 'idx_sav_tickets_group_created', table: 'sav_tickets', columns: 'group_id, created_at DESC' }, - - // invoice_verification_cache : expires_at sert la purge périodique - { name: 'idx_invoice_cache_expires_at', table: 'invoice_verification_cache', columns: 'expires_at' }, - { name: 'idx_invoice_cache_group_id', table: 'invoice_verification_cache', columns: 'group_id' }, - - // publicity_participations : la clé primaire (publicity_id, group_id) ne couvre pas group_id seul - { name: 'idx_publicity_participations_group_id', table: 'publicity_participations', columns: 'group_id' }, - - // publicities - { name: 'idx_publicities_year', table: 'publicities', columns: 'year' }, - { name: 'idx_publicities_start_date', table: 'publicities', columns: 'start_date' }, - - // dashboard_messages - { name: 'idx_dashboard_messages_store_id', table: 'dashboard_messages', columns: 'store_id' }, - { name: 'idx_dashboard_messages_created_at', table: 'dashboard_messages', columns: 'created_at DESC' }, - - // weather_data - { name: 'idx_weather_data_date_location', table: 'weather_data', columns: 'date, location' }, - { name: 'idx_weather_data_date_year', table: 'weather_data', columns: 'date, is_current_year' }, - - // supplier_mail_logs : historique récent par magasin - { name: 'idx_supplier_mail_logs_delivery', table: 'supplier_mail_logs', columns: 'delivery_id' }, - { name: 'idx_supplier_mail_logs_group', table: 'supplier_mail_logs', columns: 'group_id' }, - { name: 'idx_supplier_mail_logs_group_created', table: 'supplier_mail_logs', columns: 'group_id, created_at DESC' }, -]; - -let performanceIndexesRunning = false; - -/** - * Crée les index de performance manquants sans verrouiller les tables en écriture. - * - * CREATE INDEX CONCURRENTLY est interdit dans une transaction, et pg en ouvre une - * implicite pour une chaîne de plusieurs instructions : chaque index part donc - * dans sa propre requête, avec son propre try/catch pour qu'un échec (table - * absente, etc.) n'empêche pas les suivants. - */ -export async function ensurePerformanceIndexes(): Promise { - if (!pool) { - console.warn('⚠️ MIGRATION: No database pool, performance indexes not checked'); - return; - } - - const startedAt = Date.now(); - console.log('🔄 MIGRATION: Checking performance indexes in background...'); - - // 1. Une création CONCURRENTLY interrompue laisse un index invalide, que - // IF NOT EXISTS considère ensuite comme présent : on purge ces reliquats. - // Un index en cours de construction (par exemple par le processus d'avant un - // redémarrage) est lui aussi invalide : on l'écarte via - // pg_stat_progress_create_index. DROP INDEX CONCURRENTLY ne prend pas de - // verrou exclusif, donc la purge ne bloque jamais les lectures de l'app. - try { - const invalid = await pool.query(` - SELECT c.relname - FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%' - AND NOT EXISTS ( - SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = i.indexrelid - ) - `); - for (const row of invalid.rows) { - const indexName = String(row.relname).replace(/"/g, '""'); - try { - await pool.query(`DROP INDEX CONCURRENTLY IF EXISTS public."${indexName}"`); - console.log(`🧹 MIGRATION: Invalid index ${row.relname} dropped`); - } catch (error) { - console.warn(`⚠️ MIGRATION: Invalid index ${row.relname} could not be dropped:`, (error as any)?.message); - } - } - } catch (error) { - console.warn('⚠️ MIGRATION: Invalid indexes purge failed:', (error as any)?.message); - } - - // 2. État actuel : tables présentes, index par nom (valides ou non) et colonnes - // déjà indexées sous un autre nom (ex. idx_deliveries_invoice_ref d'init.sql), - // pour ne pas créer de doublon. En cas d'échec, chaque index est tenté. - let existingTables: Set | null = null; - const existingIndexNames = new Set(); - const invalidIndexNames = new Set(); - const indexedColumns = new Set(); - try { - const tables = await pool.query(` - SELECT c.relname - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') - `); - const indexes = await pool.query(` - SELECT t.relname AS table_name, - c.relname AS index_name, - i.indisvalid AS valid, - (i.indpred IS NULL AND i.indexprs IS NULL) AS plain, - array_to_string(ARRAY( - SELECT a.attname - FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum - WHERE k.ord <= i.indnkeyatts - ORDER BY k.ord - ), ',') AS columns - FROM pg_index i - JOIN pg_class c ON c.oid = i.indexrelid - JOIN pg_class t ON t.oid = i.indrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE n.nspname = 'public' - `); - existingTables = new Set(tables.rows.map((row: any) => row.relname)); - for (const row of indexes.rows) { - if (!row.valid) { - invalidIndexNames.add(row.index_name); - continue; - } - existingIndexNames.add(row.index_name); - if (row.plain) indexedColumns.add(`${row.table_name}(${row.columns})`); - } - } catch (error) { - console.warn('⚠️ MIGRATION: Could not read existing indexes:', (error as any)?.message); - } - - // 3. Création des index manquants, une requête par index - let created = 0; - let present = 0; - let skipped = 0; - const failed: string[] = []; - const tablesToAnalyze = new Set(); - - for (const index of PERFORMANCE_INDEXES) { - // Le sens de tri n'importe pas : un btree se parcourt dans les deux sens - const keyColumns = index.columns - .split(',') - .map((column) => column.trim().split(/\s+/)[0]) - .join(','); - if (existingIndexNames.has(index.name) || indexedColumns.has(`${index.table}(${keyColumns})`)) { - present++; - continue; - } - if (existingTables && !existingTables.has(index.table)) { - skipped++; - continue; - } - // Reliquat invalide que la purge n'a pas pu supprimer : IF NOT EXISTS l'ignorerait - if (invalidIndexNames.has(index.name)) { - failed.push(index.name); - console.warn(`⚠️ MIGRATION: Index ${index.name} is invalid and could not be purged`); - continue; - } - - try { - await pool.query( - `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${index.name} ON ${index.table} (${index.columns})` + - (index.where ? ` WHERE ${index.where}` : '') - ); - created++; - tablesToAnalyze.add(index.table); - console.log(`✅ MIGRATION: Index ${index.name} created`); - } catch (error) { - failed.push(index.name); - console.warn(`⚠️ MIGRATION: Index ${index.name} failed:`, (error as any)?.message); - } - } - - // 4. Statistiques à jour pour que le planificateur utilise les nouveaux index - // sans attendre le prochain autovacuum - for (const table of tablesToAnalyze) { - try { - await pool.query(`ANALYZE ${table}`); - } catch (error) { - console.warn(`⚠️ MIGRATION: ANALYZE ${table} failed:`, (error as any)?.message); - } - } - - const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); - console.log( - `📊 MIGRATION: Performance indexes: ${created} created, ${present} already present, ` + - `${skipped} skipped (missing table), ${failed.length} failed in ${seconds}s` + - (failed.length > 0 ? ` (${failed.join(', ')})` : '') - ); -} - -// Lance la vérification des index en tâche de fond : ni le démarrage ni le -// healthcheck n'attendent la fin des créations. La route d'urgence pouvant -// relancer les migrations, un seul passage à la fois. -function startPerformanceIndexes(): void { - if (performanceIndexesRunning) return; - performanceIndexesRunning = true; - ensurePerformanceIndexes() - .catch((error) => console.error('❌ MIGRATION ERROR: Performance indexes check failed:', error)) - .finally(() => { - performanceIndexesRunning = false; - }); -} - -export async function runProductionMigrations() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error('❌ MIGRATION: DATABASE_URL not found for migrations'); - return; - } - - console.log('🔄 MIGRATION: Starting SAV production migrations...'); - console.log('🔄 MIGRATION: Database URL configured:', databaseUrl.substring(0, 30) + '...'); - - const client = new Client({ - connectionString: databaseUrl, - ssl: false, // No SSL for local Docker - }); - - try { - await client.connect(); - console.log('✅ MIGRATION: Connected to PostgreSQL database'); - - console.log('🔄 MIGRATION: Checking if priority column exists...'); - - // Vérifier si la colonne priority existe - const checkPriorityColumn = await client.query(` - SELECT 1 FROM information_schema.columns - WHERE table_name='sav_tickets' AND column_name='priority' - `); - - console.log('🔄 MIGRATION: Priority column check result:', checkPriorityColumn.rows.length); - - if (checkPriorityColumn.rows.length === 0) { - console.log('📝 MIGRATION: Priority column missing, adding it now...'); - - // Ajouter seulement la colonne priority manquante - await client.query(` - ALTER TABLE sav_tickets ADD COLUMN priority varchar(50) NOT NULL DEFAULT 'normale'; - `); - console.log('✅ MIGRATION: Priority column added successfully!'); - - // Créer les index pour optimiser les performances - await client.query(` - CREATE INDEX IF NOT EXISTS idx_sav_tickets_priority ON sav_tickets(priority); - `); - console.log('✅ MIGRATION: Priority index created successfully!'); - - // Vérification finale - const verificationResult = await client.query(` - SELECT - COUNT(*) as total_records, - COUNT(CASE WHEN priority IS NOT NULL THEN 1 END) as records_with_priority - FROM sav_tickets; - `); - - console.log('✅ MIGRATION: SAV migration completed successfully!'); - console.log('📊 MIGRATION: Verification result:', verificationResult.rows[0]); - - } else { - console.log('✅ MIGRATION: Priority column already exists, skipping migration'); - } - - // Coordonnées magasin + configuration SMTP par magasin (mails fournisseurs) - console.log('🔄 MIGRATION: Ensuring store contact and SMTP columns on groups...'); - await client.query(` - ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255); - `); - console.log('✅ MIGRATION: Store contact and SMTP columns are present on groups'); - - // Historique des mails de relance fournisseurs - console.log('🔄 MIGRATION: Ensuring supplier_mail_logs table...'); - await client.query(` - CREATE TABLE IF NOT EXISTS supplier_mail_logs ( - id SERIAL PRIMARY KEY, - delivery_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - supplier_id INTEGER, - supplier_name VARCHAR(255), - sent_to VARCHAR(255) NOT NULL, - subject TEXT, - status VARCHAR(20) NOT NULL, - error_message TEXT, - message_id VARCHAR(255), - sent_by VARCHAR NOT NULL, - sent_by_name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id); - CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id); - `); - console.log('✅ MIGRATION: supplier_mail_logs table is present'); - - // Clés de l'API externe de rapprochement, gérées depuis Paramètres - console.log('🔄 MIGRATION: Ensuring external_api_keys table...'); - await client.query(` - CREATE TABLE IF NOT EXISTS external_api_keys ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - key_prefix VARCHAR(16) NOT NULL, - key_hash VARCHAR(64) NOT NULL UNIQUE, - created_by VARCHAR, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP, - revoked_at TIMESTAMP - ); - `); - console.log('✅ MIGRATION: external_api_keys table is present'); - - // Chiffrement au repos des secrets encore stockés en clair - // (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent : - // les valeurs déjà au format enc:v1: sont ignorées. - console.log('🔄 MIGRATION: Encrypting plaintext secrets at rest...'); - let encryptedCount = 0; - - const plainSmtp = await client.query(` - SELECT id, smtp_password FROM groups - WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%' - `); - for (const row of plainSmtp.rows) { - await client.query('UPDATE groups SET smtp_password = $1 WHERE id = $2', [ - encryptSecret(row.smtp_password), - row.id, - ]); - encryptedCount++; - } - - const plainTokens = await client.query(` - SELECT id, api_token FROM nocodb_config - WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%' - `); - for (const row of plainTokens.rows) { - if (isEncryptedSecret(row.api_token)) continue; - await client.query('UPDATE nocodb_config SET api_token = $1 WHERE id = $2', [ - encryptSecret(row.api_token), - row.id, - ]); - encryptedCount++; - } - - console.log(`✅ MIGRATION: Secrets encryption done (${encryptedCount} value(s) encrypted this run)`); - - } catch (error) { - console.error('❌ MIGRATION ERROR: Failed to run SAV production migrations:', error); - console.error('❌ MIGRATION ERROR: Error details:', { - message: (error as any)?.message, - code: (error as any)?.code, - stack: (error as any)?.stack - }); - // Ne pas faire échouer le démarrage du serveur pour les erreurs de migration - } finally { - await client.end(); - console.log('🔌 MIGRATION: Database connection closed'); - } - - // Après les migrations de schéma ci-dessus (colonne priority, supplier_mail_logs) - startPerformanceIndexes(); -} \ No newline at end of file diff --git a/server/migrations.ts b/server/migrations.ts deleted file mode 100644 index e788a74..0000000 --- a/server/migrations.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { sql } from 'drizzle-orm'; -import { db } from './db.js'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Table pour suivre les migrations appliquées -const createMigrationsTable = async () => { - await db.execute(sql` - CREATE TABLE IF NOT EXISTS migrations ( - id SERIAL PRIMARY KEY, - filename VARCHAR(255) UNIQUE NOT NULL, - executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); -}; - -interface MigrationFile { - filename: string; - sql: string; -} - -export async function runMigrations() { - console.log('🔄 Vérification des migrations...'); - - try { - // Créer la table des migrations si elle n'existe pas - await createMigrationsTable(); - - // Récupérer les migrations déjà appliquées - const appliedMigrations = await db.execute(sql`SELECT filename FROM migrations`); - const appliedFilenames = new Set(appliedMigrations.rows.map((row: any) => row.filename)); - - // Ajouter les migrations hardcodées directement dans le code pour éviter les problèmes de chemins - const hardcodedMigrations = [ - { - filename: '20260402000002_add_notified_fields_to_customer_orders.sql', - content: ` - ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP; - ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_comment TEXT; - ` - }, - { - filename: '20260402000000_add_codefou_to_suppliers.sql', - content: `ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR;` - }, - { - filename: '20250903141000_create_webhook_bap_config.sql', - content: `CREATE TABLE IF NOT EXISTS webhook_bap_config ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP', - webhook_url TEXT NOT NULL, - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO webhook_bap_config (name, webhook_url, description, is_active) -SELECT - 'Configuration BAP', - 'https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d', - 'Configuration par défaut pour envoi des fichiers BAP vers n8n', - true -WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);` - }, - { - filename: '20260814000000_add_store_contact_and_smtp_to_groups.sql', - content: ` - -- Coordonnées du magasin (signature des mails fournisseurs) - ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS phone VARCHAR(50); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS logo TEXT; - -- Configuration SMTP propre à chaque magasin - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_port INTEGER; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false; - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255); - ALTER TABLE groups ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255); - ` - }, - { - filename: '20260814000001_create_supplier_mail_logs.sql', - content: ` - CREATE TABLE IF NOT EXISTS supplier_mail_logs ( - id SERIAL PRIMARY KEY, - delivery_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - supplier_id INTEGER, - supplier_name VARCHAR(255), - sent_to VARCHAR(255) NOT NULL, - subject TEXT, - status VARCHAR(20) NOT NULL, - error_message TEXT, - message_id VARCHAR(255), - sent_by VARCHAR NOT NULL, - sent_by_name VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id); - CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id); - ` - }, - { - filename: '20261010000000_create_external_api_keys.sql', - content: ` - CREATE TABLE IF NOT EXISTS external_api_keys ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - key_prefix VARCHAR(16) NOT NULL, - key_hash VARCHAR(64) NOT NULL UNIQUE, - created_by VARCHAR, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP, - revoked_at TIMESTAMP - ); - ` - } - ]; - - // Ignorer les fichiers de migrations pour éviter les erreurs SQL - // Utiliser uniquement les migrations hardcodées - const allMigrations = hardcodedMigrations; - - if (allMigrations.length === 0) { - console.log('✅ Aucune migration à exécuter'); - return; - } - - let executedCount = 0; - - for (const migration of allMigrations) { - if (appliedFilenames.has(migration.filename)) { - console.log(`⏭️ Migration déjà appliquée: ${migration.filename}`); - continue; - } - - console.log(`🔄 Exécution migration: ${migration.filename}`); - - try { - // Exécuter la migration dans une transaction - await db.transaction(async (tx: any) => { - // Exécuter le SQL de migration - await tx.execute(sql.raw(migration.content)); - - // Marquer comme appliquée - await tx.execute(sql` - INSERT INTO migrations (filename) - VALUES (${migration.filename}) - `); - }); - - console.log(`✅ Migration appliquée avec succès: ${migration.filename}`); - executedCount++; - - } catch (error) { - console.error(`❌ Erreur lors de la migration ${migration.filename}:`, error); - throw error; - } - } - - if (executedCount > 0) { - console.log(`✅ ${executedCount} migration(s) appliquée(s) avec succès`); - } else { - console.log('✅ Toutes les migrations sont déjà appliquées'); - } - - } catch (error) { - console.error('❌ Erreur lors de l\'exécution des migrations:', error); - throw error; - } -} - -// Fonction pour créer une nouvelle migration -export function createMigration(name: string): string { - const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 14); - const filename = `${timestamp}_${name.toLowerCase().replace(/\s+/g, '_')}.sql`; - const migrationsDir = path.join(__dirname, '..', 'migrations'); - - if (!fs.existsSync(migrationsDir)) { - fs.mkdirSync(migrationsDir, { recursive: true }); - } - - const filePath = path.join(migrationsDir, filename); - const template = `-- Migration: ${name} --- Created: ${new Date().toISOString()} - --- Add your SQL here -`; - - fs.writeFileSync(filePath, template); - console.log(`📝 Migration créée: ${filePath}`); - - return filePath; -} \ No newline at end of file diff --git a/server/monitoring.ts b/server/monitoring.ts deleted file mode 100644 index 93f7c3b..0000000 --- a/server/monitoring.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -// Interface pour les métriques -interface Metrics { - requestCount: number; - errorCount: number; - responseTime: number[]; - memoryUsage: NodeJS.MemoryUsage; - activeConnections: number; - lastReset: Date; -} - -class PerformanceMonitor { - private metrics: Metrics = { - requestCount: 0, - errorCount: 0, - responseTime: [], - memoryUsage: process.memoryUsage(), - activeConnections: 0, - lastReset: new Date() - }; - - private slowQueries: Array<{ query: string; duration: number; timestamp: Date }> = []; - private MAX_RESPONSE_TIMES = 1000; // Garder les 1000 derniers temps de réponse - - // Middleware pour mesurer les performances - middleware() { - return (req: Request, res: Response, next: NextFunction) => { - const startTime = Date.now(); - this.metrics.requestCount++; - this.metrics.activeConnections++; - - // Capturer le temps de réponse - res.on('finish', () => { - const duration = Date.now() - startTime; - this.metrics.responseTime.push(duration); - - // Garder seulement les N derniers temps de réponse - if (this.metrics.responseTime.length > this.MAX_RESPONSE_TIMES) { - this.metrics.responseTime.shift(); - } - - // Détecter les requêtes lentes - if (duration > 1000) { - this.logSlowQuery(req.originalUrl, duration); - } - - // Compter les erreurs - if (res.statusCode >= 400) { - this.metrics.errorCount++; - } - - this.metrics.activeConnections--; - this.metrics.memoryUsage = process.memoryUsage(); - }); - - next(); - }; - } - - // Enregistrer une requête lente - private logSlowQuery(query: string, duration: number) { - this.slowQueries.push({ - query, - duration, - timestamp: new Date() - }); - - // Garder seulement les 100 dernières requêtes lentes - if (this.slowQueries.length > 100) { - this.slowQueries.shift(); - } - - console.warn(`⚠️ Slow query detected: ${query} (${duration}ms)`); - } - - // Obtenir les statistiques - getStats() { - const responseTimes = this.metrics.responseTime; - const avgResponseTime = responseTimes.length > 0 - ? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length - : 0; - - const p95ResponseTime = responseTimes.length > 0 - ? responseTimes.sort((a, b) => a - b)[Math.floor(responseTimes.length * 0.95)] - : 0; - - return { - uptime: process.uptime(), - requestCount: this.metrics.requestCount, - errorCount: this.metrics.errorCount, - errorRate: this.metrics.requestCount > 0 ? (this.metrics.errorCount / this.metrics.requestCount) * 100 : 0, - avgResponseTime: Math.round(avgResponseTime), - p95ResponseTime: Math.round(p95ResponseTime), - activeConnections: this.metrics.activeConnections, - memoryUsage: { - rss: `${Math.round(this.metrics.memoryUsage.rss / 1024 / 1024)}MB`, - heapTotal: `${Math.round(this.metrics.memoryUsage.heapTotal / 1024 / 1024)}MB`, - heapUsed: `${Math.round(this.metrics.memoryUsage.heapUsed / 1024 / 1024)}MB`, - external: `${Math.round(this.metrics.memoryUsage.external / 1024 / 1024)}MB` - }, - slowQueries: this.slowQueries.slice(-10), // 10 dernières requêtes lentes - lastReset: this.metrics.lastReset - }; - } - - // Réinitialiser les métriques - reset() { - this.metrics = { - requestCount: 0, - errorCount: 0, - responseTime: [], - memoryUsage: process.memoryUsage(), - activeConnections: 0, - lastReset: new Date() - }; - this.slowQueries = []; - } - - // Alerte si l'utilisation mémoire est trop élevée - checkMemoryUsage() { - const memUsage = process.memoryUsage(); - const heapUsedMB = memUsage.heapUsed / 1024 / 1024; - - if (heapUsedMB > 500) { // Plus de 500MB - console.warn(`⚠️ High memory usage: ${Math.round(heapUsedMB)}MB`); - return true; - } - return false; - } -} - -// Instance globale -export const monitor = new PerformanceMonitor(); - -// Vérification périodique de la mémoire -setInterval(() => { - monitor.checkMemoryUsage(); -}, 5 * 60 * 1000); // Toutes les 5 minutes - -// Endpoint pour les métriques -export function setupMonitoringEndpoints(app: any) { - app.get('/api/metrics', (req: Request, res: Response) => { - res.json(monitor.getStats()); - }); - - app.post('/api/metrics/reset', (req: Request, res: Response) => { - monitor.reset(); - res.json({ message: 'Metrics reset successfully' }); - }); -} \ No newline at end of file diff --git a/server/nocodbService.ts b/server/nocodbService.ts deleted file mode 100644 index f00ce23..0000000 --- a/server/nocodbService.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Service for interacting with NocoDB -import { storage } from "./storage"; - -export interface InvoiceVerificationResult { - exists: boolean; - error?: string; -} - -export async function verifyInvoiceReference( - groupId: number, - invoiceReference: string, - supplierName?: string -): Promise { - try { - // Get group with NocoDB configuration - const group = await storage.getGroup(groupId); - - if (!group || !group.nocodbConfigId || !group.nocodbTableId) { - return { exists: false, error: "No NocoDB configuration for this group" }; - } - - // Get NocoDB configuration - const nocodbConfig = await storage.getNocodbConfig(group.nocodbConfigId); - - if (!nocodbConfig) { - return { exists: false, error: "NocoDB configuration not found" }; - } - - const columnName = group.invoiceColumnName || "Ref Facture"; - - // Normalize search term (case insensitive) - const normalizedInvoiceRef = invoiceReference.trim().toLowerCase(); - - // Make request to NocoDB API to get all records and filter them case-insensitively - // URL format: {baseUrl}/api/v1/db/data/noco/{projectId}/{tableId} - const url = `${nocodbConfig.baseUrl}/api/v1/db/data/noco/${nocodbConfig.projectId}/${group.nocodbTableId}`; - - console.log(`🔍 NocoDB API Request for ${invoiceReference} (supplier: ${supplierName || 'any'}):`, { - url, - columnName, - groupId, - projectId: nocodbConfig.projectId, - tableId: group.nocodbTableId, - normalizedSearch: normalizedInvoiceRef - }); - - const response = await fetch(url, { - method: "GET", - headers: { - "xc-token": nocodbConfig.apiToken, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - return { exists: false, error: `NocoDB API error: ${response.status}` }; - } - - const data = await response.json(); - - console.log(`📋 NocoDB Response:`, { - totalRows: data.pageInfo?.totalRows || 0, - returnedRows: data.list?.length || 0, - columnName, - searchValue: invoiceReference.trim() - }); - - // Filter results case-insensitively for invoice reference - let matchingRecords = []; - if (data.list && data.list.length > 0) { - matchingRecords = data.list.filter((record: any) => { - const recordInvoiceRef = record[columnName]; - if (!recordInvoiceRef) return false; - - // Case-insensitive comparison for invoice reference - const normalizedRecordRef = recordInvoiceRef.toString().toLowerCase(); - const invoiceMatches = normalizedRecordRef === normalizedInvoiceRef; - - // If supplier name is provided, also check supplier match (case-insensitive) - if (invoiceMatches && supplierName) { - const supplierColumn = record['Fournisseur'] || record['Supplier'] || record['fournisseur'] || record['supplier']; - if (supplierColumn) { - const normalizedRecordSupplier = supplierColumn.toString().toLowerCase(); - const normalizedSearchSupplier = supplierName.toLowerCase(); - const supplierMatches = normalizedRecordSupplier.includes(normalizedSearchSupplier) || - normalizedSearchSupplier.includes(normalizedRecordSupplier); - - console.log(`🏢 Supplier check:`, { - recordSupplier: supplierColumn, - searchSupplier: supplierName, - matches: supplierMatches - }); - - return supplierMatches; - } - } - - return invoiceMatches; - }); - } - - const exists = matchingRecords.length > 0; - - if (exists) { - console.log(`✅ Found matching invoice:`, { - found: matchingRecords[0][columnName], - searchValue: invoiceReference.trim(), - totalMatches: matchingRecords.length, - supplierMatch: supplierName ? 'verified' : 'not checked' - }); - } else { - console.log(`❌ No matching invoice found for: ${invoiceReference}${supplierName ? ` (supplier: ${supplierName})` : ''}`); - } - - console.log(`🔍 Search result for ${invoiceReference}:`, { exists }); - return { exists }; - - } catch (error) { - console.error("Error verifying invoice reference:", error); - return { exists: false, error: error instanceof Error ? error.message : "Unknown error" }; - } -} - -// Bulk verify multiple invoice references -export async function verifyMultipleInvoiceReferences( - invoiceReferences: { groupId: number; invoiceReference: string; deliveryId: number; supplierName?: string }[] -): Promise> { - const results: Record = {}; - - // Process in parallel for better performance - const promises = invoiceReferences.map(async ({ groupId, invoiceReference, deliveryId, supplierName }) => { - const result = await verifyInvoiceReference(groupId, invoiceReference, supplierName); - results[deliveryId] = result; - }); - - await Promise.all(promises); - - return results; -} \ No newline at end of file diff --git a/server/permissions.ts b/server/permissions.ts deleted file mode 100644 index 597585a..0000000 --- a/server/permissions.ts +++ /dev/null @@ -1,113 +0,0 @@ -// Middleware de permissions hardcodées -// Remplace l'ancien système flexible basé sur la base de données - -import type { Request, Response, NextFunction } from 'express'; -import { hasPermission, hasModuleAccess } from '@shared/permissions'; -import type { Permission, Module } from '@shared/permissions'; - -// Interface pour les requêtes authentifiées -interface AuthRequest extends Request { - user: any; -} - -// Middleware pour vérifier l'accès à un module -export function requireModuleAccess(module: Module) { - return (req: AuthRequest, res: Response, next: NextFunction) => { - const userRole = req.user?.role; - - if (!userRole) { - return res.status(401).json({ message: "Utilisateur non authentifié" }); - } - - if (!hasModuleAccess(userRole, module)) { - return res.status(403).json({ - message: `Accès refusé au module ${module}`, - required: "Permission d'accès au module", - userRole - }); - } - - next(); - }; -} - -// Middleware pour vérifier une permission spécifique -export function requirePermission(module: Module, action: Permission) { - return (req: AuthRequest, res: Response, next: NextFunction) => { - const userRole = req.user?.role; - - if (!userRole) { - return res.status(401).json({ message: "Utilisateur non authentifié" }); - } - - if (!hasPermission(userRole, module, action)) { - return res.status(403).json({ - message: `Permission refusée: ${action} sur ${module}`, - required: `${action} permission on ${module}`, - userRole - }); - } - - next(); - }; -} - -// Middleware combiné: accès module + permission -export function requireModulePermission(module: Module, action: Permission) { - return (req: AuthRequest, res: Response, next: NextFunction) => { - const userRole = req.user?.role; - - if (!userRole) { - return res.status(401).json({ message: "Utilisateur non authentifié" }); - } - - // Vérifier l'accès au module ET la permission - if (!hasModuleAccess(userRole, module) || !hasPermission(userRole, module, action)) { - return res.status(403).json({ - message: `Accès refusé: ${action} sur module ${module}`, - required: `${action} permission on ${module} module`, - userRole - }); - } - - next(); - }; -} - -// Helper: vérifie si l'utilisateur est admin -export function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) { - const userRole = req.user?.role; - - if (!userRole) { - return res.status(401).json({ message: "Utilisateur non authentifié" }); - } - - if (userRole !== 'admin') { - return res.status(403).json({ - message: "Accès réservé aux administrateurs", - required: "admin role", - userRole - }); - } - - next(); -} - -// Helper: vérifie si l'utilisateur est admin ou directeur -export function requireAdminOrDirecteur(req: AuthRequest, res: Response, next: NextFunction) { - const userRole = req.user?.role; - - if (!userRole) { - return res.status(401).json({ message: "Utilisateur non authentifié" }); - } - - if (!['admin', 'directeur'].includes(userRole)) { - return res.status(403).json({ - message: "Accès réservé aux administrateurs et directeurs", - required: "admin or directeur role", - userRole - }); - } - - next(); -} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts deleted file mode 100644 index 0a1c724..0000000 --- a/server/routes.ts +++ /dev/null @@ -1,6077 +0,0 @@ -import type { Express, Request, Response } from "express"; -import { createServer, type Server } from "http"; -import { storage } from "./storage"; -import { setupLocalAuth, requireAuth } from "./localAuth"; -import { requireModulePermission, requireAdmin, requirePermission } from "./permissions"; -import { stripSmtpPassword } from "./sanitize"; -import { normalizeDateString } from "./dateUtils"; -import { registerExternalApi, registerExternalApiKeyAdminRoutes } from "./externalApi"; - -// Corps de requête sans les champs secrets : pour les logs uniquement -function redactBody(body: any): any { - if (!body || typeof body !== 'object') return body; - const redacted = { ...body }; - for (const key of ['smtpPassword', 'apiToken', 'password']) { - if (key in redacted && redacted[key]) redacted[key] = '[REDACTED]'; - } - return redacted; -} -import { - sendSupplierDocumentRequest, - verifySmtpConfig, - getMissingSmtpFields, -} from "./emailService"; -import { buildSupplierMailSubject } from "@shared/supplierMail"; -import { db, pool } from "./db"; -import { createRequire } from "module"; -const require = createRequire(import.meta.url); -// Suppression de l'import form-data car nous utilisons le FormData natif de Node.js 18+ -// const FormData = require('form-data'); - -console.log('🔍 Using development storage and authentication'); - - -// Simple hash password function using crypto -async function hashPasswordSimple(password: string) { - const crypto = await import('crypto'); - const { promisify } = await import('util'); - const scryptAsync = promisify(crypto.scrypt); - - const salt = crypto.randomBytes(16).toString("hex"); - const buf = (await scryptAsync(password, salt, 64)) as Buffer; - return `${buf.toString("hex")}.${salt}`; -} - - -// Alias pour compatibilité -const isAuthenticated = requireAuth; -const setupAuth = setupLocalAuth; -import { - insertGroupSchema, - insertSupplierSchema, - insertOrderSchema, - insertDeliverySchema, - insertUserGroupSchema, - insertPublicitySchema, - insertCustomerOrderSchema, - insertCustomerOrderFrontendSchema, - insertDlcProductSchema, - insertDlcProductFrontendSchema, - insertTaskSchema, - insertAnnouncementSchema, - insertNocodbConfigSchema, - insertSavTicketSchema, - insertWeatherDataSchema, - insertWeatherSettingsSchema, - insertWebhookBapConfigSchema, - insertUtilitiesSchema, - insertAvoirSchema, - insertReconciliationCommentSchema, - insertContactSchema, - users, groups, userGroups, suppliers, orders, deliveries, publicities, publicityParticipations, - customerOrders, nocodbConfig, dlcProducts, tasks, invoiceVerificationCache, dashboardMessages, webhookBapConfig, - utilities, - avoirs, - type UserWithGroups -} from "@shared/schema"; -import { hasPermission } from "@shared/permissions"; -import { z } from "zod"; -import { eq, desc, or, isNull } from "drizzle-orm"; -import { invoiceVerificationService } from "./invoiceVerification"; -import { backupService } from "./backupService"; -import { weatherService } from "./weatherService.js"; -import fetch from "node-fetch"; - -// Utilisateur courant avec ses magasins. deserializeUser (localAuth.ts) le -// recharge déjà depuis la base à chaque requête authentifiée : inutile de le -// relire dans chaque handler. -function getCurrentUser(req: any): UserWithGroups | undefined { - return req.user; -} - -export async function registerRoutes(app: Express): Promise { - // Detect environment - const environment = process.env.NODE_ENV || 'development'; - console.log('🌍 Environment detected:', environment); - - // API externe de rapprochement (clé d'API, hors session) : docs/API-RAPPROCHEMENT.md - registerExternalApi(app); - - // Le mot de passe SMTP des magasins ne doit jamais sortir du serveur. - // Les objets "group" sont joints à de nombreuses réponses (livraisons, - // commandes, utilisateurs...) : plutôt que de filtrer chaque requête, on - // nettoie une seule fois à la sortie. Le client reçoit à la place un booléen - // smtpPasswordSet lui indiquant si un mot de passe est enregistré. - app.use('/api', (req, res, next) => { - const originalJson = res.json.bind(res); - - res.json = (body: any) => originalJson(stripSmtpPassword(body)); - - // Données privées par magasin : jamais stockées par un cache partagé, et - // toujours revalidées par le navigateur (les 304 via ETag restent possibles) - res.set('Cache-Control', 'private, no-cache'); - - next(); - }); - - // Health check endpoint for Docker - app.get('/api/health', (req, res) => { - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV || 'development', - database: 'connected' // We could add a real DB check here if needed - }); - }); - // Auth middleware - await setupAuth(app); - - // Clés de l'API externe (Paramètres > API externe), admin uniquement - registerExternalApiKeyAdminRoutes(app, [isAuthenticated, requireAdmin]); - - // Routes pour configuration webhook BAP - app.get('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => { - try { - // Vérifier que l'utilisateur est admin - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé - Admin uniquement' }); - } - - const config = await storage.getWebhookBapConfig(); - res.json(config || null); - - } catch (error: any) { - console.error('❌ Erreur récupération config webhook BAP:', error); - - // Si la table n'existe pas, retourner une configuration par défaut - if (error.code === '42P01') { // Relation does not exist - console.log('⚠️ Table webhook_bap_config n\'existe pas, retour config par défaut'); - return res.json({ - id: 1, - name: "Configuration BAP", - webhookUrl: "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d", - description: "Configuration par défaut (table non créée)", - isActive: true, - needsTableCreation: true - }); - } - - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - app.post('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => { - try { - // Vérifier que l'utilisateur est admin - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé - Admin uniquement' }); - } - - // Valider les données avec Zod - const validatedData = insertWebhookBapConfigSchema.parse(req.body); - - // Vérifier si une configuration existe déjà - const existingConfig = await storage.getWebhookBapConfig(); - - let config: any; - if (existingConfig) { - // Mettre à jour la configuration existante - config = await storage.updateWebhookBapConfig(existingConfig.id, validatedData); - } else { - // Créer une nouvelle configuration - config = await storage.createWebhookBapConfig(validatedData); - } - - console.log('✅ Configuration webhook BAP sauvegardée:', { - id: config.id, - name: config.name, - isActive: config.isActive - }); - - res.json(config); - - } catch (error: any) { - console.error('❌ Erreur sauvegarde config webhook BAP:', error); - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - app.post('/api/webhook-bap-config/test', isAuthenticated, async (req: any, res) => { - try { - // Vérifier que l'utilisateur est admin - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé - Admin uniquement' }); - } - - const { webhookUrl } = req.body; - if (!webhookUrl) { - return res.status(400).json({ error: 'URL webhook requise' }); - } - - console.log('🔍 Test webhook BAP:', { url: webhookUrl }); - - // Tester la connectivité avec le webhook - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 secondes - - const testResponse = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - test: true, - timestamp: new Date().toISOString(), - message: 'Test de connectivité depuis LogiFlow' - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - console.log('🌐 Réponse test webhook:', { status: testResponse.status, ok: testResponse.ok }); - - if (!testResponse.ok) { - throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`); - } - - const result = await testResponse.text(); - - res.json({ - success: true, - status: testResponse.status, - message: 'Webhook accessible', - response: result.substring(0, 200) // Limiter la réponse - }); - - } catch (error: any) { - console.error('❌ Erreur test webhook BAP:', error); - - let errorMessage = 'Échec du test de connectivité'; - if (error.name === 'AbortError') { - errorMessage = 'Timeout - Le webhook ne répond pas'; - } else if (error.message) { - errorMessage = error.message; - } - - res.status(500).json({ error: errorMessage, details: error.message }); - } - }); - - // Routes pour configuration utilities - app.get('/api/utilities', isAuthenticated, async (req: any, res) => { - try { - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - return res.status(403).json({ error: 'Accès refusé - Admin ou Directeur uniquement' }); - } - - const config = await storage.getUtilities(); - res.json(config || null); - - } catch (error: any) { - console.error('❌ Erreur récupération utilities:', error); - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - app.post('/api/utilities', isAuthenticated, async (req: any, res) => { - try { - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - return res.status(403).json({ error: 'Accès refusé - Admin ou Directeur uniquement' }); - } - - const validatedData = insertUtilitiesSchema.parse(req.body); - - const existingConfig = await storage.getUtilities(); - - let config: any; - if (existingConfig) { - config = await storage.updateUtilities(existingConfig.id, validatedData); - } else { - config = await storage.createUtilities(validatedData); - } - - console.log('✅ Configuration utilities sauvegardée:', { - id: config.id, - salesAnalysisUrl: config.salesAnalysisUrl - }); - res.json(config); - - } catch (error: any) { - console.error('❌ Erreur sauvegarde utilities:', error); - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - // Route pour récupérer l'échéancier des paiements fournisseurs - app.get('/api/payment-schedule', isAuthenticated, async (req: any, res) => { - try { - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - return res.status(403).json({ error: 'Accès refusé - Admin ou Directeur uniquement' }); - } - - // Validation du groupId avec Zod - const groupIdSchema = z.coerce.number().int().positive(); - const validation = groupIdSchema.safeParse(req.query.groupId); - - if (!validation.success) { - return res.status(400).json({ error: 'groupId invalide - doit être un entier positif' }); - } - - const groupId = validation.data; - - // Vérifier l'autorisation : directeur ne peut voir que ses groupes - if (user.role === 'directeur') { - const userGroups = await storage.getUserGroups(user.id); - const userGroupIds = userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(groupId)) { - return res.status(403).json({ error: 'Accès refusé - Vous ne pouvez accéder qu\'aux données de votre groupe' }); - } - } - - // Récupérer le groupe - const group = await storage.getGroup(groupId); - if (!group) { - return res.json({ schedules: [], message: 'Groupe non trouvé' }); - } - - // Récupérer les livraisons du groupe (filtre magasin fait en base) - const allDeliveries = await storage.getDeliveries([groupId]); - const groupDeliveries = allDeliveries.filter((d: any) => d.groupId === groupId && d.invoiceReference); - - // Séparer les livraisons avec et sans dueDate - const deliveriesWithDueDate = groupDeliveries.filter((d: any) => d.dueDate); - const deliveriesWithoutDueDate = groupDeliveries.filter((d: any) => !d.dueDate); - - // FALLBACK : Pour les livraisons sans dueDate ou sans TTC, interroger NocoDB - const { InvoiceVerificationService } = await import('./invoiceVerification.js'); - const verificationService = new InvoiceVerificationService(); - - // Traiter les livraisons sans dueDate - for (const delivery of deliveriesWithoutDueDate) { - try { - const result = await verificationService.verifyInvoice( - delivery.invoiceReference!, - delivery.groupId, - false, // Ne pas forcer le refresh, utiliser cache si disponible - delivery.reconciled || false - ); - - const updateData: any = {}; - - if (result.exists && result.dueDate) { - // Normaliser la date avant de la stocker - const normalizedDateString = normalizeDateString(result.dueDate); - if (normalizedDateString) { - const normalizedDate = new Date(normalizedDateString); - updateData.dueDate = normalizedDate; - delivery.dueDate = normalizedDate; - } - } - - // Récupérer aussi le montant si manquant - if (result.exists && result.invoiceAmount && !delivery.invoiceAmount) { - // @ts-ignore - updateData.invoiceAmount = result.invoiceAmount.toString(); - // @ts-ignore - delivery.invoiceAmount = result.invoiceAmount.toString(); - } - - // Mettre à jour si nécessaire - if (Object.keys(updateData).length > 0) { - await storage.updateDelivery(delivery.id, updateData); - console.log(`📅 Fallback: données récupérées pour livraison #${delivery.id}`, updateData); - } - } catch (error) { - console.error(`❌ Fallback échoué pour livraison #${delivery.id}:`, error); - } - } - - // Combiner toutes les livraisons qui ont maintenant une dueDate - const allDeliveriesWithDueDate = [...deliveriesWithDueDate, ...deliveriesWithoutDueDate.filter((d: any) => d.dueDate)]; - - // FALLBACK TTC : Pour les livraisons qui ont une dueDate mais pas de TTC - const deliveriesNeedingTTC = allDeliveriesWithDueDate.filter((d: any) => !d.invoiceAmountTTC || parseFloat(d.invoiceAmountTTC) === 0); - - for (const delivery of deliveriesNeedingTTC) { - try { - const result = await verificationService.verifyInvoice( - delivery.invoiceReference!, - delivery.groupId, - false, - delivery.reconciled || false - ); - - if (result.exists && result.invoiceAmount) { - const updates: any = { - invoiceAmount: result.invoiceAmount.toString(), - supplierName: result.supplierName - }; - if (result.dueDate) { - updates.dueDate = new Date(result.dueDate); - } - await storage.updateDelivery(delivery.id, updates); - // @ts-ignore - delivery.invoiceAmount = result.invoiceAmount.toString(); - console.log(`💰 Montant récupéré pour livraison #${delivery.id}: ${result.invoiceAmount}€`); - } - } catch (error) { - console.error(`❌ Fallback TTC échoué pour livraison #${delivery.id}:`, error); - } - } - - // Récupérer tous les fournisseurs pour le mapping du mode de paiement - const allSuppliers = await storage.getSuppliers(); - const supplierMap = new Map(allSuppliers.map((s: any) => [s.id, s])); - - // Formatter les échéances - const schedules = allDeliveriesWithDueDate.map((delivery: any) => { - const supplier = supplierMap.get(delivery.supplierId); - - return { - id: delivery.id, - invoiceReference: delivery.invoiceReference, - dueDate: delivery.dueDate, - amount: delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0, - amountTTC: delivery.invoiceAmountTTC ? parseFloat(delivery.invoiceAmountTTC) : 0, - supplierName: supplier?.name || 'Fournisseur inconnu', - paymentMethod: supplier?.paymentMethod || null, - groupId: group.id, - groupName: group.name - }; - }); - - res.json({ schedules }); - - } catch (error: any) { - console.error('❌ Erreur récupération échéances:', error); - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - // Route pour exporter l'échéancier vers Excel - app.post('/api/payment-schedule/export', isAuthenticated, async (req: any, res) => { - try { - console.log('📊 [EXPORT] Début de l\'export Excel'); - - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - console.error('❌ [EXPORT] Utilisateur non authentifié'); - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - console.log(`🔍 [EXPORT] Utilisateur: ${user?.username} (${user?.role})`); - - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - console.error('❌ [EXPORT] Accès refusé'); - return res.status(403).json({ error: 'Accès refusé - Admin ou Directeur uniquement' }); - } - - // Récupérer les paramètres d'export - const { groupId, month, paymentMethods, includeHT, includeTTC } = req.body; - console.log('📋 [EXPORT] Paramètres:', { groupId, month, paymentMethods, includeHT, includeTTC }); - - // Validation - const groupIdSchema = z.coerce.number().int().positive(); - const monthSchema = z.string().regex(/^\d{4}-\d{2}$/); - const paymentMethodsSchema = z.array(z.string()).min(1); - - const validatedGroupId = groupIdSchema.parse(groupId); - const validatedMonth = monthSchema.parse(month); - const validatedPaymentMethods = paymentMethodsSchema.parse(paymentMethods); - - // Vérifier l'autorisation pour les directeurs - if (user.role === 'directeur') { - const userGroups = await storage.getUserGroups(user.id); - const userGroupIds = userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(validatedGroupId)) { - return res.status(403).json({ error: 'Accès refusé - Vous ne pouvez accéder qu\'aux données de votre groupe' }); - } - } - - // Récupérer le groupe - const group = await storage.getGroup(validatedGroupId); - if (!group) { - return res.status(404).json({ error: 'Groupe non trouvé' }); - } - - // Récupérer les livraisons du groupe avec échéance (filtre magasin fait en base) - const allDeliveries = await storage.getDeliveries([validatedGroupId]); - const groupDeliveries = allDeliveries.filter((d: any) => - d.groupId === validatedGroupId && - d.invoiceReference && - d.dueDate - ); - - // Filtrer par mois - const { parseISO, startOfMonth, endOfMonth, isWithinInterval, format } = await import('date-fns'); - const { fr } = await import('date-fns/locale'); - const monthStart = parseISO(`${validatedMonth}-01`); - const monthEnd = endOfMonth(monthStart); - - const filteredDeliveries = groupDeliveries.filter((d: any) => { - try { - const dueDate = new Date(d.dueDate); - return isWithinInterval(dueDate, { start: monthStart, end: monthEnd }); - } catch { - return false; - } - }); - - // Récupérer les fournisseurs - const allSuppliers = await storage.getSuppliers(); - const supplierMap = new Map(allSuppliers.map((s: any) => [s.id, s])); - - // Filtrer par modes de paiement sélectionnés - const schedules = filteredDeliveries - .map((delivery: any) => { - const supplier = supplierMap.get(delivery.supplierId); - return { - dueDate: new Date(delivery.dueDate), - supplierName: supplier?.name || 'Fournisseur inconnu', - invoiceReference: delivery.invoiceReference, - paymentMethod: supplier?.paymentMethod || 'Non défini', - amountHT: delivery.invoiceAmount ? parseFloat(delivery.invoiceAmount) : 0, - amountTTC: delivery.invoiceAmountTTC ? parseFloat(delivery.invoiceAmountTTC) : 0, - }; - }) - .filter((schedule: any) => validatedPaymentMethods.includes(schedule.paymentMethod)) - .sort((a: any, b: any) => a.dueDate.getTime() - b.dueDate.getTime()); - - // Générer le fichier CSV avec point-virgule (séparateur Excel France) - console.log('📄 [EXPORT] Génération du fichier CSV avec point-virgule...'); - - // Préparer les en-têtes - const headers = ['Date d\'échéance', 'Fournisseur', 'Facture', 'Mode de paiement']; - if (includeHT) headers.push('Montant HT'); - if (includeTTC) headers.push('Montant TTC'); - - // Créer les lignes de données - const csvLines: string[] = []; - - // Ajouter la ligne d'en-tête - csvLines.push(headers.join(';')); - - // Ajouter les données - schedules.forEach((schedule: any) => { - const row: string[] = [ - format(schedule.dueDate, 'dd/MM/yyyy', { locale: fr }), - schedule.supplierName, - schedule.invoiceReference, - schedule.paymentMethod, - ]; - if (includeHT) row.push(schedule.amountHT.toFixed(2)); - if (includeTTC) row.push(schedule.amountTTC.toFixed(2)); - csvLines.push(row.join(';')); - }); - - // Calculer les totaux - const totalHT = schedules.reduce((sum: number, s: any) => sum + s.amountHT, 0); - const totalTTC = schedules.reduce((sum: number, s: any) => sum + s.amountTTC, 0); - - // Ajouter une ligne de total - const totalRow: string[] = ['', '', '', 'TOTAL']; - if (includeHT) totalRow.push(totalHT.toFixed(2)); - if (includeTTC) totalRow.push(totalTTC.toFixed(2)); - csvLines.push(totalRow.join(';')); - - // Générer le contenu CSV avec BOM UTF-8 pour Excel - const BOM = '\uFEFF'; - const csvContent = BOM + csvLines.join('\n'); - - // Envoyer le fichier - res.setHeader('Content-Disposition', `attachment; filename="echeancier_${validatedMonth}.csv"`); - res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.send(csvContent); - - console.log(`📊 [EXPORT] CSV généré: ${schedules.length} échéances pour ${group.name}`); - - } catch (error: any) { - console.error('❌ [EXPORT] Erreur export Excel:', error); - console.error('❌ [EXPORT] Message:', error.message); - console.error('❌ [EXPORT] Stack:', error.stack); - res.status(500).json({ - error: 'Erreur lors de l\'export', - details: error.message, - stack: process.env.NODE_ENV === 'development' ? error.stack : undefined - }); - } - }); - - // Route BAP pour envoi webhook n8n - app.post('/api/bap/send-webhook', isAuthenticated, async (req: any, res) => { - try { - console.log('🔍 BAP: Requête reçue', { - hasUser: !!req.user, - userType: typeof req.user, - hasClaims: !!(req.user?.claims), - hasId: !!(req.user?.id), - bodyKeys: Object.keys(req.body || {}) - }); - - // Vérifier que l'utilisateur est admin - const userId = req.user?.claims?.sub || req.user?.id; - console.log('🔍 BAP: User ID extracted:', userId); - - if (!userId) { - console.error('❌ BAP: No user ID found'); - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - console.log('🔍 BAP: User found:', { id: user?.id, role: user?.role }); - - if (!user || user.role !== 'admin') { - console.error('❌ BAP: Access denied', { user: user?.role }); - return res.status(403).json({ error: 'Accès refusé - Admin uniquement' }); - } - - // Récupérer les données JSON du body - const { pdfBase64, fileName, recipient } = req.body; - - // Valider les données - if (!pdfBase64 || !fileName || !recipient) { - return res.status(400).json({ error: 'Données manquantes: pdfBase64, fileName ou recipient' }); - } - - if (!['Laurie', 'Jeremy'].includes(recipient)) { - return res.status(400).json({ error: 'Destinataire invalide' }); - } - - // Vérifier l'extension PDF - if (!fileName.toLowerCase().endsWith('.pdf')) { - return res.status(400).json({ error: 'Le fichier doit être un PDF' }); - } - - // Décoder le base64 en buffer - let fileBuffer: Buffer; - try { - fileBuffer = Buffer.from(pdfBase64, 'base64'); - } catch (error) { - return res.status(400).json({ error: 'Format base64 invalide' }); - } - - console.log('📤 BAP: Envoi webhook n8n', { - recipient, - fileName, - fileSize: fileBuffer.length, - userId: user.id - }); - - // Préparer les données JSON pour le webhook n8n (sans form-data) - const webhookPayload = { - recipient: recipient, - fileName: fileName, - fileSize: fileBuffer.length, - pdfBase64: pdfBase64, // On renvoie le base64 directement - contentType: 'application/pdf' - }; - - console.log('✅ BAP: Payload JSON préparé', { - recipient, - fileName, - fileSize: fileBuffer.length - }); - - // Récupérer l'URL du webhook depuis la configuration - const webhookConfig = await storage.getWebhookBapConfig(); - if (!webhookConfig || !webhookConfig.isActive) { - console.error('❌ BAP: Configuration webhook non trouvée ou inactive'); - return res.status(500).json({ error: 'Configuration webhook BAP non disponible' }); - } - - const webhookUrl = webhookConfig.webhookUrl; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 secondes - - console.log('🌐 BAP: Envoi vers webhook n8n (POST avec body JSON)...'); - - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(webhookPayload), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - console.log('🌐 BAP: Réponse webhook reçue', { status: response.status, ok: response.ok }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('❌ BAP: Erreur webhook', { status: response.status, errorText }); - throw new Error(`Erreur webhook: ${response.status} ${response.statusText}`); - } - - const result = await response.text(); - - console.log('✅ BAP: Webhook n8n réussi', { recipient, result: result.substring(0, 100) }); - - res.json({ - success: true, - message: 'Fichier envoyé avec succès', - webhookResponse: result - }); - - } catch (error: any) { - console.error('❌ BAP: Erreur complète:', { - name: error.name, - message: error.message, - stack: error.stack?.substring(0, 500) - }); - - let errorMessage = 'Erreur lors de l\'envoi du fichier'; - if (error.name === 'AbortError') { - errorMessage = 'Timeout - Le traitement a pris trop de temps'; - } else if (error.message) { - errorMessage = error.message; - } - - res.status(500).json({ - error: errorMessage, - details: error.message - }); - } - }); - - // Parser multipart/form-data manuellement (sans busboy/multer) - function parseMultipart(buffer: Buffer, boundary: string) { - const parts: any = {}; - const boundaryBuffer = Buffer.from(`--${boundary}`); - const sections = []; - - let start = 0; - while (true) { - const boundaryIndex = buffer.indexOf(boundaryBuffer, start); - if (boundaryIndex === -1) break; - - if (start > 0) { - sections.push(buffer.slice(start, boundaryIndex)); - } - start = boundaryIndex + boundaryBuffer.length; - } - - for (const section of sections) { - const headerEnd = section.indexOf('\r\n\r\n'); - if (headerEnd === -1) continue; - - const headers = section.slice(0, headerEnd).toString(); - const content = section.slice(headerEnd + 4, section.length - 2); - - const nameMatch = headers.match(/name="([^"]+)"/); - const filenameMatch = headers.match(/filename="([^"]+)"/); - const contentTypeMatch = headers.match(/Content-Type: ([^\r\n]+)/); - - if (!nameMatch) continue; - const name = nameMatch[1]; - - if (filenameMatch) { - parts[name] = { - filename: filenameMatch[1], - contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream', - buffer: content - }; - } else { - parts[name] = content.toString('utf-8'); - } - } - - return parts; - } - - // Route proxy SIMPLE pour envoi de factures (admin + directeur) - app.post('/api/reconciliation/send-invoice', isAuthenticated, async (req: any, res) => { - try { - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Non authentifié' }); - } - - const user = getCurrentUser(req); - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - return res.status(403).json({ error: 'Accès refusé' }); - } - - // Parser le multipart/form-data manuellement - const contentType = req.headers['content-type'] || ''; - // Gestion plus robuste du boundary (avec ou sans guillemets) - const boundaryMatch = contentType.match(/boundary="?([^";]+)"?/i); - if (!boundaryMatch) { - console.error('❌ INVOICE PROXY: Boundary manquant dans Content-Type', contentType); - return res.status(400).json({ error: 'Format multipart invalide' }); - } - - const boundary = boundaryMatch[1]; - const chunks: Buffer[] = []; - - await new Promise((resolve, reject) => { - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => resolve()); - req.on('error', reject); - }); - - const buffer = Buffer.concat(chunks); - const parts = parseMultipart(buffer, boundary); - - if (!parts.file || !parts.webhookUrl) { - return res.status(400).json({ error: 'Fichier ou webhook manquant' }); - } - - console.log('📤 INVOICE PROXY: Envoi facture', { - userId: user.id, - role: user.role, - fileName: parts.file.filename, - size: parts.file.buffer.length - }); - - // Utilisation du FormData natif de Node.js (global) - // On utilise Blob pour transformer le Buffer en objet compatible - const formData = new FormData(); - const fileBlob = new Blob([parts.file.buffer], { type: parts.file.contentType }); - - formData.append('file', fileBlob, parts.file.filename); - formData.append('supplier', parts.supplier || ''); - formData.append('blNumber', parts.blNumber || ''); - formData.append('type', parts.type || 'Facture'); - - console.log('🔗 INVOICE PROXY: Calling webhook URL:', parts.webhookUrl); - - // En utilisant le FormData natif avec fetch, pas besoin de headers manuels (boundary géré automatiquement) - const response = await fetch(parts.webhookUrl, { - method: 'POST', - body: formData as any - }); - - if (!response.ok) { - const errorBody = await response.text().catch(() => 'No body'); - console.error('❌ INVOICE PROXY: Webhook error', { - status: response.status, - statusText: response.statusText, - body: errorBody - }); - return res.status(500).json({ - error: `Webhook error: ${response.status}`, - details: errorBody - }); - } - - console.log('✅ INVOICE PROXY: Success'); - res.json({ success: true, message: 'Facture envoyée' }); - - } catch (error: any) { - console.error('❌ INVOICE PROXY: Error', error); - res.status(500).json({ error: error.message || 'Erreur serveur' }); - } - }); - - // Auth routes handled by authSwitch (local or Replit) - - // Groups routes - app.get('/api/groups', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Only admin sees all groups, all other roles (manager, employee, directeur) see only their assigned groups - if (user.role === 'admin') { - const groups = await storage.getGroups(); - res.json(groups); - } else { - const userGroups = (user as any).userGroups?.map((ug: any) => ug.group).filter(Boolean) || []; - res.json(userGroups); - } - } catch (error) { - console.error("Error fetching groups:", error); - res.status(500).json({ message: "Failed to fetch groups" }); - } - }); - - app.post('/api/groups', isAuthenticated, async (req: any, res) => { - try { - // Debug logging pour la création de groupe - console.log('📨 POST /api/groups - Headers:', { - 'content-type': req.headers['content-type'], - 'content-length': req.headers['content-length'], - 'user-agent': req.headers['user-agent']?.substring(0, 50) + '...' - }); - - console.log('📋 POST /api/groups - Request body:', JSON.stringify(redactBody(req.body), null, 2)); - - // Déterminer l'ID utilisateur selon l'environnement - let userId; - if (req.user.claims && req.user.claims.sub) { - userId = req.user.claims.sub; // Production Replit Auth - console.log('🔐 Using Replit Auth user ID:', userId); - } else if (req.user.id) { - userId = req.user.id; // Développement local - console.log('🔐 Using local auth user ID:', userId); - } else { - console.error('❌ No user ID found in request:', { user: req.user }); - return res.status(401).json({ message: "User authentication failed" }); - } - - console.log('🔐 User requesting group creation:', userId); - - // Vérifier l'utilisateur - const user = await storage.getUser(userId); - if (!user) { - console.error('❌ User not found:', userId); - return res.status(404).json({ message: "User not found" }); - } - - console.log('✅ User found:', { username: user.username, role: user.role }); - - // Vérifier les permissions - if (user.role !== 'admin' && user.role !== 'manager') { - console.error('❌ Insufficient permissions:', { userRole: user.role, required: ['admin', 'manager'] }); - return res.status(403).json({ message: "Insufficient permissions" }); - } - - console.log('✅ User has permission to create group'); - - // Valider les données - console.log('🔍 Validating group data with schema...'); - const data = insertGroupSchema.parse(req.body); - console.log('✅ Group data validation passed:', data); - - // Créer le groupe - console.log('🏪 Creating group in database...'); - const group = await storage.createGroup(data); - console.log('✅ Group creation successful:', { id: group.id, name: group.name }); - - res.json(group); - } catch (error: any) { - console.error('❌ Failed to create group:', { - error: error?.message || 'Unknown error', - stack: error?.stack, - body: redactBody(req.body), - userId: req.user?.id || req.user?.claims?.sub || 'unknown' - }); - - // Erreur de validation Zod - if (error?.name === 'ZodError') { - console.error('❌ Validation error details:', error.errors); - return res.status(400).json({ - message: "Validation failed", - errors: error.errors - }); - } - - res.status(500).json({ message: "Failed to create group" }); - } - }); - - app.put('/api/groups/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'manager')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - const data = insertGroupSchema.partial().parse(req.body); - - // Le mot de passe SMTP n'est jamais renvoyé au client : un champ vide - // signifie "inchangé", pas "effacer". On ne l'écrase que s'il est fourni. - if (!data.smtpPassword) { - delete (data as any).smtpPassword; - } - - const group = await storage.updateGroup(id, data); - res.json(group); - } catch (error: any) { - console.error("Error updating group:", error); - res.status(500).json({ message: "Failed to update group" }); - } - }); - - // Test de la configuration SMTP d'un magasin (aucun message envoyé) - app.post('/api/groups/:id/test-smtp', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'manager')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - const group = await storage.getGroup(id); - if (!group) { - return res.status(404).json({ message: "Magasin introuvable" }); - } - - const missing = getMissingSmtpFields(group as any); - if (missing.length > 0) { - return res.status(400).json({ - success: false, - message: `Configuration incomplète : ${missing.join(', ')}` - }); - } - - await verifySmtpConfig(group as any); - res.json({ success: true, message: "Connexion au serveur SMTP réussie" }); - } catch (error: any) { - console.error("Erreur test SMTP:", error); - res.status(400).json({ - success: false, - message: error?.message || "Impossible de joindre le serveur SMTP" - }); - } - }); - - app.delete('/api/groups/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'manager')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - await storage.deleteGroup(id); - res.json({ message: "Group deleted successfully" }); - } catch (error) { - console.error("Error deleting group:", error); - res.status(500).json({ message: "Failed to delete group" }); - } - }); - - // Suppliers routes - app.get('/api/suppliers', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'manager' && user.role !== 'directeur' && user.role !== 'employee')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Check if DLC filter is requested - const dlcFilter = req.query.dlc === 'true'; - const suppliers = await storage.getSuppliers(); - - // Filter suppliers for DLC enabled only if requested - if (dlcFilter) { - const dlcSuppliers = suppliers.filter(supplier => supplier.hasDlc === true); - res.json(dlcSuppliers); - } else { - res.json(suppliers); - } - } catch (error) { - console.error("Error fetching suppliers:", error); - res.status(500).json({ message: "Failed to fetch suppliers" }); - } - }); - - app.post('/api/suppliers', isAuthenticated, async (req: any, res) => { - try { - // Debug logging pour la création de fournisseur - console.log('📨 POST /api/suppliers - Headers:', { - 'content-type': req.headers['content-type'], - 'content-length': req.headers['content-length'] - }); - - console.log('📋 POST /api/suppliers - Request body:', JSON.stringify(redactBody(req.body), null, 2)); - - // Déterminer l'ID utilisateur selon l'environnement - let userId; - if (req.user.claims && req.user.claims.sub) { - userId = req.user.claims.sub; // Production Replit Auth - console.log('🔐 Using Replit Auth user ID:', userId); - } else if (req.user.id) { - userId = req.user.id; // Développement local - console.log('🔐 Using local auth user ID:', userId); - } else { - console.error('❌ No user ID found in request:', { user: req.user }); - return res.status(401).json({ message: "User authentication failed" }); - } - - console.log('🔐 User requesting supplier creation:', userId); - - // Vérifier l'utilisateur - const user = await storage.getUser(userId); - if (!user) { - console.error('❌ User not found:', userId); - return res.status(404).json({ message: "User not found" }); - } - - console.log('✅ User found:', { username: user.username, role: user.role }); - - // Vérifier les permissions - if (user.role !== 'admin' && user.role !== 'manager' && user.role !== 'directeur') { - console.error('❌ Insufficient permissions:', { userRole: user.role, required: ['admin', 'manager', 'directeur'] }); - return res.status(403).json({ message: "Insufficient permissions" }); - } - - console.log('✅ User has permission to create supplier'); - - // Valider les données - console.log('🔍 Validating supplier data with schema...'); - const data = insertSupplierSchema.parse(req.body); - console.log('✅ Supplier data validation passed:', data); - - // Créer le fournisseur - console.log('🚚 Creating supplier in database...'); - const supplier = await storage.createSupplier(data); - console.log('✅ Supplier creation successful:', { id: supplier.id, name: supplier.name }); - - res.json(supplier); - } catch (error: any) { - console.error('❌ Failed to create supplier:', { - error: (error as Error).message, - stack: error.stack, - body: redactBody(req.body), - userId: req.user?.id || req.user?.claims?.sub || 'unknown' - }); - - // Erreur de validation Zod - if (error.name === 'ZodError') { - console.error('❌ Validation error details:', error.errors); - return res.status(400).json({ - message: "Validation failed", - errors: error.errors - }); - } - - res.status(500).json({ message: "Failed to create supplier" }); - } - }); - - app.put('/api/suppliers/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'manager' && user.role !== 'directeur')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - const data = insertSupplierSchema.partial().parse(req.body); - const supplier = await storage.updateSupplier(id, data); - res.json(supplier); - } catch (error) { - console.error("Error updating supplier:", error); - res.status(500).json({ message: "Failed to update supplier" }); - } - }); - - app.delete('/api/suppliers/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || (user.role !== 'admin' && user.role !== 'directeur')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - await storage.deleteSupplier(id); - res.json({ message: "Supplier deleted successfully" }); - } catch (error) { - console.error("Error deleting supplier:", error); - res.status(500).json({ message: "Failed to delete supplier" }); - } - }); - - // Contacts routes - app.get('/api/contacts', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) return res.status(404).json({ message: "User not found" }); - - let groupIds: number[] | undefined; - if (user.role !== 'admin') { - groupIds = (user.userGroups || []).map((ug: any) => ug.groupId); - } else if (req.query.groupId) { - groupIds = [parseInt(req.query.groupId as string)]; - } - - const result = await storage.getContacts(groupIds); - res.json(result); - } catch (error) { - console.error("Error fetching contacts:", error); - res.status(500).json({ message: "Failed to fetch contacts" }); - } - }); - - app.post('/api/contacts', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - const data = insertContactSchema.parse(req.body); - const contact = await storage.createContact(data); - res.json(contact); - } catch (error: any) { - if (error.name === 'ZodError') { - return res.status(400).json({ message: "Validation failed", errors: error.errors }); - } - console.error("Error creating contact:", error); - res.status(500).json({ message: "Failed to create contact" }); - } - }); - - app.put('/api/contacts/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - const id = parseInt(req.params.id); - const data = insertContactSchema.partial().parse(req.body); - const contact = await storage.updateContact(id, data); - res.json(contact); - } catch (error) { - console.error("Error updating contact:", error); - res.status(500).json({ message: "Failed to update contact" }); - } - }); - - app.delete('/api/contacts/:id', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - const id = parseInt(req.params.id); - await storage.deleteContact(id); - res.json({ message: "Contact deleted successfully" }); - } catch (error) { - console.error("Error deleting contact:", error); - res.status(500).json({ message: "Failed to delete contact" }); - } - }); - - // Orders routes - app.get('/api/orders', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { startDate, endDate, storeId } = req.query; - let orders; - - if (user.role === 'admin') { - let groupIds: number[] | undefined; - - // If admin selected a specific store, filter by it - if (storeId) { - groupIds = [parseInt(storeId as string)]; - } - - // Only filter by date if both startDate and endDate are provided - if (startDate && endDate) { - orders = await storage.getOrdersByDateRange(startDate as string, endDate as string, groupIds); - } else { - orders = await storage.getOrders(groupIds); - } - } else { - // For manager and employee roles, filter by their assigned groups - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - let groupIds: number[] | undefined; - - if (storeId) { - // If a specific store is requested, verify user has access to it - const requestedStoreId = parseInt(storeId as string); - if (userGroupIds.includes(requestedStoreId)) { - groupIds = [requestedStoreId]; - } else { - // User doesn't have access to this store, return empty array - return res.json([]); - } - } else { - // For directeur role, automatically use their assigned store (but with full permissions within that store) - if (user.role === 'directeur') { - if (userGroupIds.length > 0) { - groupIds = [userGroupIds[0]]; // Use first assigned store automatically - } else { - return res.json([]); - } - } - // For manager role, automatically use their assigned store - else if (user.role === 'manager') { - if (userGroupIds.length > 0) { - groupIds = [userGroupIds[0]]; // Use first assigned store automatically - } else { - return res.json([]); - } - } else { - // For employee role, require explicit store selection - return res.json([]); - } - } - - // Only filter by date if both startDate and endDate are provided - if (startDate && endDate) { - orders = await storage.getOrdersByDateRange(startDate as string, endDate as string, groupIds); - } else { - orders = await storage.getOrders(groupIds); - } - } - - res.json(orders); - } catch (error) { - console.error("Error fetching orders:", error); - res.status(500).json({ message: "Failed to fetch orders" }); - } - }); - - // Statistiques agrégées commandes/livraisons. - // - // Les pages Magasins et Fournisseurs n'affichent que des comptages. Elles - // chargeaient tout l'historique via /api/orders et /api/deliveries pour ne - // faire que des .filter().length côté navigateur : le comptage est fait en - // base et seules les valeurs agrégées transitent. - const resolveStatsGroupIds = (user: any, storeId?: string): number[] | undefined | null => { - const userGroupIds: number[] = user.userGroups?.map((ug: any) => ug.groupId) || []; - - if (user.role === 'admin') { - return storeId ? [parseInt(storeId)] : undefined; // undefined = tous les magasins - } - - if (storeId) { - const requestedStoreId = parseInt(storeId); - // null = accès refusé au magasin demandé - return userGroupIds.includes(requestedStoreId) ? [requestedStoreId] : null; - } - - return userGroupIds.length > 0 ? userGroupIds : null; - }; - - app.get('/api/stats/by-group', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined); - if (groupIds === null) { - return res.json([]); - } - - res.json(await storage.getOrderDeliveryStatsByGroup(groupIds)); - } catch (error) { - console.error("Error fetching group stats:", error); - res.status(500).json({ message: "Failed to fetch group stats" }); - } - }); - - app.get('/api/stats/by-supplier', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined); - if (groupIds === null) { - return res.json([]); - } - - res.json(await storage.getOrderDeliveryStatsBySupplier(groupIds)); - } catch (error) { - console.error("Error fetching supplier stats:", error); - res.status(500).json({ message: "Failed to fetch supplier stats" }); - } - }); - - app.get('/api/orders/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const order = await storage.getOrder(id); - - if (!order) { - return res.status(404).json({ message: "Order not found" }); - } - - // Check if user has access to this order (only admin can access all orders) - if (user.role !== 'admin') { - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(order.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - res.json(order); - } catch (error) { - console.error("Error fetching order:", error); - res.status(500).json({ message: "Failed to fetch order" }); - } - }); - - app.post('/api/orders', isAuthenticated, async (req: any, res) => { - try { - console.log('📦 Order creation started:', { - userId: req.user?.id || req.user?.claims?.sub, - body: redactBody(req.body), - environment: process.env.NODE_ENV - }); - - const user = getCurrentUser(req); - if (!user) { - console.log('❌ User not found in order creation'); - return res.status(404).json({ message: "User not found" }); - } - - console.log('👤 User found for order creation:', { - id: user.id, - role: user.role, - groupsCount: user.userGroups.length, - groups: user.userGroups.map(ug => ({ groupId: ug.groupId, groupName: ug.group?.name })) - }); - - const data = insertOrderSchema.parse({ - ...req.body, - createdBy: user.id, - }); - - console.log('✅ Order data validated:', data); - - // Check if user has access to the group (only admin can access all groups) - if (user.role !== 'admin') { - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(data.groupId)) { - console.log('❌ Access denied to group:', { requestedGroupId: data.groupId, userGroups: userGroupIds }); - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - console.log('🚀 Creating order in storage...'); - const order = await storage.createOrder(data); - console.log('✅ Order created successfully:', { - id: order.id, - groupId: order.groupId, - plannedDate: order.plannedDate, - supplierId: order.supplierId - }); - - res.json(order); - } catch (error: any) { - console.error("❌ Error creating order:", { - error: (error as Error).message, - stack: error.stack, - body: redactBody(req.body), - userId: req.user?.id || req.user?.claims?.sub || 'unknown' - }); - - // Erreur de validation Zod - if (error.name === 'ZodError') { - console.error('❌ Order validation error details:', error.errors); - return res.status(400).json({ - message: "Validation failed", - errors: error.errors - }); - } - - res.status(500).json({ message: "Failed to create order" }); - } - }); - - app.put('/api/orders/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const order = await storage.getOrder(id); - - if (!order) { - return res.status(404).json({ message: "Order not found" }); - } - - // Check edit permissions using the shared permission system - if (!hasPermission(user.role, 'orders', 'edit')) { - return res.status(403).json({ message: "Insufficient permissions to edit orders" }); - } - - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(order.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - const data = insertOrderSchema.partial().parse(req.body); - const updatedOrder = await storage.updateOrder(id, data); - res.json(updatedOrder); - } catch (error) { - console.error("Error updating order:", error); - res.status(500).json({ message: "Failed to update order" }); - } - }); - - app.delete('/api/orders/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const order = await storage.getOrder(id); - - if (!order) { - return res.status(404).json({ message: "Order not found" }); - } - - // Check delete permissions using the shared permission system - if (!hasPermission(user.role, 'orders', 'delete')) { - return res.status(403).json({ message: "Insufficient permissions to delete orders" }); - } - - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(order.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteOrder(id); - res.json({ message: "Order deleted successfully" }); - } catch (error) { - console.error("Error deleting order:", error); - res.status(500).json({ message: "Failed to delete order" }); - } - }); - - // Route pour diagnostiquer et synchroniser les statuts commandes/livraisons - app.post('/api/sync-order-delivery-status', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - console.log('🔄 Starting order-delivery status synchronization...'); - - // Diagnostic: Trouver les commandes avec livraisons livrées mais pas en statut "delivered" - const orders = await storage.getOrders(); - const problematicOrders = []; - const fixedOrders = []; - - for (const order of orders) { - // getOrders() renvoie déjà les livraisons de chaque commande : les - // recharger ici relisait toute la table à chaque itération. - const orderDeliveries = order.deliveries ?? []; - - if (orderDeliveries.length > 0) { - const hasDeliveredDeliveries = orderDeliveries.some((d: any) => d.status === 'delivered'); - - if (hasDeliveredDeliveries && order.status !== 'delivered') { - console.log(`🔍 Found problematic order: #CMD-${order.id} (status: ${order.status}) with delivered deliveries`); - problematicOrders.push({ - orderId: order.id, - currentStatus: order.status, - deliveredDeliveries: orderDeliveries.filter((d: any) => d.status === 'delivered').length, - totalDeliveries: orderDeliveries.length - }); - - // Fixer automatiquement - try { - await storage.updateOrder(order.id, { status: 'delivered' }); - console.log(`✅ Fixed order #CMD-${order.id} status to 'delivered'`); - fixedOrders.push(order.id); - } catch (error) { - console.error(`❌ Failed to fix order #CMD-${order.id}:`, error); - } - } - } - } - - console.log('🔄 Synchronization completed'); - - res.json({ - message: "Synchronization completed", - diagnostics: { - problematicOrdersFound: problematicOrders.length, - ordersFixed: fixedOrders.length, - problematicOrders, - fixedOrders - } - }); - - } catch (error) { - console.error("❌ Error in sync operation:", error); - res.status(500).json({ message: "Failed to synchronize statuses", error: (error as Error).message }); - } - }); - - // Deliveries routes - app.get('/api/deliveries', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { startDate, endDate, storeId, withBL } = req.query; - let deliveries; - - // Filtre de statut optionnel, appliqué en base (ex. rapprochement BL : - // status=delivered). Toute autre valeur est ignorée. - const allowedStatuses = ['pending', 'planned', 'delivered']; - const status = typeof req.query.status === 'string' && allowedStatuses.includes(req.query.status) - ? req.query.status - : undefined; - - if (user.role === 'admin') { - let groupIds: number[] | undefined; - - // If admin selected a specific store, filter by it - if (storeId) { - groupIds = [parseInt(storeId as string)]; - } - - // Only filter by date if both startDate and endDate are provided - if (startDate && endDate) { - deliveries = await storage.getDeliveriesByDateRange(startDate as string, endDate as string, groupIds); - } else { - deliveries = await storage.getDeliveries(groupIds, { status }); - } - } else { - // For manager and employee roles, filter by their assigned groups - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - let groupIds: number[] | undefined; - - if (storeId) { - // If a specific store is requested, verify user has access to it - const requestedStoreId = parseInt(storeId as string); - if (userGroupIds.includes(requestedStoreId)) { - groupIds = [requestedStoreId]; - } else { - // User doesn't have access to this store, return empty array - return res.json([]); - } - } else { - // For directeur role, automatically use their assigned store (but with full permissions within that store) - if (user.role === 'directeur') { - if (userGroupIds.length > 0) { - groupIds = [userGroupIds[0]]; // Use first assigned store automatically - } else { - return res.json([]); - } - } - // For manager role, automatically use their assigned store - else if (user.role === 'manager') { - if (userGroupIds.length > 0) { - groupIds = [userGroupIds[0]]; // Use first assigned store automatically - } else { - return res.json([]); - } - } else { - // For employee role, require explicit store selection - return res.json([]); - } - } - - // Only filter by date if both startDate and endDate are provided - if (startDate && endDate) { - deliveries = await storage.getDeliveriesByDateRange(startDate as string, endDate as string, groupIds); - } else { - deliveries = await storage.getDeliveries(groupIds, { status }); - } - } - - // La lecture par plage de dates ne filtre pas le statut en base - if (status && startDate && endDate) { - deliveries = deliveries.filter((d: any) => d.status === status); - } - - // Filter for BL if requested - if (withBL === 'true') { - deliveries = deliveries.filter((d: any) => d.blNumber && d.status === 'delivered'); - } - - res.json(deliveries); - } catch (error) { - console.error("Error fetching deliveries:", error); - res.status(500).json({ message: "Failed to fetch deliveries" }); - } - }); - - app.get('/api/deliveries/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const delivery = await storage.getDelivery(id); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check if user has access to this delivery - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - res.json(delivery); - } catch (error) { - console.error("Error fetching delivery:", error); - res.status(500).json({ message: "Failed to fetch delivery" }); - } - }); - - app.put('/api/deliveries/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const delivery = await storage.getDelivery(id); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check edit permissions using the shared permission system - if (!hasPermission(user.role, 'deliveries', 'edit')) { - return res.status(403).json({ message: "Insufficient permissions to edit deliveries" }); - } - - if (user.role !== 'admin') { - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - // Transform data types before validation - const transformedData = { ...req.body }; - - // Convert decimal amounts to strings or null (schema expects string for decimal fields) - if (transformedData.blAmount !== undefined) { - transformedData.blAmount = (transformedData.blAmount === null || transformedData.blAmount === '') - ? null - : transformedData.blAmount.toString(); - } - if (transformedData.invoiceAmount !== undefined) { - transformedData.invoiceAmount = (transformedData.invoiceAmount === null || transformedData.invoiceAmount === '') - ? null - : transformedData.invoiceAmount.toString(); - } - - // Convert text fields - allow null for clearing - if (transformedData.invoiceReference !== undefined) { - transformedData.invoiceReference = (transformedData.invoiceReference === '') - ? null - : transformedData.invoiceReference; - } - if (transformedData.blNumber !== undefined) { - transformedData.blNumber = (transformedData.blNumber === '') - ? null - : transformedData.blNumber; - } - - // Convert timestamp fields to Date objects or null (schema expects Date for timestamp fields) - if (transformedData.validatedAt !== undefined) { - transformedData.validatedAt = (transformedData.validatedAt === null || transformedData.validatedAt === '') - ? null - : new Date(transformedData.validatedAt); - } - if (transformedData.dueDate !== undefined) { - transformedData.dueDate = (transformedData.dueDate === null || transformedData.dueDate === '') - ? null - : new Date(transformedData.dueDate); - } - - const data = insertDeliverySchema.partial().parse(transformedData); - - // CRITICAL FIX: Si une commande est liée lors de la modification, vérifier qu'elle appartient au même magasin - if (data.orderId !== undefined) { - if (data.orderId !== null) { - const linkedOrder = await storage.getOrder(data.orderId); - if (!linkedOrder) { - return res.status(400).json({ message: "La commande liée n'existe pas" }); - } - if (linkedOrder.groupId !== delivery.groupId) { - return res.status(400).json({ - message: `Impossible de lier une livraison du magasin ${delivery.groupId} avec une commande du magasin ${linkedOrder.groupId}` - }); - } - console.log(`✅ Validation OK: Livraison #${id} et commande #${data.orderId} appartiennent au même magasin ${delivery.groupId}`); - } - } - - // GESTION ÉCHÉANCE : Si la référence facture change, mettre à jour la date d'échéance - if (data.invoiceReference !== undefined && data.invoiceReference !== delivery.invoiceReference) { - if (data.invoiceReference && data.invoiceReference.trim()) { - // Nouvelle référence facture : reverifier dans NocoDB pour récupérer l'échéance - try { - const { InvoiceVerificationService } = await import('./invoiceVerification.js'); - const verificationService = new InvoiceVerificationService(); - const result = await verificationService.verifyInvoice( - data.invoiceReference, - delivery.groupId, - true, // forceRefresh - delivery.reconciled || false - ); - - if (result.exists && result.dueDate) { - // Normaliser la date avant de la stocker - const normalizedDateString = normalizeDateString(result.dueDate); - if (normalizedDateString) { - // Convertir la string normalisée en objet Date pour Drizzle - data.dueDate = new Date(normalizedDateString); - console.log(`📅 Date d'échéance récupérée et normalisée: ${normalizedDateString} (original: ${result.dueDate})`); - } else { - data.dueDate = null; - console.log(`📅 Date d'échéance invalide, ignorée`); - } - } else { - data.dueDate = null; - console.log(`📅 Aucune date d'échéance trouvée dans NocoDB`); - } - } catch (error) { - console.error('❌ Erreur récupération échéance:', error); - // Ne pas bloquer la mise à jour, juste ne pas avoir d'échéance - data.dueDate = null; - } - } else { - // Référence facture vidée : vider aussi l'échéance - data.dueDate = null; - console.log(`📅 Référence facture vidée, échéance également vidée`); - } - } - - const updatedDelivery = await storage.updateDelivery(id, data); - - // SYNCHRONISATION AUTOMATIQUE : Si livraison devient "delivered", marquer la commande associée comme "delivered" - // MAIS seulement après validation explicite (pas juste mise à jour status) - // Cette sync sera gérée dans validateDelivery endpoint uniquement - - // AUTO-VALIDATION RAPPROCHEMENT AUTOMATIQUE : Si fournisseur en mode automatique, livraison delivered + BL → auto-valider - if (data.status === 'delivered' || data.blNumber) { - try { - // Récupérer le fournisseur pour vérifier le mode automatique : il est - // déjà joint à la livraison, la table n'est relue que s'il a changé - const supplier = (updatedDelivery.supplierId === delivery.supplierId && delivery.supplier) - ? delivery.supplier - : (await storage.getSuppliers()).find((s: any) => s.id === updatedDelivery.supplierId); - - if (supplier?.automaticReconciliation && - updatedDelivery.status === 'delivered' && - updatedDelivery.blNumber) { - - console.log(`🤖 Auto-reconciliation: Delivery #${id} from automatic supplier ${supplier.name}, auto-validating...`); - - // Auto-valider le rapprochement - await storage.updateDelivery(id, { - reconciled: true, - validatedAt: new Date() - }); - - console.log(`✅ Auto-reconciliation: Delivery #${id} automatically validated for supplier ${supplier.name}`); - } - } catch (error) { - console.error(`❌ Auto-reconciliation failed for delivery #${id}:`, error); - } - } - - res.json(updatedDelivery); - } catch (error) { - console.error("Error updating delivery:", error); - res.status(500).json({ message: "Failed to update delivery" }); - } - }); - - app.post('/api/deliveries', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - console.log('🚛 Creating delivery with data:', req.body); - - // Traiter les données pour orderId - "none" devient null - const processedBody = { ...req.body }; - if (processedBody.orderId === "none" || processedBody.orderId === "") { - processedBody.orderId = null; - } else if (processedBody.orderId) { - processedBody.orderId = parseInt(processedBody.orderId); - } - - const data = insertDeliverySchema.parse({ - ...processedBody, - createdBy: user.id, - }); - - console.log('🚛 Processed delivery data:', data); - - // Check if user has access to the group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(data.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - // CRITICAL FIX: Si une commande est liée, vérifier qu'elle appartient au même magasin - if (data.orderId) { - const linkedOrder = await storage.getOrder(data.orderId); - if (!linkedOrder) { - return res.status(400).json({ message: "La commande liée n'existe pas" }); - } - if (linkedOrder.groupId !== data.groupId) { - return res.status(400).json({ - message: `Impossible de lier une livraison du magasin ${data.groupId} avec une commande du magasin ${linkedOrder.groupId}` - }); - } - console.log(`✅ Validation OK: Livraison et commande #${data.orderId} appartiennent au même magasin ${data.groupId}`); - } - - const delivery = await storage.createDelivery(data); - - // Log de liaison avec commande - if (data.orderId) { - console.log(`🔗 Delivery #${delivery.id} linked to order #${data.orderId}`); - } else { - console.log(`🚛 Delivery #${delivery.id} created without order link`); - } - - res.json(delivery); - } catch (error) { - console.error("Error creating delivery:", error); - res.status(500).json({ message: "Failed to create delivery" }); - } - }); - - // SUPPRIMÉ : Doublon d'endpoint PUT /api/deliveries/:id (le premier endpoint avec logique complète est conservé) - - app.delete('/api/deliveries/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const delivery = await storage.getDelivery(id); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check permissions using the shared permission system - if (!hasPermission(user.role, 'deliveries', 'delete')) { - return res.status(403).json({ message: "Insufficient permissions to delete deliveries" }); - } - - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteDelivery(id); - res.json({ message: "Delivery deleted successfully" }); - } catch (error) { - console.error("Error deleting delivery:", error); - res.status(500).json({ message: "Failed to delete delivery" }); - } - }); - - // Route obsolète supprimée - utiliser le système de commentaires de rapprochement à la place - // Les commentaires sont désormais gérés via /api/deliveries/:id/reconciliation-comments - - // Routes pour les commentaires de rapprochement - // GET - Récupérer les commentaires d'une livraison - app.get('/api/deliveries/:id/reconciliation-comments', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const deliveryId = parseInt(req.params.id); - const delivery = await storage.getDelivery(deliveryId); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check permissions - if (!hasPermission(user.role, 'deliveries', 'view')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Check group access - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const comments = await storage.getReconciliationComments(deliveryId); - res.json(comments); - } catch (error) { - console.error("Error fetching reconciliation comments:", error); - res.status(500).json({ message: "Failed to fetch reconciliation comments" }); - } - }); - - // POST - Créer un nouveau commentaire - app.post('/api/deliveries/:id/reconciliation-comments', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const deliveryId = parseInt(req.params.id); - const delivery = await storage.getDelivery(deliveryId); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check permissions - if (!hasPermission(user.role, 'deliveries', 'edit')) { - return res.status(403).json({ message: "Insufficient permissions to create comments" }); - } - - // Check group access - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const commentData = insertReconciliationCommentSchema.parse({ - ...req.body, - deliveryId, - authorId: user.id, - groupId: delivery.groupId, - }); - - const comment = await storage.createReconciliationComment(commentData); - res.status(201).json(comment); - } catch (error) { - console.error("Error creating reconciliation comment:", error); - res.status(500).json({ message: "Failed to create reconciliation comment" }); - } - }); - - // PUT - Modifier un commentaire - app.put('/api/reconciliation-comments/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const commentId = parseInt(req.params.id); - const comment = await storage.getReconciliationCommentById(commentId); - - if (!comment) { - return res.status(404).json({ message: "Comment not found" }); - } - - // Check permissions - only author or admin can edit - if (user.role !== 'admin' && comment.authorId !== user.id) { - return res.status(403).json({ message: "Only comment author or admin can edit comments" }); - } - - // Check group access - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(comment.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const updateData = insertReconciliationCommentSchema.partial().parse(req.body); - const updatedComment = await storage.updateReconciliationComment(commentId, updateData); - - res.json(updatedComment); - } catch (error) { - console.error("Error updating reconciliation comment:", error); - res.status(500).json({ message: "Failed to update reconciliation comment" }); - } - }); - - // DELETE - Supprimer un commentaire - app.delete('/api/reconciliation-comments/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const commentId = parseInt(req.params.id); - const comment = await storage.getReconciliationCommentById(commentId); - - if (!comment) { - return res.status(404).json({ message: "Comment not found" }); - } - - // Check permissions - only author or admin can delete - if (user.role !== 'admin' && comment.authorId !== user.id) { - return res.status(403).json({ message: "Only comment author or admin can delete comments" }); - } - - // Check group access - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(comment.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - await storage.deleteReconciliationComment(commentId); - res.json({ message: "Comment deleted successfully" }); - } catch (error) { - console.error("Error deleting reconciliation comment:", error); - res.status(500).json({ message: "Failed to delete reconciliation comment" }); - } - }); - - // Route de vérification de facture NocoDB - // Envoi au fournisseur de la demande de facture (PDF) ou de BL (Excel) - // via le serveur SMTP configuré sur la fiche du magasin de la livraison - app.post('/api/deliveries/:id/send-supplier-mail', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const deliveryId = parseInt(req.params.id); - const delivery = await storage.getDelivery(deliveryId); - - if (!delivery) { - return res.status(404).json({ message: "Livraison introuvable" }); - } - - if (!hasPermission(user.role, 'deliveries', 'view')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Hors admin, l'utilisateur doit appartenir au magasin de la livraison - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - // Adresse du fournisseur : celle enregistrée sur sa fiche - const supplierEmail = delivery.supplier?.email?.trim(); - if (!supplierEmail) { - return res.status(400).json({ - message: `Aucune adresse email renseignée pour ${delivery.supplier?.name || 'ce fournisseur'}` - }); - } - - // Configuration SMTP du magasin (mot de passe inclus : usage serveur uniquement) - const group = await storage.getGroup(delivery.groupId); - if (!group) { - return res.status(404).json({ message: "Magasin de la livraison introuvable" }); - } - - if (!(group as any).smtpEnabled) { - return res.status(400).json({ - message: `L'envoi de mails n'est pas activé pour le magasin ${group.name}. Renseignez la configuration SMTP sur sa fiche.` - }); - } - - const missing = getMissingSmtpFields(group as any); - if (missing.length > 0) { - return res.status(400).json({ - message: `Configuration SMTP incomplète pour ${group.name} : ${missing.join(', ')}` - }); - } - - // Historisation de la tentative, succès comme échec — un échec de - // journalisation ne doit jamais faire échouer (ni annuler) l'envoi - const senderName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() - || user.username || user.id; - const logAttempt = async (status: 'sent' | 'failed', extra: { messageId?: string; errorMessage?: string }) => { - try { - await storage.createSupplierMailLog({ - deliveryId, - groupId: delivery.groupId, - supplierId: delivery.supplierId ?? null, - supplierName: delivery.supplier?.name || null, - sentTo: supplierEmail, - subject: buildSupplierMailSubject(delivery as any), - status, - errorMessage: extra.errorMessage || null, - messageId: extra.messageId || null, - sentBy: user.id, - sentByName: senderName, - }); - } catch (logError) { - console.error('⚠️ Historisation du mail fournisseur impossible:', logError); - } - }; - - try { - const result = await sendSupplierDocumentRequest(group as any, delivery as any, supplierEmail); - - await logAttempt('sent', { messageId: result.messageId }); - - console.log('📧 Mail fournisseur envoyé:', { - deliveryId, - supplier: delivery.supplier?.name, - to: supplierEmail, - store: group.name, - messageId: result.messageId - }); - - res.json({ - success: true, - sentTo: supplierEmail, - supplierName: delivery.supplier?.name || null, - messageId: result.messageId - }); - } catch (sendError: any) { - await logAttempt('failed', { errorMessage: sendError?.message || 'Erreur inconnue' }); - throw sendError; - } - } catch (error: any) { - console.error("Erreur envoi mail fournisseur:", error); - res.status(500).json({ - message: error?.message || "Impossible d'envoyer le mail au fournisseur" - }); - } - }); - - // Historique des relances fournisseurs, restreint aux magasins de l'utilisateur - app.get('/api/supplier-mail-logs', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const deliveryId = req.query.deliveryId ? parseInt(req.query.deliveryId as string) : undefined; - - let groupIds: number[] | undefined; - if (user.role !== 'admin') { - groupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (groupIds.length === 0) { - return res.json([]); - } - } else if (req.query.storeId) { - groupIds = [parseInt(req.query.storeId as string)]; - } - - const logs = await storage.getSupplierMailLogs(groupIds, deliveryId); - res.json(logs); - } catch (error) { - console.error("Erreur lecture historique mails fournisseurs:", error); - res.status(500).json({ message: "Failed to fetch supplier mail logs" }); - } - }); - - app.post('/api/deliveries/:id/verify-invoice', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const deliveryId = parseInt(req.params.id); - const delivery = await storage.getDelivery(deliveryId); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check permissions - if (!hasPermission(user.role, 'deliveries', 'view')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Only admin have access to all deliveries, others must be in the same group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(delivery.groupId)) { - console.log('🚫 Access denied - User groups check:', { - userId: user.id, - userRole: user.role, - userGroupIds, - deliveryGroupId: delivery.groupId, - deliverySupplier: delivery.supplier?.name - }); - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const { invoiceReference, blNumber, forceRefresh } = req.body; - - if (!delivery.supplier || !delivery.group) { - console.log('❌ Livraison manque informations:', { - deliveryId, - hasSupplier: !!delivery.supplier, - hasGroup: !!delivery.group - }); - return res.status(400).json({ message: "Delivery missing supplier or group information" }); - } - - // Accepter soit une référence de facture soit un numéro de BL - if ((!invoiceReference || !invoiceReference.trim()) && (!blNumber || !blNumber.trim())) { - return res.status(400).json({ message: "Référence de facture ou numéro BL requis" }); - } - - let result; - - if (invoiceReference && invoiceReference.trim()) { - // Vérifier par référence de facture - result = await invoiceVerificationService.verifyInvoice( - invoiceReference, - delivery.groupId, - forceRefresh || false, - delivery.reconciled || false - ); - } else if (blNumber && blNumber.trim()) { - // Vérifier par numéro BL - result = await invoiceVerificationService.verifyInvoiceByBL( - blNumber, - delivery.supplier.name, - delivery.groupId, - forceRefresh || false, - delivery.reconciled || false - ); - } else { - result = { - exists: false, - matchType: 'none', - errorMessage: 'Aucune référence de facture ou numéro BL fourni' - }; - } - - // CRITICAL FIX: Sauvegarder les données dans la table deliveries après vérification réussie - if (result.exists && (result.invoiceAmount !== undefined || result.invoiceAmountTTC !== undefined || result.dueDate !== undefined || result.invoiceReference !== undefined)) { - try { - const updateData: any = {}; - - // Ajouter la référence facture si trouvée - if (result.invoiceReference) { - updateData.invoiceReference = result.invoiceReference; - } - - // Ajouter le montant facture HT si trouvé - if (result.invoiceAmount !== undefined && result.invoiceAmount !== null) { - updateData.invoiceAmount = result.invoiceAmount.toString(); - } - - // Ajouter le montant facture TTC si trouvé - if (result.invoiceAmountTTC !== undefined && result.invoiceAmountTTC !== null) { - updateData.invoiceAmountTTC = result.invoiceAmountTTC.toString(); - } - - // Ajouter l'échéance si trouvée - if (result.dueDate) { - const normalizedDateString = normalizeDateString(result.dueDate); - if (normalizedDateString) { - updateData.dueDate = new Date(normalizedDateString); - console.log(`✅ Sauvegarde échéance dans deliveries: ${normalizedDateString}`); - } - } - - // Mettre à jour la livraison si on a des données - if (Object.keys(updateData).length > 0) { - await storage.updateDelivery(deliveryId, updateData); - console.log(`✅ Livraison #${deliveryId} mise à jour avec:`, updateData); - } - } catch (error) { - console.error('❌ Erreur sauvegarde données vérification:', error); - // Ne pas bloquer la réponse, juste logger l'erreur - } - } - - res.json(result); - } catch (error) { - console.error("Error verifying invoice:", error); - res.status(500).json({ - message: "Failed to verify invoice", - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - app.post('/api/deliveries/:id/validate', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check validate permissions using the shared permission system - if (!hasPermission(user.role, 'deliveries', 'validate')) { - return res.status(403).json({ message: "Insufficient permissions to validate deliveries" }); - } - - const id = parseInt(req.params.id); - const delivery = await storage.getDelivery(id); - - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Check permissions - if (user.role === 'manager') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(delivery.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - const { blNumber, blAmount } = req.body; - - // Le numéro de BL est maintenant obligatoire pour valider une livraison - if (!blNumber || !blNumber.trim()) { - return res.status(400).json({ message: "Le numéro de bon de livraison est obligatoire pour valider une livraison" }); - } - - let blData: any = { blNumber: blNumber.trim() }; - if (blAmount !== undefined && blAmount !== null && blAmount !== '') { - blData.blAmount = blAmount; - } - - await storage.validateDelivery(id, blData); - - // AUTO-VALIDATION RAPPROCHEMENT : fournisseur en mode automatique → rapprochement validé directement - try { - // Fournisseur déjà joint à la livraison - const supplier = delivery.supplier; - if (supplier?.automaticReconciliation) { - await storage.updateDelivery(id, { - reconciled: true, - validatedAt: new Date() - }); - console.log(`✅ Auto-reconciliation: Delivery #${id} automatically validated for supplier ${supplier.name}`); - } - } catch (error) { - console.error(`❌ Auto-reconciliation failed for delivery #${id}:`, error); - } - - // MISE À JOUR DU CACHE : Marquer le cache comme permanent pour cette livraison validée - try { - if (delivery.invoiceReference && delivery.invoiceReference.trim()) { - console.log('🔄 [CACHE] Mise à jour cache permanent après validation livraison'); - await invoiceVerificationService.updateCacheAsReconciled(delivery.invoiceReference, delivery.groupId); - } - if (delivery.blNumber && delivery.blNumber.trim()) { - console.log('🔄 [CACHE] Mise à jour cache permanent BL après validation livraison'); - await invoiceVerificationService.updateCacheAsReconciled(delivery.blNumber, delivery.groupId); - } - } catch (error) { - console.error('❌ Erreur mise à jour cache après validation:', error); - } - - // SYNCHRONISATION AUTOMATIQUE : Quand validation, marquer la commande associée comme "delivered" - if (delivery.orderId) { - try { - console.log(`🔄 Auto-sync: Delivery #${id} validated, updating order #${delivery.orderId} to delivered`); - await storage.updateOrder(delivery.orderId, { status: 'delivered' }); - console.log(`✅ Auto-sync: Order #${delivery.orderId} automatically marked as delivered`); - } catch (error) { - console.error(`❌ Auto-sync failed for order #${delivery.orderId}:`, error); - } - } - - res.json({ message: "Delivery validated successfully" }); - } catch (error) { - console.error("Error validating delivery:", error); - res.status(500).json({ message: "Failed to validate delivery" }); - } - }); - - // Marquer le contrôle d'une livraison comme effectué - app.put('/api/deliveries/:id/control', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - if (isNaN(id)) { - return res.status(400).json({ message: "Invalid delivery ID" }); - } - - // Vérifier que la livraison existe - const delivery = await storage.getDelivery(id); - if (!delivery) { - return res.status(404).json({ message: "Delivery not found" }); - } - - // Vérifier les permissions - if (!hasPermission(user.role, 'deliveries', 'edit')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Mettre à jour le contrôle - await storage.markDeliveryControlValidated(id, user.id); - - res.json({ message: "Delivery control validated successfully" }); - } catch (error) { - console.error("Error validating delivery control:", error); - res.status(500).json({ message: "Failed to validate delivery control" }); - } - }); - - // Route pour diagnostiquer le cache des livraisons - app.get('/api/cache/diagnosis', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Vérifier que c'est un admin - if (user.role !== 'admin') { - return res.status(403).json({ message: "Seuls les admins peuvent exécuter cette opération" }); - } - - // Récupérer des statistiques sur le cache - const deliveries = await storage.getDeliveries(); - const reconciledCount = deliveries.filter(d => d.reconciled).length; - const totalCount = deliveries.length; - - // Vérifier quelques caches - const sampleCaches = []; - const reconciledDeliveries = deliveries.filter(d => d.reconciled).slice(0, 5); // Prendre 5 exemples - - for (const delivery of reconciledDeliveries) { - if (delivery.invoiceReference) { - const cacheKey = `${delivery.invoiceReference.toLowerCase()}_${delivery.groupId}`; - const cached = await storage.getInvoiceVerificationCache(cacheKey); - sampleCaches.push({ - deliveryId: delivery.id, - invoiceRef: delivery.invoiceReference, - groupId: delivery.groupId, - reconciled: delivery.reconciled, - cacheExists: !!cached, - cacheReconciled: cached?.isReconciled || false, - cacheExpires: cached?.expiresAt - }); - } - } - - res.json({ - statistics: { - totalDeliveries: totalCount, - reconciledDeliveries: reconciledCount, - percentageReconciled: Math.round((reconciledCount / totalCount) * 100) - }, - sampleCaches, - message: "Diagnostic du cache terminé" - }); - } catch (error) { - console.error("Erreur diagnostic cache:", error); - res.status(500).json({ - message: "Erreur lors du diagnostic", - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // Route pour mettre à jour les caches existants des livraisons validées - app.post('/api/cache/update-reconciled', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Vérifier que c'est un admin - if (user.role !== 'admin') { - return res.status(403).json({ message: "Seuls les admins peuvent exécuter cette opération" }); - } - - console.log('🔧 [ADMIN] Exécution mise à jour des caches permanents...'); - await invoiceVerificationService.updateExistingReconciledCaches(); - - res.json({ - message: "Mise à jour des caches permanents terminée avec succès", - success: true - }); - } catch (error) { - console.error("Erreur mise à jour caches:", error); - res.status(500).json({ - message: "Erreur lors de la mise à jour des caches", - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // DLC Products routes - app.get('/api/dlc-products', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { storeId, status, supplierId, search } = req.query; - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const filters: any = {}; - if (status && status !== 'all') filters.status = status as string; - if (supplierId && supplierId !== 'all') filters.supplierId = parseInt(supplierId as string); - if (search) filters.search = search as string; - - const dlcProducts = await storage.getDlcProducts(groupIds, filters); - res.json(dlcProducts); - } catch (error) { - console.error("Error fetching DLC products:", error); - res.status(500).json([]); - } - }); - - app.get('/api/dlc-products/stats', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { storeId } = req.query; - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const stats = await storage.getDlcStats(groupIds); - res.json(stats); - } catch (error) { - console.error("Error fetching DLC stats:", error); - res.status(500).json({ active: 0, expiringSoon: 0, expired: 0 }); - } - }); - - app.get('/api/dlc-products/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - res.json(dlcProduct); - } catch (error) { - console.error("Error fetching DLC product:", error); - res.status(500).json({ message: "Failed to fetch DLC product" }); - } - }); - - app.post('/api/dlc-products', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const data = { - ...req.body, - createdBy: user.id, - }; - - // Check if user has access to the group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(data.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const dlcProduct = await storage.createDlcProduct(data); - res.json(dlcProduct); - } catch (error) { - console.error("Error creating DLC product:", error); - res.status(500).json({ message: "Failed to create DLC product" }); - } - }); - - app.put('/api/dlc-products/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - const updatedProduct = await storage.updateDlcProduct(id, req.body); - res.json(updatedProduct); - } catch (error) { - console.error("Error updating DLC product:", error); - res.status(500).json({ message: "Failed to update DLC product" }); - } - }); - - app.delete('/api/dlc-products/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteDlcProduct(id); - res.json({ message: "DLC Product deleted successfully" }); - } catch (error) { - console.error("Error deleting DLC product:", error); - res.status(500).json({ message: "Failed to delete DLC product" }); - } - }); - - app.post('/api/dlc-products/:id/validate', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check if user has permission to validate DLC products (admin, directeur, manager) - if (!['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to validate DLC products" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // For non-admin users, check if they have access to the product's group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied to this group's DLC products" }); - } - } - - console.log('🔍 DLC Validation attempt:', { - userId: user.id, - userRole: user.role, - dlcProductId: id, - dlcGroupId: dlcProduct.groupId, - userGroups: user.role !== 'admin' ? user.userGroups.map(ug => ug.groupId) : 'all' - }); - - const validatedProduct = await storage.validateDlcProduct(id, user.id); - console.log('✅ DLC Product validated successfully by:', user.role, user.id); - - res.json(validatedProduct); - } catch (error) { - console.error("Error validating DLC product:", error); - res.status(500).json({ message: "Failed to validate DLC product" }); - } - }); - - // Route pour marquer un produit DLC comme stock épuisé - accessible à tous - app.put('/api/dlc-products/:id/stock-epuise', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // For non-admin users, check if they have access to the product's group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied to this group's DLC products" }); - } - } - - console.log('🔍 DLC Stock épuisé attempt:', { - userId: user.id, - userRole: user.role, - dlcProductId: id, - dlcGroupId: dlcProduct.groupId - }); - - const markedProduct = await storage.markDlcProductStockEpuise(id, user.id); - console.log('✅ DLC Product marked as stock épuisé by:', user.role, user.id); - - res.json(markedProduct); - } catch (error) { - console.error("Error marking DLC product as stock épuisé:", error); - res.status(500).json({ message: "Failed to mark DLC product as stock épuisé" }); - } - }); - - // Route pour restaurer le stock d'un produit DLC - réservé aux admins, directeurs et managers - app.put('/api/dlc-products/:id/restore-stock', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check if user has permission to restore stock (admin, directeur, manager) - if (!['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to restore DLC product stock" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // For non-admin users, check if they have access to the product's group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied to this group's DLC products" }); - } - } - - console.log('🔍 DLC Stock restore attempt:', { - userId: user.id, - userRole: user.role, - dlcProductId: id, - dlcGroupId: dlcProduct.groupId - }); - - const restoredProduct = await storage.restoreDlcProductStock(id); - console.log('✅ DLC Product stock restored by:', user.role, user.id); - - res.json(restoredProduct); - } catch (error) { - console.error("Error restoring DLC product stock:", error); - res.status(500).json({ message: "Failed to restore DLC product stock" }); - } - }); - - // Route pour marquer un produit DLC comme traité temporairement (expire bientôt) - accessible à tous - app.put('/api/dlc-products/:id/mark-processed', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // For non-admin users, check if they have access to the product's group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied to this group's DLC products" }); - } - } - - console.log('🔍 DLC Product mark as processed attempt:', { - userId: user.id, - userRole: user.role, - dlcProductId: id, - dlcGroupId: dlcProduct.groupId - }); - - const processedProduct = await storage.markDlcProductAsProcessed(id, user.id); - console.log('✅ DLC Product marked as processed by:', user.role, user.id); - - res.json(processedProduct); - } catch (error) { - console.error("Error marking DLC product as processed:", error); - res.status(500).json({ message: "Failed to mark DLC product as processed" }); - } - }); - - // Route pour annuler le traitement temporaire d'un produit DLC - réservé aux admins, directeurs et managers - app.put('/api/dlc-products/:id/unmark-processed', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check if user has permission to unmark processed (admin, directeur, manager) - if (!['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to unmark DLC product as processed" }); - } - - const id = parseInt(req.params.id); - const dlcProduct = await storage.getDlcProduct(id); - - if (!dlcProduct) { - return res.status(404).json({ message: "DLC Product not found" }); - } - - // For non-admin users, check if they have access to the product's group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(dlcProduct.groupId)) { - return res.status(403).json({ message: "Access denied to this group's DLC products" }); - } - } - - console.log('🔍 DLC Product unmark processed attempt:', { - userId: user.id, - userRole: user.role, - dlcProductId: id, - dlcGroupId: dlcProduct.groupId - }); - - const unprocessedProduct = await storage.unmarkDlcProductAsProcessed(id); - console.log('✅ DLC Product unmarked as processed by:', user.role, user.id); - - res.json(unprocessedProduct); - } catch (error) { - console.error("Error unmarking DLC product as processed:", error); - res.status(500).json({ message: "Failed to unmark DLC product as processed" }); - } - }); - - // Tasks routes - app.get('/api/tasks', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { storeId } = req.query; - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - // Admin can see all tasks or filter by specific store - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // For directeur and other non-admin users: always restrict to their assigned groups - const userGroupIds = user.userGroups.map(ug => ug.groupId); - - if (storeId) { - // If a specific store is requested, verify user has access to it - const requestedStoreId = parseInt(storeId as string); - if (userGroupIds.includes(requestedStoreId)) { - groupIds = [requestedStoreId]; - } else { - // User doesn't have access to this store, return empty array - return res.json([]); - } - } else { - // IMPORTANT FIX: For directeur/manager roles, when no specific store is selected, - // we should NOT show data from all their groups. This was causing the issue - // where after page refresh, data from multiple groups was displayed. - // Return empty result to force explicit store selection for non-admin users. - return res.json([]); - } - } - - const tasks = await storage.getTasks(groupIds, user.role); - res.json(tasks); - } catch (error) { - console.error("Error fetching tasks:", error); - res.status(500).json([]); - } - }); - - app.post('/api/tasks', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions for creating tasks - if (!hasPermission(user.role, 'tasks', 'create')) { - return res.status(403).json({ message: "Permission denied: cannot create tasks" }); - } - - const data = { - ...req.body, - createdBy: user.id, - }; - - // Assign a default groupId if not provided - if (!data.groupId) { - if (user.role === 'admin') { - data.groupId = 1; // Default for admin - } else { - const userGroupIds = user.userGroups?.map(ug => ug.groupId) || []; - data.groupId = userGroupIds.length > 0 ? userGroupIds[0] : 1; - } - } - - // Check if user has access to the group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map(ug => ug.groupId) || []; - if (userGroupIds.length > 0 && !userGroupIds.includes(data.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const task = await storage.createTask(data); - res.json(task); - } catch (error) { - console.error("Error creating task:", error); - res.status(500).json({ message: "Failed to create task" }); - } - }); - - app.put('/api/tasks/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const task = await storage.getTask(id); - - if (!task) { - return res.status(404).json({ message: "Task not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(task.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - // Nettoyer les données reçues pour éviter les problèmes de types - const cleanData: any = {}; - - // Ne pas utiliser de conditions qui ignorent les valeurs falsy légitimes - if (req.body.title !== undefined) cleanData.title = req.body.title; - if (req.body.description !== undefined) cleanData.description = req.body.description; - if (req.body.priority !== undefined) cleanData.priority = req.body.priority; - if (req.body.status !== undefined) cleanData.status = req.body.status; - if (req.body.assignedTo !== undefined) cleanData.assignedTo = req.body.assignedTo; - if (req.body.startDate !== undefined) { - cleanData.startDate = req.body.startDate === '' ? null : req.body.startDate; - } - if (req.body.dueDate !== undefined) { - cleanData.dueDate = req.body.dueDate === '' ? null : req.body.dueDate; - } - - const updatedTask = await storage.updateTask(id, cleanData); - res.json(updatedTask); - } catch (error) { - const taskId = parseInt(req.params.id); - console.error("❌ Error updating task in route:", { - taskId: taskId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - cleanData: req.body ? { - title: req.body.title, - description: req.body.description, - priority: req.body.priority, - status: req.body.status, - assignedTo: req.body.assignedTo, - startDate: req.body.startDate, - dueDate: req.body.dueDate, - } : null - }); - res.status(500).json({ - message: "Failed to update task", - details: error instanceof Error ? error.message : String(error) - }); - } - }); - - app.delete('/api/tasks/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const task = await storage.getTask(id); - - if (!task) { - return res.status(404).json({ message: "Task not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(task.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteTask(id); - res.json({ message: "Task deleted successfully" }); - } catch (error) { - console.error("Error deleting task:", error); - res.status(500).json({ message: "Failed to delete task" }); - } - }); - - app.post('/api/tasks/:id/complete', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const task = await storage.getTask(id); - - if (!task) { - return res.status(404).json({ message: "Task not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(task.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.completeTask(id, user.id); - res.json({ message: "Task completed successfully" }); - } catch (error) { - console.error("Error completing task:", error); - res.status(500).json({ message: "Failed to complete task" }); - } - }); - - // Customer Orders routes - app.get('/api/customer-orders', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { storeId } = req.query; - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const customerOrders = await storage.getCustomerOrders(groupIds); - res.json(customerOrders); - } catch (error) { - console.error("Error fetching customer orders:", error); - res.status(500).json([]); - } - }); - - app.post('/api/customer-orders', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const data = { - ...req.body, - createdBy: user.id, - }; - - // Check if user has access to the group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups ? user.userGroups.map(ug => ug.groupId) : []; - - // Convert data.groupId to number if it's a string - const requestedGroupId = typeof data.groupId === 'string' ? parseInt(data.groupId) : data.groupId; - - // Allow managers, directeurs, and employees to create orders in their assigned groups - if (!['manager', 'directeur', 'employee'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to create customer orders" }); - } - - if (!userGroupIds.includes(requestedGroupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const customerOrder = await storage.createCustomerOrder(data); - res.json(customerOrder); - } catch (error) { - console.error("Error creating customer order:", error); - res.status(500).json({ message: "Failed to create customer order" }); - } - }); - - app.put('/api/customer-orders/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const customerOrder = await storage.getCustomerOrder(id); - - if (!customerOrder) { - return res.status(404).json({ message: "Customer order not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(customerOrder.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - const updatedOrder = await storage.updateCustomerOrder(id, req.body); - res.json(updatedOrder); - } catch (error) { - console.error("Error updating customer order:", error); - res.status(500).json({ message: "Failed to update customer order" }); - } - }); - - app.delete('/api/customer-orders/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const customerOrder = await storage.getCustomerOrder(id); - - if (!customerOrder) { - return res.status(404).json({ message: "Customer order not found" }); - } - - // Check permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(customerOrder.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteCustomerOrder(id); - res.json({ message: "Customer order deleted successfully" }); - } catch (error) { - console.error("Error deleting customer order:", error); - res.status(500).json({ message: "Failed to delete customer order" }); - } - }); - - // Client call tracking routes - app.get('/api/customer-orders/pending-calls', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - let groupIds: number[] | undefined; - - // Only admin, directeur, and manager can view pending calls - if (user.role === 'employee') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - if (user.role === 'admin') { - // Admin can optionally filter by store - const { storeId } = req.query; - if (storeId) { - groupIds = [parseInt(storeId as string)]; - } - } else { - // For directeur and manager, filter by their assigned groups - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - groupIds = userGroupIds; - } - - const pendingCalls = await storage.getPendingClientCalls(groupIds); - - res.json(pendingCalls); - } catch (error) { - console.error("Error fetching pending client calls:", error); - res.status(500).json({ message: "Failed to fetch pending client calls" }); - } - }); - - app.patch('/api/customer-orders/:id/mark-called', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - - // Only admin, directeur, and manager can mark calls - if (user.role === 'employee') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Check if user has access to this customer order - const customerOrder = await storage.getCustomerOrder(id); - if (!customerOrder) { - return res.status(404).json({ message: "Customer order not found" }); - } - - if (user.role !== 'admin') { - const userGroupIds = (user as any).userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(customerOrder.groupId)) { - return res.status(403).json({ message: "Access denied to this customer order" }); - } - } - - const { comment } = req.body; - const updatedOrder = await storage.markClientCalled(id, user.id, comment); - res.json(updatedOrder); - } catch (error) { - console.error("Error marking client as called:", error); - res.status(500).json({ message: "Failed to mark client as called" }); - } - }); - - // Avoir routes - app.get('/api/avoirs', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { storeId } = req.query; - let groupIds: number[] | undefined; - - if (user.role === 'admin' || user.role === 'directeur') { - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // Managers can only see their group's avoirs - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else if (!storeId) { - groupIds = userGroupIds; - } else { - return res.json([]); - } - } - - const avoirs = await storage.getAvoirs(groupIds); - res.json(avoirs); - } catch (error) { - console.error("Error fetching avoirs:", error); - res.status(500).json({ message: "Failed to fetch avoirs" }); - } - }); - - app.get('/api/avoirs/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const avoir = await storage.getAvoir(id); - - if (!avoir) { - return res.status(404).json({ message: "Avoir not found" }); - } - - // Check if user has access to this avoir's group - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(avoir.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - res.json(avoir); - } catch (error) { - console.error("Error fetching avoir:", error); - res.status(500).json({ message: "Failed to fetch avoir" }); - } - }); - - app.post('/api/avoirs', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Validate data with Zod schema - const validatedData = insertAvoirSchema.parse({ - ...req.body, - createdBy: user.id, - }); - - // Check if user has access to the specified group - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(validatedData.groupId)) { - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const avoir = await storage.createAvoir(validatedData); - console.log('✅ Avoir created:', avoir.id, 'by user:', user.id); - - // Send webhook after avoir creation - try { - const group = await storage.getGroup(avoir.groupId); - if (group && group.webhookUrl) { - const webhookData = { - type: "Avoir", - avoirId: avoir.id, - invoiceReference: avoir.invoiceReference, - amount: avoir.amount, - supplierName: "Unknown", // Will be fetched from relations - groupName: group.name, - comment: avoir.comment || "", - commercialProcessed: avoir.commercialProcessed, - createdBy: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username, - createdAt: avoir.createdAt - }; - - const webhookResponse = await fetch(group.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(webhookData) - }); - - if (webhookResponse.ok) { - await storage.updateAvoirWebhookStatus(avoir.id, true); - console.log('✅ Avoir webhook sent successfully:', avoir.id); - } else { - console.error('❌ Failed to send avoir webhook:', webhookResponse.status); - } - } - } catch (webhookError) { - console.error('❌ Error sending avoir webhook:', webhookError); - } - - res.json(avoir); - } catch (error) { - if (error instanceof z.ZodError) { - console.error('❌ [POST AVOIR] ERREURS VALIDATION ZOD:', JSON.stringify(error.errors, null, 2)); - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - console.error("❌ [POST AVOIR] Erreur générale:", error); - res.status(500).json({ message: "Failed to create avoir" }); - } - }); - - app.put('/api/avoirs/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const existingAvoir = await storage.getAvoir(id); - - if (!existingAvoir) { - return res.status(404).json({ message: "Avoir not found" }); - } - - // Check permissions - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(existingAvoir.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - // ✅ CRITICAL FIX: Validate data with Zod schema (partial) - const validatedData = insertAvoirSchema.partial().parse(req.body); - - // ✅ FIX: Convertir undefined en null pour les champs optionnels (important pour PostgreSQL) - const dataForDb: any = { - ...validatedData, - amount: validatedData.amount === undefined ? null : validatedData.amount, - invoiceReference: validatedData.invoiceReference === undefined ? null : validatedData.invoiceReference, - comment: validatedData.comment === undefined ? null : validatedData.comment, - }; - - const updatedAvoir = await storage.updateAvoir(id, dataForDb); - console.log('✅ Avoir updated:', id, 'by user:', user.id); - - // 🎯 WEBHOOK QUAND STATUT PASSE À "Reçu" - if (validatedData.status === 'Reçu' && existingAvoir.status !== 'Reçu') { - try { - // Utiliser groupe par défaut (1) pour admin si pas de groupe sélectionné - const groupId = updatedAvoir.groupId || (user.role === 'admin' ? 1 : updatedAvoir.groupId); - const group = await storage.getGroup(groupId); - - if (group && group.webhookUrl) { - const webhookData = { - type: "Avoir", - avoirId: updatedAvoir.id, - invoiceReference: updatedAvoir.invoiceReference, - amount: updatedAvoir.amount, - supplierName: "Fournisseur", // Sera enrichi avec relations - groupName: group.name, - comment: updatedAvoir.comment || "", - commercialProcessed: updatedAvoir.commercialProcessed, - status: "Reçu", - createdBy: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username, - processedAt: new Date().toISOString() - }; - - console.log('🌐 Envoi webhook avoir reçu:', { groupId, webhookUrl: group.webhookUrl }); - - const webhookResponse = await fetch(group.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(webhookData) - }); - - if (webhookResponse.ok) { - await storage.updateAvoirWebhookStatus(updatedAvoir.id, true); - console.log('✅ Webhook avoir reçu envoyé:', updatedAvoir.id); - } else { - console.error('❌ Échec envoi webhook avoir:', webhookResponse.status); - } - } - } catch (webhookError) { - console.error('❌ Erreur webhook avoir reçu:', webhookError); - } - } - - res.json(updatedAvoir); - } catch (error) { - if (error instanceof z.ZodError) { - console.error('❌ [PUT AVOIR] ERREURS VALIDATION ZOD:', JSON.stringify(error.errors, null, 2)); - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - console.error("Error updating avoir:", error); - res.status(500).json({ message: "Failed to update avoir" }); - } - }); - - app.delete('/api/avoirs/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const avoir = await storage.getAvoir(id); - - if (!avoir) { - return res.status(404).json({ message: "Avoir not found" }); - } - - // Check permissions (only admin and directeur can delete) - if (user.role !== 'admin' && user.role !== 'directeur') { - return res.status(403).json({ message: "Insufficient permissions to delete avoirs" }); - } - - // For directeur, check group access - if (user.role === 'directeur') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (!userGroupIds.includes(avoir.groupId)) { - return res.status(403).json({ message: "Access denied" }); - } - } - - await storage.deleteAvoir(id); - console.log('✅ Avoir deleted:', id, 'by user:', user.id); - res.json({ message: "Avoir deleted successfully" }); - } catch (error) { - console.error("Error deleting avoir:", error); - res.status(500).json({ message: "Failed to delete avoir" }); - } - }); - - // Route de vérification de facture NocoDB pour les avoirs - app.post('/api/avoirs/:id/verify-invoice', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const avoirId = parseInt(req.params.id); - const avoir = await storage.getAvoir(avoirId); - - if (!avoir) { - return res.status(404).json({ message: "Avoir not found" }); - } - - // Check permissions (using 'deliveries' module for similar logic) - if (!hasPermission(user.role, 'deliveries', 'view')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - // Only admin have access to all avoirs, others must be in the same group - if (user.role !== 'admin') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - if (!userGroupIds.includes(avoir.groupId)) { - console.log('🚫 Access denied - User groups check:', { - userId: user.id, - userRole: user.role, - userGroupIds, - avoirGroupId: avoir.groupId, - avoirSupplier: avoir.supplier?.name - }); - return res.status(403).json({ message: "Access denied to this group" }); - } - } - - const { invoiceReference, forceRefresh } = req.body; - - if (!avoir.supplier || !avoir.group) { - console.log('❌ Avoir manque informations:', { - avoirId, - hasSupplier: !!avoir.supplier, - hasGroup: !!avoir.group - }); - return res.status(400).json({ message: "Avoir missing supplier or group information" }); - } - - // Vérifier que la référence facture est présente - if (!invoiceReference || !invoiceReference.trim()) { - return res.status(400).json({ message: "Référence de facture requise" }); - } - - // Vérifier par référence de facture uniquement - const result = await invoiceVerificationService.verifyInvoice( - invoiceReference, - avoir.groupId, - forceRefresh || false, - false // Les avoirs ne sont pas "réconciliés" comme les livraisons - ); - - res.json(result); - } catch (error) { - console.error("Error verifying avoir invoice:", error); - res.status(500).json({ - message: "Failed to verify avoir invoice", - error: error instanceof Error ? error.message : 'Unknown error' - }); - } - }); - - // Avoir status update routes - app.put('/api/avoirs/:id/webhook-status', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const { webhookSent } = req.body; - - // Only allow admin and directeur to update webhook status - if (user.role !== 'admin' && user.role !== 'directeur') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - await storage.updateAvoirWebhookStatus(id, webhookSent); - res.json({ message: "Webhook status updated successfully" }); - } catch (error) { - console.error("Error updating avoir webhook status:", error); - res.status(500).json({ message: "Failed to update webhook status" }); - } - }); - - app.put('/api/avoirs/:id/nocodb-verification', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const { verified } = req.body; - - // Only allow admin and directeur to update verification status - if (user.role !== 'admin' && user.role !== 'directeur') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - await storage.updateAvoirNocodbVerification(id, verified); - - // Si validé, marquer le cache comme réconcilié (permanent) - if (verified) { - try { - const avoir = await storage.getAvoir(id); - if (avoir?.invoiceReference?.trim()) { - await invoiceVerificationService.updateCacheAsReconciled( - avoir.invoiceReference, - avoir.groupId - ); - console.log('✅ Cache marqué comme réconcilié pour avoir:', id); - } - } catch (cacheError) { - console.error('❌ Erreur marquage cache réconcilié:', cacheError); - // Ne pas faire échouer la validation si le cache échoue - } - } - - console.log('✅ Avoir NocoDB verification updated:', id, 'verified:', verified, 'by user:', user.id); - res.json({ message: "NocoDB verification status updated successfully" }); - } catch (error) { - console.error("Error updating avoir NocoDB verification:", error); - res.status(500).json({ message: "Failed to update verification status" }); - } - }); - - // Route pour marquer explicitement le cache comme réconcilié - app.post('/api/cache/mark-reconciled', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { invoiceReference, groupId } = req.body; - - if (!invoiceReference || !groupId) { - return res.status(400).json({ message: "Invoice reference and group ID required" }); - } - - // Seuls admin et directeur peuvent marquer comme réconcilié - if (user.role !== 'admin' && user.role !== 'directeur') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - await invoiceVerificationService.updateCacheAsReconciled(invoiceReference, groupId); - console.log('✅ Cache marqué comme réconcilié:', { invoiceReference, groupId, user: user.id }); - - res.json({ message: "Cache marked as reconciled successfully" }); - } catch (error) { - console.error("Error marking cache as reconciled:", error); - res.status(500).json({ message: "Failed to mark cache as reconciled" }); - } - }); - - // Statistics routes - app.get('/api/stats/monthly', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { year, month, storeId } = req.query; - const currentYear = year ? parseInt(year as string) : new Date().getFullYear(); - const currentMonth = month ? parseInt(month as string) : new Date().getMonth() + 1; - - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - // Admin can view all stores or filter by selected store - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // Non-admin users: filter by their assigned groups - const userGroupIds = user.userGroups.map(ug => ug.groupId); - - // If a specific store is selected and user has access, filter by it - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const stats = await storage.getMonthlyStats(currentYear, currentMonth, groupIds); - res.json(stats); - } catch (error) { - console.error("Error fetching stats:", error); - res.status(500).json({ message: "Failed to fetch statistics" }); - } - }); - - // Route pour les statistiques annuelles - app.get('/api/stats/yearly', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { year, storeId } = req.query; - const currentYear = year ? parseInt(year as string) : new Date().getFullYear(); - - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - // Admin can view all stores or filter by selected store - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // Non-admin users: filter by their assigned groups - const userGroupIds = user.userGroups.map(ug => ug.groupId); - - // If a specific store is selected and user has access, filter by it - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const stats = await storage.getYearlyStats(currentYear, groupIds); - res.json(stats); - } catch (error) { - console.error("Error fetching yearly stats:", error); - res.status(500).json({ message: "Failed to fetch yearly statistics" }); - } - }); - - // User-Group management routes (admin only) - app.post('/api/users/:userId/groups', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const userId = req.params.userId; - const data = insertUserGroupSchema.parse({ - userId, - groupId: req.body.groupId, - }); - - const userGroup = await storage.assignUserToGroup(data); - res.json(userGroup); - } catch (error) { - console.error("Error assigning user to group:", error); - res.status(500).json({ message: "Failed to assign user to group" }); - } - }); - - app.delete('/api/users/:userId/groups/:groupId', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const userId = req.params.userId; - const groupId = parseInt(req.params.groupId); - - await storage.removeUserFromGroup(userId, groupId); - res.json({ message: "User removed from group successfully" }); - } catch (error) { - console.error("Error removing user from group:", error); - res.status(500).json({ message: "Failed to remove user from group" }); - } - }); - - // Users management routes - app.get('/api/users', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || !['admin', 'directeur', 'manager'].includes(user.role)) { - return res.status(403).json({ message: "Access denied" }); - } - - // Tous les utilisateurs avec leurs magasins en une fois (sans le mot de - // passe), au lieu d'une lecture par utilisateur - const allUsers = await storage.getUsersWithGroups(); - const usersWithData = allUsers.map((baseUser) => ({ - ...baseUser, - userGroups: baseUser.userGroups || [], - userRoles: [] // Keep roles simple for now since we're using hardcoded permissions - })); - - res.json(usersWithData); - } catch (error) { - console.error("❌ Critical error fetching users:", error); - console.error("❌ Error stack:", (error as any).stack); - // En cas d'erreur, retourner un array vide pour éviter React Error #310 - res.status(500).json([]); - } - }); - - app.post('/api/users', isAuthenticated, async (req: any, res) => { - try { - const currentUser = getCurrentUser(req); - if (!currentUser || currentUser.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - // Schema création utilisateur SANS champs obligatoires pour résoudre le problème de production - const createUserSchema = z.object({ - id: z.string().optional(), - username: z.string().min(1, "L'identifiant est obligatoire"), - email: z.union([z.string().email(), z.literal(""), z.null()]).optional(), - firstName: z.string().optional(), - lastName: z.string().optional(), - password: z.string().min(1, "Le mot de passe est obligatoire"), - role: z.enum(['admin', 'directeur', 'manager', 'employee']).optional(), - }); - - const userData = createUserSchema.parse(req.body); - - // Hash password with improved error handling - let hashedPassword = userData.password; - if (userData.password) { - try { - hashedPassword = await hashPasswordSimple(userData.password); - } catch (hashError) { - console.error('❌ Password hashing failed:', hashError); - return res.status(500).json({ message: "Failed to secure password" }); - } - } - - // Generate unique ID - const newUserId = userData.id || `manual_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - const userToCreate = { - id: newUserId, - username: userData.username, - email: userData.email && userData.email.trim() !== '' ? userData.email : undefined, - firstName: userData.firstName || '', - lastName: userData.lastName || '', - password: hashedPassword, - role: userData.role || 'employee', - }; - - const newUser = await storage.createUser(userToCreate); - - res.json(newUser); - } catch (error) { - console.error("❌ Error creating user:", error); - console.error("❌ Error type:", (error as any).constructor?.name); - console.error("❌ Error code:", (error as any).code); - console.error("❌ Error constraint:", (error as any).constraint); - console.error("❌ Error stack:", (error as any).stack); - - if (error instanceof z.ZodError) { - console.log('❌ Validation error:', error.errors); - return res.status(400).json({ message: "Invalid user data", errors: error.errors }); - } - - // Handle specific database constraint errors - if ((error as any).code === '23505') { - if ((error as any).constraint === 'users_username_key') { - return res.status(409).json({ - message: "Un utilisateur avec ce nom d'utilisateur existe déjà. Veuillez choisir un autre nom d'utilisateur." - }); - } - if ((error as any).constraint === 'users_email_key') { - return res.status(409).json({ - message: "Un utilisateur avec cette adresse email existe déjà." - }); - } - } - - // Handle connection errors - if ((error as any).code === 'ECONNREFUSED' || (error as any).code === 'ENOTFOUND') { - return res.status(503).json({ message: "Database connection error" }); - } - - res.status(500).json({ message: "Failed to create user" }); - } - }); - - app.put('/api/users/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - // Schema utilisateur SANS champs obligatoires pour résoudre le problème de production - const updateUserSchema = z.object({ - username: z.string().optional(), - role: z.enum(['admin', 'directeur', 'manager', 'employee']).optional(), - firstName: z.union([z.string(), z.literal(""), z.null()]).optional(), - lastName: z.union([z.string(), z.literal(""), z.null()]).optional(), - email: z.union([z.string().email(), z.literal(""), z.null()]).optional(), - password: z.string().optional(), - }); - - const userData = updateUserSchema.parse(req.body); - - // Clean up the data - handle empty emails properly - const cleanUserData: any = { ...userData }; - - // Handle email field - convert empty string to undefined - if (cleanUserData.email !== undefined) { - cleanUserData.email = cleanUserData.email && cleanUserData.email.trim() !== '' ? cleanUserData.email : undefined; - } - - // Hash password if provided - if (cleanUserData.password) { - try { - cleanUserData.password = await hashPasswordSimple(cleanUserData.password); - cleanUserData.passwordChanged = true; - } catch (hashError) { - console.error('❌ Password hashing failed:', hashError); - return res.status(500).json({ message: "Failed to secure password" }); - } - } - - const updatedUser = await storage.updateUser(req.params.id, cleanUserData); - res.json(updatedUser); - } catch (error: any) { - console.error("❌ Error updating user:", error); - console.error("❌ Error type:", (error as any).constructor?.name); - console.error("❌ Error code:", (error as any).code); - console.error("❌ Error constraint:", (error as any).constraint); - console.error("❌ Error stack:", (error as any).stack); - - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Invalid user data", errors: error.errors }); - } - - // Handle specific database constraint errors - if ((error as any).code === '23505') { - if ((error as any).constraint === 'users_username_key') { - return res.status(409).json({ - message: "Un utilisateur avec ce nom d'utilisateur existe déjà. Veuillez choisir un autre nom d'utilisateur." - }); - } - if ((error as any).constraint === 'users_email_key') { - return res.status(409).json({ - message: "Un utilisateur avec cette adresse email existe déjà. Veuillez utiliser une autre adresse email ou laisser le champ vide." - }); - } - } - - res.status(500).json({ message: "Failed to update user" }); - } - }); - - app.post('/api/users/:id/groups', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const { groupId } = req.body; - await storage.assignUserToGroup({ - userId: req.params.id, - groupId: parseInt(groupId), - }); - - res.json({ message: "User assigned to group successfully" }); - } catch (error) { - console.error("Error assigning user to group:", error); - res.status(500).json({ message: "Failed to assign user to group" }); - } - }); - - app.delete('/api/users/:id/groups/:groupId', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - await storage.removeUserFromGroup(req.params.id, parseInt(req.params.groupId)); - res.json({ message: "User removed from group successfully" }); - } catch (error) { - console.error("Error removing user from group:", error); - res.status(500).json({ message: "Failed to remove user from group" }); - } - }); - - // Delete user route - app.delete('/api/users/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const userToDelete = req.params.id; - - // Prevent admin from deleting themselves - if (userToDelete === user.id) { - return res.status(400).json({ message: "Vous ne pouvez pas supprimer votre propre compte" }); - } - - // Remove user from all groups first - await storage.removeUserFromAllGroups(userToDelete); - - // Delete the user - await storage.deleteUser(userToDelete); - res.json({ message: "Utilisateur supprimé avec succès" }); - } catch (error) { - console.error("Error deleting user:", error); - res.status(500).json({ message: "Failed to delete user" }); - } - }); - - // Publicity routes (renamed to ad-campaigns to avoid adblocker issues) - app.get('/api/ad-campaigns/debug', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { year, storeId } = req.query; - const filterYear = year ? parseInt(year as string) : undefined; - - // DEBUG: Log pour identifier le problème avec 2025 - console.log(`📋 API AD-CAMPAIGNS DEBUG REQUEST:`, { year, filterYear, storeId, userRole: user.role }); - - let groupIds: number[] | undefined; - - if (user.role === 'admin') { - // Admin can view all publicities or filter by selected store - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // Non-admin users: filter by their assigned groups - groupIds = user.userGroups.map(ug => ug.groupId); - } - - console.log(`📋 CALLING storage.getPublicities:`, { filterYear, groupIds }); - const publicities = await storage.getPublicities(filterYear, groupIds); - console.log(`📋 PUBLICITIES RETURNED:`, { count: publicities.length }); - - res.json(publicities); - } catch (error) { - console.error("❌ ERROR fetching publicities:", error); - res.status(500).json({ message: "Failed to fetch publicities", error: (error as Error).message }); - } - }); - - app.get('/api/ad-campaigns/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const id = parseInt(req.params.id); - const publicity = await storage.getPublicity(id); - - if (!publicity) { - return res.status(404).json({ message: "Publicity not found" }); - } - - // Check access permissions - if (user.role !== 'admin') { - const userGroupIds = user.userGroups.map(ug => ug.groupId); - const hasAccess = publicity.participations.some((p: any) => userGroupIds.includes(p.groupId)); - - if (!hasAccess) { - return res.status(403).json({ message: "Access denied" }); - } - } - - res.json(publicity); - } catch (error) { - console.error("Error fetching publicity:", error); - res.status(500).json({ message: "Failed to fetch publicity" }); - } - }); - - // Get all publicities (with optional year and store filtering) - app.get('/api/ad-campaigns', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { year, storeId } = req.query; - let groupIds: number[] | undefined; - - // Determine which groups to filter by - if (user.role === 'admin') { - // Admins can filter by specific store or see all - groupIds = storeId ? [parseInt(storeId as string)] : undefined; - } else { - // Non-admins see only their assigned groups - const userGroupIds = user.userGroups.map(ug => ug.groupId); - if (storeId && userGroupIds.includes(parseInt(storeId as string))) { - groupIds = [parseInt(storeId as string)]; - } else { - groupIds = userGroupIds; - } - } - - const yearNum = year ? parseInt(year as string) : undefined; - const publicities = await storage.getPublicities(yearNum, groupIds); - - res.json(publicities); - } catch (error) { - console.error("Error fetching publicities:", error); - res.status(500).json([]); - } - }); - - app.post('/api/ad-campaigns', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions using the shared permission system - if (!hasPermission(user.role, 'publicity', 'create')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const data = insertPublicitySchema.parse({ - ...req.body, - createdBy: req.user.claims ? req.user.claims.sub : req.user.id - }); - - const { participatingGroups, ...publicityData } = req.body; - - // Create publicity - const newPublicity = await storage.createPublicity(data); - - // Set participations - if (participatingGroups && participatingGroups.length > 0) { - await storage.setPublicityParticipations(newPublicity.id, participatingGroups); - } - - // Get the complete publicity with relations - const completePublicity = await storage.getPublicity(newPublicity.id); - res.json(completePublicity); - } catch (error) { - console.error("Error creating publicity:", error); - res.status(500).json({ message: "Failed to create publicity" }); - } - }); - - app.put('/api/ad-campaigns/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions using the shared permission system - if (!hasPermission(user.role, 'publicity', 'edit')) { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(req.params.id); - const { participatingGroups, ...publicityData } = req.body; - - // Update publicity - const updatedPublicity = await storage.updatePublicity(id, publicityData); - - // Update participations - if (participatingGroups !== undefined) { - await storage.setPublicityParticipations(id, participatingGroups); - } - - // Get the complete publicity with relations - const completePublicity = await storage.getPublicity(id); - res.json(completePublicity); - } catch (error) { - console.error("Error updating publicity:", error); - res.status(500).json({ message: "Failed to update publicity" }); - } - }); - - app.delete('/api/ad-campaigns/:id', isAuthenticated, async (req: any, res) => { - const publicityId = req.params.id; - console.log(`🗑️ [API] DELETE request received for publicity ID: ${publicityId} (ad-campaigns)`); - console.log(`🗑️ [API] User info:`, { - hasUser: !!req.user, - userId: req.user?.id || req.user?.claims?.sub, - method: req.method, - url: req.url, - headers: { 'content-type': req.headers['content-type'] } - }); - - try { - const user = getCurrentUser(req); - if (!user) { - console.log(`❌ [API] User not found for publicity deletion: ${publicityId}`); - return res.status(404).json({ message: "User not found" }); - } - - console.log(`🗑️ [API] User found:`, { id: user.id, role: user.role, name: user.name }); - - // Check permissions (admin only for deletion) - if (user.role !== 'admin') { - console.log(`❌ [API] Insufficient permissions for publicity deletion: ${publicityId}, user role: ${user.role}`); - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(publicityId); - console.log(`🗑️ [API] Admin ${user.name} (${user.id}) attempting to delete publicity ${id}`); - - await storage.deletePublicity(id); - - console.log(`✅ [API] Successfully deleted publicity ${id} by admin ${user.name}`); - res.json({ message: "Publicity deleted successfully" }); - } catch (error) { - console.error(`❌ [API] Error deleting publicity ${publicityId}:`, error); - res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message }); - } - }); - - // Alternative DELETE route using POST (for production environments that block DELETE) - app.post('/api/ad-campaigns/:id/delete', isAuthenticated, async (req: any, res) => { - const publicityId = req.params.id; - console.log(`🗑️ [API-POST] DELETE via POST request received for publicity ID: ${publicityId} (ad-campaigns)`); - console.log(`🗑️ [API-POST] User info:`, { - hasUser: !!req.user, - userId: req.user?.id || req.user?.claims?.sub, - method: req.method, - url: req.url - }); - - try { - const user = getCurrentUser(req); - if (!user) { - console.log(`❌ [API-POST] User not found for publicity deletion: ${publicityId}`); - return res.status(404).json({ message: "User not found" }); - } - - console.log(`🗑️ [API-POST] User found:`, { id: user.id, role: user.role, name: user.name }); - - // Check permissions (admin only for deletion) - if (user.role !== 'admin') { - console.log(`❌ [API-POST] Insufficient permissions for publicity deletion: ${publicityId}, user role: ${user.role}`); - return res.status(403).json({ message: "Insufficient permissions" }); - } - - const id = parseInt(publicityId); - console.log(`🗑️ [API-POST] Admin ${user.name} (${user.id}) attempting to delete publicity ${id} via POST`); - - await storage.deletePublicity(id); - - console.log(`✅ [API-POST] Successfully deleted publicity ${id} by admin ${user.name} via POST`); - res.json({ message: "Publicity deleted successfully" }); - } catch (error) { - console.error(`❌ [API-POST] Error deleting publicity ${publicityId} via POST:`, error); - res.status(500).json({ message: "Failed to delete publicity", error: (error as Error).message }); - } - }); - - // Schema logging route for production debugging - app.get('/api/debug/log-schema', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent utiliser cette route de debug.' }); - } - - const isProduction = process.env.NODE_ENV === 'production'; - - if (!isProduction) { - return res.status(400).json({ error: 'Cette route fonctionne uniquement en production sur votre serveur privé' }); - } - - console.log('\n🔍 ===== DÉBUT SCAN SCHÉMA BASE DE DONNÉES ====='); - console.log('⏰ Timestamp:', new Date().toISOString()); - - // Vérifier que pool est disponible - if (!pool) { - throw new Error('Pool de base de données non disponible en production'); - } - - // Récupérer les tables - const tablesQuery = ` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - ORDER BY table_name - `; - - const tablesResult = await pool.query(tablesQuery); - - console.log(`\n📊 TOTAL DES TABLES TROUVÉES: ${tablesResult.rows.length}`); - console.log('=========================================='); - - // Pour chaque table, récupérer les colonnes - for (const tableRow of tablesResult.rows) { - const tableName = tableRow.table_name; - - console.log(`\n🔸 TABLE: ${tableName.toUpperCase()}`); - console.log(`${'='.repeat(tableName.length + 8)}`); - - const columnsQuery = ` - SELECT - column_name, - data_type, - character_maximum_length, - is_nullable, - column_default, - ordinal_position - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position - `; - - const columnsResult = await pool.query(columnsQuery, [tableName]); - - if (columnsResult.rows.length > 0) { - columnsResult.rows.forEach((col: any, index: number) => { - const nullable = col.is_nullable === 'YES' ? 'NULL' : 'NOT NULL'; - const maxLength = col.character_maximum_length ? `(${col.character_maximum_length})` : ''; - const defaultVal = col.column_default ? ` DEFAULT ${col.column_default}` : ''; - - console.log(` ${(index + 1).toString().padStart(2, '0')}. ${col.column_name.padEnd(25)} : ${col.data_type}${maxLength} ${nullable}${defaultVal}`); - }); - - // Compter les enregistrements dans la table - try { - const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`; - const countResult = await pool.query(countQuery); - console.log(` 📈 Nombre d'enregistrements: ${countResult.rows[0].total}`); - } catch (countError) { - console.log(` ⚠️ Impossible de compter les enregistrements: ${(countError as Error).message}`); - } - } - } - - // Récupérer les contraintes de clés étrangères - console.log('\n🔗 CONTRAINTES DE CLÉS ÉTRANGÈRES:'); - console.log('==================================='); - - const fkQuery = ` - SELECT - tc.table_name, - kcu.column_name, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name, - tc.constraint_name - FROM information_schema.table_constraints AS tc - JOIN information_schema.key_column_usage AS kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage AS ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' - ORDER BY tc.table_name, kcu.column_name - `; - - const fkResult = await pool.query(fkQuery); - - if (fkResult.rows.length > 0) { - fkResult.rows.forEach((fk: any) => { - console.log(` ${fk.table_name}.${fk.column_name} → ${fk.foreign_table_name}.${fk.foreign_column_name}`); - }); - } else { - console.log(' Aucune contrainte de clé étrangère trouvée'); - } - - console.log('\n🏁 ===== FIN SCAN SCHÉMA BASE DE DONNÉES =====\n'); - - res.json({ - success: true, - message: 'Schéma loggé avec succès dans les logs du serveur', - totalTables: tablesResult.rows.length, - totalForeignKeys: fkResult.rows.length, - timestamp: new Date().toISOString(), - downloadUrl: '/api/debug/download-schema' - }); - - } catch (error) { - console.error('❌ ERREUR lors du scan du schéma:', error); - res.status(500).json({ error: (error as Error).message }); - } - }); - - // Download database schema report - app.get('/api/debug/download-schema', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent télécharger le rapport de schéma.' }); - } - - const isProduction = process.env.NODE_ENV === 'production'; - - if (!isProduction) { - return res.status(400).json({ error: 'Cette route fonctionne uniquement en production sur votre serveur privé' }); - } - - // Vérifier que pool est disponible - if (!pool) { - throw new Error('Pool de base de données non disponible en production'); - } - - const timestamp = new Date().toISOString(); - let reportContent = `LOGIFLOW - RAPPORT SCHÉMA BASE DE DONNÉES PRODUCTION -=============================================== - -GÉNÉRÉ LE : ${timestamp} -SERVEUR : Production privé LogiFlow -ENVIRONNEMENT : PostgreSQL Docker -UTILISATEUR : ${user.username} - -=============================================== -RÉSUMÉ DU SCAN -=============================================== - -`; - - // Récupérer les tables - const tablesQuery = ` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - ORDER BY table_name - `; - - const tablesResult = await pool.query(tablesQuery); - - reportContent += `TOTAL DES TABLES TROUVÉES: ${tablesResult.rows.length}\n`; - reportContent += `==========================================\n\n`; - - // Pour chaque table, récupérer les colonnes - for (const tableRow of tablesResult.rows) { - const tableName = tableRow.table_name; - - reportContent += `TABLE: ${tableName.toUpperCase()}\n`; - reportContent += `${'='.repeat(tableName.length + 6)}\n\n`; - - const columnsQuery = ` - SELECT - column_name, - data_type, - character_maximum_length, - is_nullable, - column_default, - ordinal_position - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = $1 - ORDER BY ordinal_position - `; - - const columnsResult = await pool.query(columnsQuery, [tableName]); - - if (columnsResult.rows.length > 0) { - columnsResult.rows.forEach((col: any, index: number) => { - const nullable = col.is_nullable === 'YES' ? 'NULL' : 'NOT NULL'; - const maxLength = col.character_maximum_length ? `(${col.character_maximum_length})` : ''; - const defaultVal = col.column_default ? ` DEFAULT ${col.column_default}` : ''; - - reportContent += ` ${(index + 1).toString().padStart(2, '0')}. ${col.column_name.padEnd(25)} : ${col.data_type}${maxLength} ${nullable}${defaultVal}\n`; - }); - - // Compter les enregistrements dans la table - try { - const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"`; - const countResult = await pool.query(countQuery); - reportContent += ` 📈 Nombre d'enregistrements: ${countResult.rows[0].total}\n\n`; - } catch (countError) { - reportContent += ` ⚠️ Impossible de compter les enregistrements: ${(countError as Error).message}\n\n`; - } - } - } - - // Récupérer les contraintes de clés étrangères - reportContent += `CONTRAINTES DE CLÉS ÉTRANGÈRES:\n`; - reportContent += `===================================\n\n`; - - const fkQuery = ` - SELECT - tc.table_name, - kcu.column_name, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name, - tc.constraint_name - FROM information_schema.table_constraints AS tc - JOIN information_schema.key_column_usage AS kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage AS ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = 'public' - ORDER BY tc.table_name, kcu.column_name - `; - - const fkResult = await pool.query(fkQuery); - - if (fkResult.rows.length > 0) { - fkResult.rows.forEach((fk: any) => { - reportContent += ` ${fk.table_name}.${fk.column_name} → ${fk.foreign_table_name}.${fk.foreign_column_name}\n`; - }); - } else { - reportContent += ' Aucune contrainte de clé étrangère trouvée\n'; - } - - reportContent += `\n===============================================\n`; - reportContent += `FIN DU RAPPORT - ${timestamp}\n`; - reportContent += `===============================================`; - - // Définir les headers pour le téléchargement - const filename = `logiflow-schema-${new Date().toISOString().split('T')[0]}.txt`; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(reportContent); - - } catch (error) { - console.error('❌ ERREUR lors de la génération du rapport de schéma:', error); - res.status(500).json({ error: (error as Error).message }); - } - }); - - // Invoice verification routes - app.post('/api/verify-invoice', isAuthenticated, async (req: any, res) => { - try { - const { groupId, invoiceReference } = req.body; - - if (!groupId || !invoiceReference) { - return res.status(400).json({ message: "groupId and invoiceReference are required" }); - } - - console.log('🔍 Vérification facture demandée:', { groupId, invoiceReference }); - - const { InvoiceVerificationService } = await import('./invoiceVerification.js'); - const verificationService = new InvoiceVerificationService(); - const result = await verificationService.verifyInvoice(invoiceReference, groupId); - - console.log('✅ Résultat vérification:', result); - res.json(result); - } catch (error) { - console.error("❌ Error verifying invoice:", error); - res.status(500).json({ message: "Failed to verify invoice" }); - } - }); - - app.post('/api/verify-invoices', isAuthenticated, async (req: any, res) => { - try { - const { invoiceReferences } = req.body; - - if (!Array.isArray(invoiceReferences)) { - return res.status(400).json({ message: "invoiceReferences must be an array" }); - } - - // Add supplier name to invoice references for verification - const enrichedReferences = invoiceReferences.map((ref: any) => ({ - ...ref, - supplierName: ref.supplierName // Include supplier name for matching - })); - - const { verifyMultipleInvoiceReferences } = await import('./nocodbService.js'); - const results = await verifyMultipleInvoiceReferences(enrichedReferences); - - res.json(results); - } catch (error) { - console.error("Error verifying invoices:", error); - res.status(500).json({ message: "Failed to verify invoices" }); - } - }); - - // NocoDB Configuration routes - app.get('/api/nocodb-config', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent gérer les configurations NocoDB.' }); - } - - const configs = await storage.getNocodbConfigs(); - res.json(configs); - } catch (error) { - console.error('Error fetching NocoDB configs:', error); - res.status(500).json({ error: 'Failed to fetch configurations' }); - } - }); - - app.post('/api/nocodb-config', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent gérer les configurations NocoDB.' }); - } - - const configData = insertNocodbConfigSchema.parse({ - ...req.body, - createdBy: user.id - }); - const config = await storage.createNocodbConfig(configData); - res.status(201).json(config); - } catch (error) { - console.error('Error creating NocoDB config:', error); - res.status(500).json({ error: 'Failed to create configuration' }); - } - }); - - app.put('/api/nocodb-config/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent gérer les configurations NocoDB.' }); - } - - const id = parseInt(req.params.id); - const configData = insertNocodbConfigSchema.partial().parse(req.body); - const config = await storage.updateNocodbConfig(id, configData); - res.json(config); - } catch (error) { - console.error('Error updating NocoDB config:', error); - res.status(500).json({ error: 'Failed to update configuration' }); - } - }); - - app.delete('/api/nocodb-config/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: 'Accès refusé. Seuls les administrateurs peuvent gérer les configurations NocoDB.' }); - } - - const id = parseInt(req.params.id); - await storage.deleteNocodbConfig(id); - res.status(204).send(); - } catch (error) { - console.error('Error deleting NocoDB config:', error); - res.status(500).json({ error: 'Failed to delete configuration' }); - } - }); - - app.get('/api/nocodb-config/active', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(403).json({ message: 'Accès refusé.' }); - } - - const activeConfig = await storage.getActiveNocodbConfig(); - res.json(activeConfig || null); - } catch (error) { - console.error('Error fetching active NocoDB config:', error); - res.status(500).json({ error: 'Failed to fetch active configuration' }); - } - }); - - // Backup management routes (Admin only) - app.get('/api/backups', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const backups = await backupService.getBackupList(); - res.json(backups); - } catch (error) { - console.error("Error fetching backups:", error); - res.status(500).json({ message: "Failed to fetch backups" }); - } - }); - - app.post('/api/backups', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const backup = await backupService.createBackup('manual', user.id); - res.json(backup); - } catch (error) { - console.error("Error creating backup:", error); - res.status(500).json({ message: "Failed to create backup" }); - } - }); - - app.get('/api/backups/:filename/download', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const { filename } = req.params; - const filepath = await backupService.downloadBackup(filename); - - res.download(filepath, filename, (err: any) => { - if (err) { - console.error("Error downloading backup:", err); - res.status(404).json({ message: "Backup file not found" }); - } - }); - } catch (error) { - console.error("Error downloading backup:", error); - res.status(500).json({ message: "Failed to download backup" }); - } - }); - - app.delete('/api/backups/:filename', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const { filename } = req.params; - await backupService.deleteBackup(filename); - res.json({ message: "Backup deleted successfully" }); - } catch (error) { - console.error("Error deleting backup:", error); - res.status(500).json({ message: "Failed to delete backup" }); - } - }); - - // SAV (Service Après-Vente) routes - app.get('/api/sav/tickets', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Get user's group IDs for filtering - let groupIds: number[] = []; - if (user.role === 'admin') { - // Admin can see all tickets if no specific store is selected - // But if a store is selected in the UI, filter by that store - const selectedGroupId = req.query.groupId ? parseInt(req.query.groupId) : null; - if (selectedGroupId) { - groupIds = [selectedGroupId]; - } else { - groupIds = []; // See all tickets - } - } else { - // Other roles see only their assigned groups - const userGroups = (user as any).userGroups; - groupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - } - - // Parse query filters - const filters = { - groupIds: groupIds.length > 0 ? groupIds : undefined, - status: req.query.status as string, - supplierId: req.query.supplierId ? parseInt(req.query.supplierId) : undefined, - priority: req.query.priority as string, - startDate: req.query.startDate as string, - endDate: req.query.endDate as string, - }; - - const tickets = await storage.getSavTickets(filters); - res.json(tickets); - } catch (error) { - console.error("Error fetching SAV tickets:", error); - res.status(500).json({ message: "Failed to fetch tickets" }); - } - }); - - app.get('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const ticketId = parseInt(req.params.id); - const ticket = await storage.getSavTicket(ticketId); - - if (!ticket) { - return res.status(404).json({ message: "Ticket not found" }); - } - - // Check if user can access this ticket - if (user.role !== 'admin') { - const userGroups = (user as any).userGroups; - const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - if (!userGroupIds.includes(ticket.groupId)) { - return res.status(403).json({ message: "Access denied to this ticket" }); - } - } - - res.json(ticket); - } catch (error) { - console.error("Error fetching SAV ticket:", error); - res.status(500).json({ message: "Failed to fetch ticket" }); - } - }); - - app.post('/api/sav/tickets', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions: admin, manager, directeur can create (employee can only view) - if (!['admin', 'manager', 'directeur'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to create tickets" }); - } - - // Get user's groups for automatic assignment - let availableGroupIds: number[] = []; - if (user.role === 'admin') { - const allGroups = await storage.getGroups(); - availableGroupIds = allGroups.map(g => g.id); - } else { - const userGroups = (user as any).userGroups; - availableGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - } - - if (availableGroupIds.length === 0) { - return res.status(400).json({ message: "No groups available for ticket creation" }); - } - - // Generate unique ticket number - const now = new Date(); - const ticketNumber = `SAV-${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}-${Date.now().toString().slice(-6)}`; - - // Parse and validate request body - const assignedGroupId = req.body.groupId || availableGroupIds[0]; - - const ticketData = insertSavTicketSchema.parse({ - ...req.body, - groupId: assignedGroupId, // Use first available group if not specified - createdBy: user.id, - }); - - // Add the generated ticket number - const ticketDataWithNumber = { - ...ticketData, - ticketNumber, - }; - - // Validate that the specified group is accessible to the user - if (!availableGroupIds.includes(ticketData.groupId)) { - return res.status(403).json({ message: "Access denied to the specified group" }); - } - - const ticket = await storage.createSavTicket(ticketDataWithNumber); - res.status(201).json(ticket); - } catch (error) { - console.error("Error creating SAV ticket:", error); - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to create ticket" }); - } - }); - - app.patch('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions: admin, manager, directeur can modify - if (!['admin', 'manager', 'directeur'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to modify tickets" }); - } - - const ticketId = parseInt(req.params.id); - const existingTicket = await storage.getSavTicket(ticketId); - - if (!existingTicket) { - return res.status(404).json({ message: "Ticket not found" }); - } - - // Check if user can access this ticket - if (user.role !== 'admin') { - const userGroups = (user as any).userGroups; - const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - if (!userGroupIds.includes(existingTicket.groupId)) { - return res.status(403).json({ message: "Access denied to modify this ticket" }); - } - } - - // Parse and validate partial update data - const updateData = insertSavTicketSchema.partial().parse(req.body); - const updatedTicket = await storage.updateSavTicket(ticketId, updateData); - - // Add history entry for status change if status was modified - if (req.body.status && req.body.status !== existingTicket.status) { - await storage.addSavTicketHistory({ - ticketId: ticketId, - action: 'status_change', - description: `Statut changé de "${existingTicket.status}" vers "${req.body.status}"`, - createdBy: user.id, - }); - } - - res.json(updatedTicket); - } catch (error) { - console.error("Error updating SAV ticket:", error); - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to update ticket" }); - } - }); - - app.delete('/api/sav/tickets/:id', isAuthenticated, async (req: any, res) => { - try { - const userId = req.user.claims ? req.user.claims.sub : req.user.id; - const user = await storage.getUser(userId); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions: only admin and directeur can delete - if (!['admin', 'directeur'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to delete tickets" }); - } - - const ticketId = parseInt(req.params.id); - const existingTicket = await storage.getSavTicket(ticketId); - - if (!existingTicket) { - return res.status(404).json({ message: "Ticket not found" }); - } - - await storage.deleteSavTicket(ticketId); - res.status(204).send(); - } catch (error) { - console.error("Error deleting SAV ticket:", error); - res.status(500).json({ message: "Failed to delete ticket" }); - } - }); - - app.post('/api/sav/tickets/:id/history', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Check permissions: admin, manager, directeur can add comments - if (!['admin', 'manager', 'directeur'].includes(user.role)) { - return res.status(403).json({ message: "Insufficient permissions to add comments" }); - } - - const ticketId = parseInt(req.params.id); - const ticket = await storage.getSavTicket(ticketId); - - if (!ticket) { - return res.status(404).json({ message: "Ticket not found" }); - } - - // Check if user can access this ticket - if (user.role !== 'admin') { - const userGroups = (user as any).userGroups; - const userGroupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - if (!userGroupIds.includes(ticket.groupId)) { - return res.status(403).json({ message: "Access denied to add comments to this ticket" }); - } - } - - // Parse and validate history entry - // const historyData = insertSavTicketHistorySchema.parse({ - // ticketId: ticketId, - // action: 'comment', - // description: req.body.description, - // createdBy: user.id, - // }); - - const history = await storage.addSavTicketHistory({ - ticketId: ticketId, - action: 'comment', - description: req.body.description, - createdBy: user.id, - }); - res.status(201).json(history); - } catch (error) { - console.error("Error adding SAV ticket history:", error); - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to add comment" }); - } - }); - - app.get('/api/sav/stats', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Get user's group IDs for filtering - let groupIds: number[] = []; - if (user.role === 'admin') { - // Admin can see all stats if no specific store is selected - // But if a store is selected in the UI, filter by that store - const selectedGroupId = req.query.groupId ? parseInt(req.query.groupId) : null; - if (selectedGroupId) { - groupIds = [selectedGroupId]; - } else { - groupIds = []; // See all stats - } - } else { - // Other roles see only their assigned groups - const userGroups = (user as any).userGroups; - groupIds = userGroups ? userGroups.map((ug: any) => ug.groupId) : []; - } - - const stats = await storage.getSavTicketStats(groupIds.length > 0 ? groupIds : undefined); - res.json(stats); - } catch (error) { - console.error("Error fetching SAV stats:", error); - res.status(500).json({ message: "Failed to fetch stats" }); - } - }); - - // Emergency migration route for SAV priority column - // SÉCURITÉ : réservée aux administrateurs authentifiés (était accessible sans login) - app.post('/api/admin/emergency-migration', isAuthenticated, async (req: any, res) => { - try { - const user = await storage.getUser(req.user.claims ? req.user.claims.sub : req.user.id); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Insufficient permissions" }); - } - - console.log('🚨 EMERGENCY: Forcing SAV migration execution...'); - - // Import migration function - const { runProductionMigrations } = await import('./migrations.production.js'); - - // Force run the migration - await runProductionMigrations(); - - console.log('✅ EMERGENCY: Migration executed successfully'); - res.json({ - success: true, - message: 'Emergency migration executed successfully', - timestamp: new Date().toISOString() - }); - - } catch (error) { - console.error('❌ EMERGENCY: Migration failed:', error); - res.status(500).json({ - success: false, - error: 'Emergency migration failed', - details: (error as Error).message, - timestamp: new Date().toISOString() - }); - } - }); - - // Weather routes - app.get('/api/weather/settings', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const settings = await storage.getWeatherSettings(); - res.json(settings); - } catch (error) { - console.error("Error fetching weather settings:", error); - res.status(500).json({ message: "Failed to fetch weather settings" }); - } - }); - - app.post('/api/weather/settings', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const data = insertWeatherSettingsSchema.parse(req.body); - const settings = await storage.createWeatherSettings(data); - res.json(settings); - } catch (error) { - console.error("Error creating weather settings:", error); - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to create weather settings" }); - } - }); - - app.put('/api/weather/settings/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const id = parseInt(req.params.id); - const data = insertWeatherSettingsSchema.partial().parse(req.body); - const settings = await storage.updateWeatherSettings(id, data); - res.json(settings); - } catch (error) { - console.error("Error updating weather settings:", error); - if (error instanceof z.ZodError) { - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to update weather settings" }); - } - }); - - app.post('/api/weather/test-connection', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user || user.role !== 'admin') { - return res.status(403).json({ message: "Access denied" }); - } - - const { apiKey, location } = req.body; - - if (!apiKey || !location) { - return res.status(400).json({ message: "API key and location are required" }); - } - - const result = await weatherService.testApiConnection(apiKey, location); - res.json(result); - } catch (error) { - console.error("Error testing weather API connection:", error); - res.status(500).json({ message: "Failed to test API connection" }); - } - }); - - app.get('/api/weather/current', isAuthenticated, async (req: any, res) => { - try { - const settings = await storage.getWeatherSettings(); - - if (!settings || !settings.isActive) { - return res.status(404).json({ message: "Weather service not configured or disabled" }); - } - - const today = new Date().toISOString().split('T')[0]; - const previousYearDate = weatherService.getPreviousYearDate(); - - // Lectures du cache indépendantes : en parallèle - let [currentYearData, previousYearData] = await Promise.all([ - storage.getWeatherData(today, true), - storage.getWeatherData(previousYearDate, false), - ]); - - // Les appels à l'API météo restent séquentiels : lancés en parallèle, - // Visual Crossing peut refuser la seconde requête d'une même clé (limite - // de requêtes simultanées) et la comparaison avec l'an dernier disparaît. - - // Fetch current year data if not in cache - if (!currentYearData) { - console.log("🌤️ [FETCH] Fetching current weather data from API"); - const apiData = await weatherService.fetchCurrentWeather(settings); - if (apiData) { - const weatherData = weatherService.convertApiDataToWeatherData(apiData, settings.location, true); - if (weatherData) { - try { - currentYearData = await storage.createWeatherData(weatherData); - console.log("✅ [CACHE] Current year data saved to cache"); - } catch (error: any) { - console.warn("⚠️ [CACHE] Could not save current year data (may already exist):", error.message); - // Récupérer les données existantes au lieu de créer ; à défaut, - // afficher quand même la réponse de l'API - currentYearData = (await storage.getWeatherData(today, true)) ?? (weatherData as any); - } - } - } - } - - // Fetch previous year data if not in cache - if (!previousYearData) { - console.log("🌤️ [FETCH] Fetching previous year weather data from API"); - const apiData = await weatherService.fetchPreviousYearWeather(settings, previousYearDate); - if (apiData) { - const weatherData = weatherService.convertApiDataToWeatherData(apiData, settings.location, false); - if (weatherData) { - try { - previousYearData = await storage.createWeatherData(weatherData); - console.log("✅ [CACHE] Previous year data saved to cache"); - } catch (error: any) { - console.warn("⚠️ [CACHE] Could not save previous year data (may already exist):", error.message); - // Récupérer les données existantes au lieu de créer ; à défaut, - // afficher quand même la réponse de l'API plutôt que de la perdre - previousYearData = (await storage.getWeatherData(previousYearDate, false)) ?? (weatherData as any); - } - } else { - console.warn(`⚠️ [HISTORY] Empty historical response for ${previousYearDate}`); - } - } else { - console.warn("⚠️ [HISTORY] Could not fetch historical data - continuing with current year only"); - } - } - - // Repli : si l'API historique a échoué (quota épuisé, panne, plan sans - // accès à l'historique), reprendre la ligne en cache la plus proche de - // la date cible plutôt que de faire disparaître la comparaison N-1 - if (!previousYearData) { - previousYearData = await storage.getNearestWeatherData(previousYearDate, false, 7); - if (previousYearData) { - console.log(`🌤️ [FALLBACK] Using nearest cached previous-year data (${previousYearData.date}) for target ${previousYearDate}`); - } - } - - // Second repli : la météo relevée l'an dernier, au jour le jour, quand ces - // dates étaient « aujourd'hui » (lignes is_current_year = true datées de - // l'année précédente). Elle reste disponible même si l'API refuse - // durablement l'historique (plan, quota). - if (!previousYearData) { - previousYearData = await storage.getNearestWeatherData(previousYearDate, true, 7); - if (previousYearData) { - console.log(`🌤️ [FALLBACK] Using weather recorded last year (${previousYearData.date}) for target ${previousYearDate}`); - } else { - console.warn(`⚠️ [FALLBACK] No previous-year weather available around ${previousYearDate}`); - } - } - - // Toujours retourner une réponse même si seule l'année actuelle est disponible - const response = { - currentYear: currentYearData ? { - ...currentYearData, - maxTemperature: parseFloat(currentYearData.tempMax), - minTemperature: parseFloat(currentYearData.tempMin), - condition: currentYearData.conditions, - icon: weatherService.getWeatherIcon(currentYearData.icon) - } : null, - previousYear: previousYearData ? { - ...previousYearData, - maxTemperature: parseFloat(previousYearData.tempMax), - minTemperature: parseFloat(previousYearData.tempMin), - condition: previousYearData.conditions, - icon: weatherService.getWeatherIcon(previousYearData.icon) - } : null, - location: settings.location - }; - - res.json(response); - } catch (error) { - console.error("Error fetching weather data:", error); - res.status(500).json({ message: "Failed to fetch weather data" }); - } - }); - - // Route de géolocalisation météo - // Announcement routes - PostgreSQL en production, mémoire en développement - app.get('/api/announcements', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - if (environment === 'production') { - // PRODUCTION: Utiliser PostgreSQL avec DASHBOARD_MESSAGES - try { - // Auteur et magasin joints dans la même requête, au lieu de deux - // lectures par message (username et id de magasin sont uniques) - let query = db.select({ - id: dashboardMessages.id, - title: dashboardMessages.title, - content: dashboardMessages.content, - type: dashboardMessages.type, - storeId: dashboardMessages.storeId, - createdBy: dashboardMessages.createdBy, - createdAt: dashboardMessages.createdAt, - authorId: users.id, - authorUsername: users.username, - authorFirstName: users.firstName, - authorLastName: users.lastName, - authorName: users.name, - groupId: groups.id, - groupName: groups.name, - }) - .from(dashboardMessages) - // En production, createdBy est varchar, donc jointure par username - .leftJoin(users, eq(users.username, dashboardMessages.createdBy)) - .leftJoin(groups, eq(groups.id, dashboardMessages.storeId)); - - // Filtrage par magasin pour admin : inclure les annonces globales + annonces du magasin - if (user.role === 'admin' && req.query.storeId) { - const storeId = parseInt(req.query.storeId as string); - // Inclure les annonces globales (storeId = null) ET les annonces du magasin sélectionné - query = query.where( - or( - eq(dashboardMessages.storeId, storeId), - isNull(dashboardMessages.storeId) - ) - ); - } - - const rows = await query.orderBy(desc(dashboardMessages.createdAt)).limit(5); - - const announcements = rows.map((row: any) => { - const { - authorId, authorUsername, authorFirstName, authorLastName, authorName, - groupId, groupName, - ...message - } = row; - - // Auteur : repli sur "Utilisateur Inconnu" si l'utilisateur n'existe plus - let author = { id: message.createdBy, firstName: 'Utilisateur', lastName: 'Inconnu', username: message.createdBy }; - if (authorId) { - // Utiliser name s'il existe, sinon firstName + lastName, sinon username - const displayName = authorName || - (authorFirstName && authorLastName ? `${authorFirstName} ${authorLastName}` : '') || - authorUsername; - - author = { - id: authorId, - firstName: authorFirstName || displayName.split(' ')[0] || authorUsername, - lastName: authorLastName || displayName.split(' ').slice(1).join(' ') || '', - username: authorUsername - }; - } else { - console.warn('❌ [PRODUCTION] User not found with username:', message.createdBy); - } - - // Magasin uniquement si storeId est défini et existe - const group = message.storeId && groupId ? { id: groupId, name: groupName } : null; - - return { - ...message, - author, - group - }; - }); - - res.json(announcements); - - } catch (dbError) { - console.error('🎯 [PRODUCTION] Database error:', dbError); - // Fallback au stockage mémoire en cas d'erreur DB - console.log('🧠 [FALLBACK] Using memory storage due to DB error'); - const announcements = await storage.getAnnouncements(); - res.json(announcements); - } - } else { - // DÉVELOPPEMENT: Utiliser stockage mémoire - const groupIds = user.role === 'admin' && req.query.storeId - ? [parseInt(req.query.storeId as string)] - : undefined; - - const announcements = await storage.getAnnouncements(groupIds); - res.json(announcements); - } - } catch (error) { - console.error("📢 [SERVER] Error fetching announcements:", error); - res.status(500).json({ message: "Failed to fetch announcements" }); - } - }); - - app.post('/api/announcements', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Only admin can create announcements - if (user.role !== 'admin') { - console.error('🎯 [SERVER] Access denied - user role:', user.role); - return res.status(403).json({ message: "Only administrators can create announcements" }); - } - - const announcementData = insertAnnouncementSchema.parse({ - title: req.body.title, - content: req.body.content, - type: req.body.type || 'info', - storeId: req.body.storeId || null, // Permettre les annonces par magasin OU globales - createdBy: user.username, // Utiliser username pour PostgreSQL - }); - - if (environment === 'production') { - // PRODUCTION: Créer dans PostgreSQL DASHBOARD_MESSAGES - try { - const [newMessage] = await db.insert(dashboardMessages).values({ - title: announcementData.title, - content: announcementData.content, - type: announcementData.type, - storeId: announcementData.storeId, - createdBy: user.username, // Utiliser username plutôt que ID pour PostgreSQL - }).returning(); - - const announcement = { - ...newMessage, - author: { id: user.id, firstName: user.firstName, lastName: user.lastName }, - group: null - }; - - res.status(201).json(announcement); - } catch (dbError) { - console.error('🎯 [PRODUCTION] DB error, fallback to memory:', dbError); - const announcement = await storage.createAnnouncement(announcementData); - res.status(201).json(announcement); - } - } else { - // DÉVELOPPEMENT: Créer en mémoire - const announcement = await storage.createAnnouncement(announcementData); - res.status(201).json(announcement); - } - } catch (error) { - console.error('🎯 [SERVER] Error creating announcement:', error); - if (error instanceof z.ZodError) { - console.error('🎯 [SERVER] Validation errors:', error.errors); - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to create announcement" }); - } - }); - - // PUT /api/announcements/:id - Update announcement (admin only) - app.put('/api/announcements/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Only admin can edit announcements - if (user.role !== 'admin') { - console.error('📝 [SERVER] Access denied - user role:', user.role); - return res.status(403).json({ message: "Only administrators can edit announcements" }); - } - - const id = parseInt(req.params.id); - - // Verify announcement exists - const existingAnnouncement = await storage.getAnnouncement(id); - if (!existingAnnouncement) { - console.error('📝 [SERVER] Announcement not found:', id); - return res.status(404).json({ message: "Announcement not found" }); - } - - const announcementData = insertAnnouncementSchema.partial().parse(req.body); - - const updatedAnnouncement = await storage.updateAnnouncement(id, announcementData); - - res.json(updatedAnnouncement); - } catch (error) { - console.error('📝 [SERVER] Error updating announcement:', error); - if (error instanceof z.ZodError) { - console.error('📝 [SERVER] Validation errors:', error.errors); - return res.status(400).json({ message: "Validation error", errors: error.errors }); - } - res.status(500).json({ message: "Failed to update announcement" }); - } - }); - - app.delete('/api/announcements/:id', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Only admin can delete announcements - if (user.role !== 'admin') { - console.error('🗑️ [SERVER] Access denied - user role:', user.role); - return res.status(403).json({ message: "Only administrators can delete announcements" }); - } - - const id = parseInt(req.params.id); - console.log('🗑️ [SERVER] Parsed announcement ID:', id); - - if (environment === 'production') { - // PRODUCTION: Supprimer dans PostgreSQL DASHBOARD_MESSAGES - console.log('🗑️ [PRODUCTION] Deleting from PostgreSQL DASHBOARD_MESSAGES table'); - try { - const deletedRows = await db.delete(dashboardMessages).where(eq(dashboardMessages.id, id)).returning(); - - if (deletedRows.length === 0) { - console.error('🗑️ [PRODUCTION] Announcement not found:', id); - return res.status(404).json({ message: "Announcement not found" }); - } - - console.log('🗑️ [PRODUCTION] Announcement deleted successfully from DB:', deletedRows[0]); - res.json({ message: "Announcement deleted successfully" }); - } catch (dbError) { - console.error('🗑️ [PRODUCTION] DB error, fallback to memory:', dbError); - const success = await storage.deleteAnnouncement(id); - - if (!success) { - return res.status(404).json({ message: "Announcement not found" }); - } - - res.json({ message: "Announcement deleted successfully" }); - } - } else { - // DÉVELOPPEMENT: Supprimer en mémoire - console.log('🗑️ [DEV] Deleting from memory storage'); - const success = await storage.deleteAnnouncement(id); - - if (!success) { - console.error('🗑️ [DEV] Announcement not found:', id); - return res.status(404).json({ message: "Announcement not found" }); - } - - console.log('🗑️ [DEV] Announcement deleted from memory'); - res.json({ message: "Announcement deleted successfully" }); - } - } catch (error) { - console.error("🗑️ [SERVER] Error deleting announcement:", error); - res.status(500).json({ message: "Failed to delete announcement" }); - } - }); - - app.post('/api/weather/geolocation', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const { latitude, longitude } = req.body; - - if (!latitude || !longitude) { - return res.status(400).json({ message: "Latitude et longitude requises" }); - } - - // Récupérer les paramètres météo actuels pour obtenir la clé API - const settings = await storage.getWeatherSettings(); - if (!settings || !settings.apiKey) { - return res.status(500).json({ message: "Configuration météo manquante" }); - } - - // Convertir les coordonnées en nom de ville - const locationData = await weatherService.getCityFromCoordinates( - parseFloat(latitude), - parseFloat(longitude), - settings.apiKey - ); - - if (!locationData) { - return res.status(500).json({ message: "Impossible de déterminer la ville à partir des coordonnées" }); - } - - // Mettre à jour automatiquement la configuration météo avec la nouvelle localisation - await storage.updateWeatherSettings(settings.id, { - location: locationData.fullLocation - }); - - // Vider le cache météo car la localisation a changé - await storage.clearWeatherCache(); - - console.log('🌍 Localisation mise à jour automatiquement:', { - from: settings.location, - to: locationData.fullLocation, - coordinates: { latitude, longitude } - }); - - res.json({ - success: true, - location: locationData, - message: `Localisation mise à jour vers ${locationData.city}, ${locationData.country}` - }); - - } catch (error) { - console.error("Erreur géolocalisation météo:", error); - res.status(500).json({ message: "Erreur lors de la géolocalisation" }); - } - }); - - // API d'exécution SQL pour admin uniquement - app.post('/api/admin/execute-sql', isAuthenticated, async (req: any, res) => { - try { - // Vérifier que l'utilisateur est admin - const userId = req.user?.claims?.sub || req.user?.id; - if (!userId) { - return res.status(401).json({ error: 'Utilisateur non authentifié' }); - } - - const user = await storage.getUser(userId); - if (!user || user.role !== 'admin') { - return res.status(403).json({ error: 'Accès refusé - Admin uniquement' }); - } - - const { sql: sqlQuery } = req.body; - - if (!sqlQuery || typeof sqlQuery !== 'string') { - return res.status(400).json({ error: 'SQL query requis' }); - } - - console.log('🔧 [SQL-EXECUTOR] Début exécution SQL pour admin:', userId); - console.log('🔧 [SQL-EXECUTOR] Query:', sqlQuery.substring(0, 200) + '...'); - - const logs = [`🔄 Exécution SQL démarrée...`]; - - try { - // Utilisation de drizzle-orm pour l'exécution - const { sql } = await import('drizzle-orm'); - const result = await db.execute(sql.raw(sqlQuery)); - - logs.push(`✅ SQL exécuté avec succès`); - logs.push(`📊 Nombre de lignes affectées: ${result.rowCount || 0}`); - - if (result.rows && result.rows.length > 0) { - logs.push(`📋 Nombre de lignes retournées: ${result.rows.length}`); - if (result.rows.length <= 10) { - logs.push(`📋 Résultats: ${JSON.stringify(result.rows, null, 2)}`); - } else { - logs.push(`📋 Échantillon (10 premières lignes): ${JSON.stringify(result.rows.slice(0, 10), null, 2)}`); - } - } - - console.log('✅ [SQL-EXECUTOR] Exécution réussie'); - - res.json({ - success: true, - logs, - results: result.rows, - rowCount: result.rowCount - }); - - } catch (sqlError: any) { - console.error('❌ [SQL-EXECUTOR] Erreur SQL:', sqlError); - logs.push(`❌ Erreur SQL: ${sqlError.message}`); - - return res.status(500).json({ - error: `Erreur SQL: ${sqlError.message}`, - logs - }); - } - - } catch (error: any) { - console.error('❌ [SQL-EXECUTOR] Erreur générale:', error); - res.status(500).json({ error: 'Erreur serveur', details: error.message }); - } - }); - - // Analytics routes - app.get('/api/analytics/summary', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - // Parse query parameters - const filters = { - startDate: req.query.startDate ? new Date(req.query.startDate) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate) : undefined, - supplierIds: req.query.supplierIds ? req.query.supplierIds.split(',').map(Number) : undefined, - groupIds: req.query.groupIds ? req.query.groupIds.split(',').map(Number) : undefined, - status: req.query.status ? req.query.status.split(',') : undefined - }; - - // Apply role-based filtering - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - filters.groupIds = filters.groupIds - ? filters.groupIds.filter((id: number) => userGroupIds.includes(id)) - : userGroupIds; - } - - const summary = await storage.getAnalyticsSummary(filters); - res.json(summary); - } catch (error) { - console.error('Error fetching analytics summary:', error); - res.status(500).json({ message: 'Failed to fetch analytics summary' }); - } - }); - - app.get('/api/analytics/timeseries', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const filters = { - startDate: req.query.startDate ? new Date(req.query.startDate) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate) : undefined, - supplierIds: req.query.supplierIds ? req.query.supplierIds.split(',').map(Number) : undefined, - groupIds: req.query.groupIds ? req.query.groupIds.split(',').map(Number) : undefined, - granularity: (req.query.granularity as 'day' | 'week' | 'month') || 'day' - }; - - // Apply role-based filtering - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - filters.groupIds = filters.groupIds - ? filters.groupIds.filter((id: number) => userGroupIds.includes(id)) - : userGroupIds; - } - - const timeseries = await storage.getAnalyticsTimeseries(filters); - res.json(timeseries); - } catch (error) { - console.error('Error fetching analytics timeseries:', error); - res.status(500).json({ message: 'Failed to fetch analytics timeseries' }); - } - }); - - app.get('/api/analytics/by-supplier', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const filters = { - startDate: req.query.startDate ? new Date(req.query.startDate) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate) : undefined, - groupIds: req.query.groupIds ? req.query.groupIds.split(',').map(Number) : undefined - }; - - // Apply role-based filtering - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - filters.groupIds = filters.groupIds - ? filters.groupIds.filter((id: number) => userGroupIds.includes(id)) - : userGroupIds; - } - - const bySupplier = await storage.getAnalyticsBySupplier(filters); - res.json(bySupplier); - } catch (error) { - console.error('Error fetching analytics by supplier:', error); - res.status(500).json({ message: 'Failed to fetch analytics by supplier' }); - } - }); - - app.get('/api/analytics/by-store', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const filters = { - startDate: req.query.startDate ? new Date(req.query.startDate) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate) : undefined, - supplierIds: req.query.supplierIds ? req.query.supplierIds.split(',').map(Number) : undefined - }; - - const byStore = await storage.getAnalyticsByStore(filters); - res.json(byStore); - } catch (error) { - console.error('Error fetching analytics by store:', error); - res.status(500).json({ message: 'Failed to fetch analytics by store' }); - } - }); - - app.get('/api/analytics/export', isAuthenticated, async (req: any, res) => { - try { - const user = getCurrentUser(req); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const type = req.query.type || 'summary'; - const filters = { - startDate: req.query.startDate ? new Date(req.query.startDate) : undefined, - endDate: req.query.endDate ? new Date(req.query.endDate) : undefined, - supplierIds: req.query.supplierIds ? req.query.supplierIds.split(',').map(Number) : undefined, - groupIds: req.query.groupIds ? req.query.groupIds.split(',').map(Number) : undefined, - status: req.query.status ? req.query.status.split(',') : undefined - }; - - // Apply role-based filtering - if (user.role !== 'admin' && user.role !== 'directeur') { - const userGroupIds = user.userGroups?.map((ug: any) => ug.groupId) || []; - filters.groupIds = filters.groupIds - ? filters.groupIds.filter((id: number) => userGroupIds.includes(id)) - : userGroupIds; - } - - let data: any; - let filename = `analytics_${type}_${new Date().toISOString().split('T')[0]}.csv`; - - switch (type) { - case 'timeseries': - data = await storage.getAnalyticsTimeseries({ ...filters, granularity: 'day' }); - break; - case 'suppliers': - data = await storage.getAnalyticsBySupplier(filters); - break; - case 'stores': - data = await storage.getAnalyticsByStore(filters); - break; - default: - data = [await storage.getAnalyticsSummary(filters)]; - } - - // Convert to CSV - if (data && data.length > 0) { - const headers = Object.keys(data[0]).join(','); - const rows = data.map((item: any) => - Object.values(item).map((val: any) => - typeof val === 'object' ? JSON.stringify(val) : val - ).join(',') - ).join('\n'); - - const csv = `${headers}\n${rows}`; - - res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(csv); - } else { - res.status(404).json({ message: 'No data to export' }); - } - } catch (error) { - console.error('Error exporting analytics:', error); - res.status(500).json({ message: 'Failed to export analytics' }); - } - }); - - // Proxy API ffnancy - évite les problèmes CORS côté navigateur - app.get('/api/ffnancy/articles', requireAuth, async (req: Request, res: Response) => { - try { - const params = new URLSearchParams(); - const allowed = ['search', 'codein', 'ean', 'codefou', 'actif', 'page', 'limit']; - for (const key of allowed) { - if (req.query[key]) params.append(key, req.query[key] as string); - } - const response = await fetch(`https://api.ffnancy.fr/api/articles?${params}`); - if (!response.ok) return res.status(response.status).json({ error: 'API ffnancy error' }); - res.json(await response.json()); - } catch { - res.status(500).json({ error: 'Failed to reach API ffnancy' }); - } - }); - - app.get('/api/ffnancy/mouvements/entrees', requireAuth, async (req: Request, res: Response) => { - try { - const params = new URLSearchParams(); - const allowed = ['artNoId', 'dateDebut', 'dateFin', 'site', 'page', 'limit']; - for (const key of allowed) { - if (req.query[key]) params.append(key, req.query[key] as string); - } - const response = await fetch(`https://api.ffnancy.fr/api/mouvements/entrees?${params}`); - if (!response.ok) return res.status(response.status).json({ error: 'API ffnancy error' }); - res.json(await response.json()); - } catch { - res.status(500).json({ error: 'Failed to reach API ffnancy' }); - } - }); - - // Create server instance - const httpServer = createServer(app); - - // Server startup - return httpServer; -} diff --git a/server/sanitize.ts b/server/sanitize.ts deleted file mode 100644 index 4a129a2..0000000 --- a/server/sanitize.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Nettoyage des données sensibles avant envoi au client. - */ - -/** - * Retire récursivement le mot de passe SMTP des réponses API et le remplace par - * un indicateur de présence (smtpPasswordSet). Traverse les objets et tableaux - * imbriqués car les magasins apparaissent sous plusieurs formes : liste de - * groupes, champ "group" d'une livraison ou d'une commande, relations - * utilisateur... Filtrer chaque requête serait fragile, on nettoie donc une - * seule fois à la sortie. - */ -export function stripSmtpPassword(value: any, depth = 0, seen = new WeakSet()): any { - if (depth > 8 || value === null || typeof value !== 'object') { - return value; - } - - // Les structures cycliques sont renvoyées telles quelles plutôt que de - // faire boucler la récursion - if (seen.has(value)) { - return value; - } - seen.add(value); - - if (Array.isArray(value)) { - return value.map(item => stripSmtpPassword(item, depth + 1, seen)); - } - - // Ne pas dénaturer les types non sérialisables en objets simples - if (value instanceof Date || Buffer.isBuffer(value)) { - return value; - } - - const result: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (key === 'smtpPassword' || key === 'smtp_password') { - result.smtpPasswordSet = Boolean(entry); - continue; - } - result[key] = stripSmtpPassword(entry, depth + 1, seen); - } - return result; -} diff --git a/server/security.ts b/server/security.ts deleted file mode 100644 index 65b6976..0000000 --- a/server/security.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { Express, Request, Response, NextFunction } from 'express'; -import rateLimit from 'express-rate-limit'; -import validator from 'validator'; -import { randomBytes } from 'crypto'; - -// ============================================================================ -// CSRF Protection (Double Submit Cookie Pattern) -// ============================================================================ - -const CSRF_COOKIE_NAME = 'csrf_token'; -const CSRF_HEADER_NAME = 'x-csrf-token'; - -/** - * Generate a cryptographically secure CSRF token - */ -export function generateCsrfToken(): string { - return randomBytes(32).toString('hex'); -} - -/** - * Setup CSRF protection middleware using Double Submit Cookie pattern - * This pattern works well with SPAs and doesn't require server-side session storage - */ -export function setupCsrfProtection(app: Express) { - // Middleware to set CSRF cookie on every response - app.use((req: Request, res: Response, next: NextFunction) => { - // Only set cookie if not already present - if (!req.cookies?.[CSRF_COOKIE_NAME]) { - const token = generateCsrfToken(); - res.cookie(CSRF_COOKIE_NAME, token, { - httpOnly: false, // Must be readable by JS for double submit - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 24 * 60 * 60 * 1000 // 24 hours - }); - } - next(); - }); - - // Middleware to validate CSRF token on state-changing requests - app.use((req: Request, res: Response, next: NextFunction) => { - const safeMethods = ['GET', 'HEAD', 'OPTIONS']; - - // Skip CSRF check for safe methods - if (safeMethods.includes(req.method)) { - return next(); - } - - // Skip CSRF check for API endpoints that use other auth (e.g., webhook callbacks) - const csrfExemptPaths = [ - '/api/health', - '/api/webhook', // External webhook callbacks - '/api/ext/', // API externe : authentifiée par clé d'API, sans cookie de session - ]; - - if (csrfExemptPaths.some(path => req.path.startsWith(path))) { - return next(); - } - - const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; - const headerToken = req.headers[CSRF_HEADER_NAME] as string; - - // Validate CSRF token - if (!cookieToken || !headerToken || cookieToken !== headerToken) { - console.warn(`🚨 CSRF validation failed for ${req.method} ${req.path} from IP: ${req.ip}`); - return res.status(403).json({ - error: 'CSRF token validation failed', - message: 'Request rejected due to security validation failure' - }); - } - - next(); - }); -} - -/** - * Get current CSRF token endpoint for frontend - */ -export function setupCsrfTokenEndpoint(app: Express) { - app.get('/api/csrf-token', (req: Request, res: Response) => { - let token = req.cookies?.[CSRF_COOKIE_NAME]; - - if (!token) { - token = generateCsrfToken(); - res.cookie(CSRF_COOKIE_NAME, token, { - httpOnly: false, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 24 * 60 * 60 * 1000 - }); - } - - res.json({ csrfToken: token }); - }); -} - -// ============================================================================ -// Security Headers -// ============================================================================ - -export function setupSecurityHeaders(app: Express) { - app.use((req: Request, res: Response, next: NextFunction) => { - // Protection contre les attaques XSS - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - - // Protection HTTPS - if (process.env.NODE_ENV === 'production') { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - - // Politique de sécurité du contenu (renforcée) - res.setHeader('Content-Security-Policy', - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + - "style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data: https:; " + - "connect-src 'self' ws: wss:; " + - "font-src 'self' data:; " + - "form-action 'self';" // Prevent form submissions to external sites - ); - - // Protection contre les attaques de référence - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - - // Protection des données sensibles - res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - - next(); - }); -} - -// ============================================================================ -// Rate Limiting -// ============================================================================ - -export function setupRateLimiting(app: Express) { - // Limiteur général - const generalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 1000, // limite chaque IP à 1000 requêtes par fenêtre - message: { - error: 'Trop de requêtes depuis cette IP, veuillez réessayer plus tard.', - }, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => { - return req.path === '/api/health'; - } - }); - - // Limiteur pour l'authentification (strict) - const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // limite les tentatives de connexion - message: { - error: 'Trop de tentatives de connexion, veuillez réessayer plus tard.', - }, - standardHeaders: true, - legacyHeaders: false, - }); - - // Limiteur pour l'API - const apiLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1 minute - max: process.env.NODE_ENV === 'development' ? 500 : 300, - message: { - error: 'Limite API atteinte, veuillez ralentir vos requêtes.', - }, - standardHeaders: true, - legacyHeaders: false, - skip: (req) => { - return req.path === '/api/health' || req.path === '/api/user'; - }, - handler: (req, res) => { - console.warn(`🚨 Rate limit reached for IP: ${req.ip} on path: ${req.path} at ${new Date().toISOString()}`); - res.status(429).json({ - error: 'Limite API atteinte, veuillez ralentir vos requêtes.', - }); - } - }); - - app.use(generalLimiter); - app.use('/api/login', authLimiter); - app.use('/api/', apiLimiter); -} - -// ============================================================================ -// Input Sanitization (Using validator.js) -// ============================================================================ - -/** - * Sanitize a single string value using validator.js - * Protects against XSS, SQL injection patterns, and path traversal - */ -export function sanitizeString(input: string): string { - if (typeof input !== 'string') return input; - - let sanitized = input.trim(); - - // Escape HTML entities to prevent XSS - sanitized = validator.escape(sanitized); - - // Remove null bytes (used in some injection attacks) - sanitized = sanitized.replace(/\0/g, ''); - - // Remove path traversal attempts - sanitized = sanitized.replace(/\.\.\//g, '').replace(/\.\.\\/g, ''); - - return sanitized; -} - -/** - * Recursively sanitize all string values in an object - */ -export function sanitizeInput(input: any): any { - if (typeof input === 'string') { - return sanitizeString(input); - } - - if (Array.isArray(input)) { - return input.map(item => sanitizeInput(item)); - } - - if (typeof input === 'object' && input !== null) { - const sanitized: any = {}; - for (const key in input) { - // Also sanitize object keys to prevent prototype pollution - const sanitizedKey = sanitizeString(key); - if (sanitizedKey === '__proto__' || sanitizedKey === 'constructor' || sanitizedKey === 'prototype') { - continue; // Skip prototype pollution attempts - } - sanitized[sanitizedKey] = sanitizeInput(input[key]); - } - return sanitized; - } - - return input; -} - -/** - * Validate and sanitize email - */ -export function sanitizeEmail(email: string): string | null { - if (!email || typeof email !== 'string') return null; - - const normalized = validator.normalizeEmail(email); - if (!normalized || !validator.isEmail(normalized)) { - return null; - } - - return normalized; -} - -/** - * Validate and sanitize URL - */ -export function sanitizeUrl(url: string): string | null { - if (!url || typeof url !== 'string') return null; - - if (!validator.isURL(url, { - protocols: ['http', 'https'], - require_protocol: true, - require_valid_protocol: true - })) { - return null; - } - - return url; -} - -/** - * Check for SQL injection patterns (for logging/monitoring) - */ -export function detectSqlInjection(input: string): boolean { - if (typeof input !== 'string') return false; - - const sqlPatterns = [ - /(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXEC|UNION|OR|AND)\b.*\b(FROM|INTO|TABLE|WHERE|SET)\b)/i, - /(['"]?\s*(OR|AND)\s*['"]?\s*['"]?\s*=\s*['"]?)/i, - /(--|\#|\/\*|\*\/)/, - /(\bEXEC\b|\bEXECUTE\b|\bxp_)/i, - ]; - - return sqlPatterns.some(pattern => pattern.test(input)); -} - -// Middleware de nettoyage des requêtes -export function setupInputSanitization(app: Express) { - app.use((req: Request, res: Response, next: NextFunction) => { - // Log potential SQL injection attempts - const checkAndLog = (data: any, source: string) => { - if (typeof data === 'object' && data !== null) { - for (const key in data) { - const value = data[key]; - if (typeof value === 'string' && detectSqlInjection(value)) { - console.warn(`🚨 Potential SQL injection detected in ${source}:`, { - ip: req.ip, - path: req.path, - key, - value: value.substring(0, 100) // Truncate for logging - }); - } - } - } - }; - - if (req.body) { - checkAndLog(req.body, 'body'); - req.body = sanitizeInput(req.body); - } - if (req.query) { - checkAndLog(req.query, 'query'); - req.query = sanitizeInput(req.query); - } - if (req.params) { - checkAndLog(req.params, 'params'); - req.params = sanitizeInput(req.params); - } - next(); - }); -} - -// ============================================================================ -// Secure Logging -// ============================================================================ - -const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'api_key', 'authorization', 'cookie']; - -export function secureLog(message: string, data?: any) { - const timestamp = new Date().toISOString(); - - // Mask sensitive data - const maskSensitive = (obj: any): any => { - if (!obj || typeof obj !== 'object') return obj; - - const masked: any = Array.isArray(obj) ? [] : {}; - for (const key in obj) { - if (SENSITIVE_KEYS.some(s => key.toLowerCase().includes(s))) { - masked[key] = '[REDACTED]'; - } else if (typeof obj[key] === 'object') { - masked[key] = maskSensitive(obj[key]); - } else { - masked[key] = obj[key]; - } - } - return masked; - }; - - const logData = data ? JSON.stringify(maskSensitive(data), null, 2) : ''; - - // Check message for sensitive content - if (SENSITIVE_KEYS.some(s => message.toLowerCase().includes(s))) { - console.log(`[${timestamp}] ${message} - [SENSITIVE DATA HIDDEN]`); - } else { - console.log(`[${timestamp}] ${message}`, logData); - } -} \ No newline at end of file diff --git a/server/storage.ts b/server/storage.ts deleted file mode 100644 index c76c6c6..0000000 --- a/server/storage.ts +++ /dev/null @@ -1,5915 +0,0 @@ -import { - users, - groups, - suppliers, - orders, - deliveries, - userGroups, - publicities, - publicityParticipations, - customerOrders, - dlcProducts, - tasks, - dashboardMessages, - announcements, - nocodbConfig, - supplierMailLogs, - invoiceVerificationCache, - reconciliationComments, - savTickets, - weatherData, - weatherSettings, - type User, - type UpsertUser, - type Group, - type InsertGroup, - type Supplier, - type InsertSupplier, - type Order, - type InsertOrder, - type Delivery, - type InsertDelivery, - type UserGroup, - type InsertUserGroup, - type OrderWithRelations, - type DeliveryWithRelations, - type EntityActivityStats, - type UserWithGroups, - type Publicity, - type InsertPublicity, - type PublicityParticipation, - type InsertPublicityParticipation, - type PublicityWithRelations, - type CustomerOrder, - type InsertCustomerOrder, - type CustomerOrderWithRelations, - type DlcProduct, - type InsertDlcProduct, - type DlcProductFrontend, - type InsertDlcProductFrontend, - type DlcProductWithRelations, - type Task, - type InsertTask, - type TaskWithRelations, - type Announcement, - type InsertAnnouncement, - type AnnouncementWithRelations, - type ReconciliationComment, - type InsertReconciliationComment, - type ReconciliationCommentWithRelations, - type DashboardMessage, - type NocodbConfig, - type InsertNocodbConfig, - type SupplierMailLog, - type InsertSupplierMailLog, - type InvoiceVerificationCache, - type InsertInvoiceVerificationCache, - type SavTicket, - type InsertSavTicket, - type SavTicketHistory, - type InsertSavTicketHistory, - type SavTicketWithRelations, - type SavTicketHistoryWithCreator, - type WeatherData, - type InsertWeatherData, - type WeatherSettings, - type InsertWeatherSettings, - webhookBapConfig, - type WebhookBapConfig, - type InsertWebhookBapConfig, - utilities, - type Utilities, - type InsertUtilities, - avoirs, - type Avoir, - type InsertAvoir, - type AvoirWithRelations, - contacts, - type Contact, - type InsertContact, -} from "@shared/schema"; -import { encryptSecret, decryptSecret } from "./crypto"; -import { db } from "./db"; -import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne, getTableColumns } from "drizzle-orm"; -import { getAnnouncementStorage } from "./announcementStorage"; - -// Projections des relations jointes dans les listes. -// -// La ligne groups complète contient le logo (data URI de plusieurs dizaines de -// Ko) et la configuration SMTP : la joindre telle quelle la recopiait dans -// chaque ligne de chaque liste. Le client ne lit que ces champs. Le -// rapprochement et les avoirs ont en plus besoin de la config NocoDB et du -// webhook. La fiche complète reste disponible via getGroup/getGroups. -// L'id doit rester en tête : drizzle renvoie null pour la relation quand la -// première colonne d'une jointure externe est nulle. -const groupSummaryColumns = { - id: groups.id, - name: groups.name, - color: groups.color, -}; - -const groupReconciliationColumns = { - ...groupSummaryColumns, - nocodbConfigId: groups.nocodbConfigId, - nocodbTableName: groups.nocodbTableName, - webhookUrl: groups.webhookUrl, -}; - -// Créateur ou auteur joint à une ligne, sans le hash du mot de passe -const userSummaryColumns = { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email, -}; - -// Toutes les colonnes de users sauf le hash du mot de passe -const { password: _password, ...publicUserColumns } = getTableColumns(users); - -// Magasin joint aux affectations d'un utilisateur (forme de UserWithGroups). -// Les dates restent la valeur brute du driver, comme avec l'ancienne requête SQL -// brute, pour ne pas changer ce que reçoit le client. -const userGroupColumns = { - id: groups.id, - name: groups.name, - color: groups.color, - createdAt: sql`${groups.createdAt}`, - updatedAt: sql`${groups.updatedAt}`, -}; - - -export interface IStorage { - // User operations - getUser(id: string): Promise; - getUserByEmail(email: string): Promise; - getUserByUsername(username: string): Promise; - upsertUser(user: UpsertUser): Promise; - getUserWithGroups(id: string): Promise; - // Tous les utilisateurs avec leurs magasins, sans le champ password - getUsersWithGroups(): Promise; - getUsers(): Promise; - createUser(user: UpsertUser): Promise; - updateUser(id: string, user: Partial): Promise; - deleteUser(id: string): Promise; - - // Group operations - getGroups(): Promise; - getGroup(id: number): Promise; - createGroup(group: InsertGroup): Promise; - updateGroup(id: number, group: Partial): Promise; - deleteGroup(id: number): Promise; - - // Supplier operations - getSuppliers(): Promise; - createSupplier(supplier: InsertSupplier): Promise; - updateSupplier(id: number, supplier: Partial): Promise; - deleteSupplier(id: number): Promise; - - // Contact operations - getContacts(groupIds?: number[]): Promise; - createContact(contact: InsertContact): Promise; - updateContact(id: number, contact: Partial): Promise; - deleteContact(id: number): Promise; - - // Order operations - getOrders(groupIds?: number[]): Promise; - getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise; - getOrder(id: number): Promise; - createOrder(order: InsertOrder): Promise; - updateOrder(id: number, order: Partial): Promise; - deleteOrder(id: number): Promise; - - // Delivery operations - getDeliveries(groupIds?: number[], options?: { status?: string }): Promise; - getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise; - getDelivery(id: number): Promise; - createDelivery(delivery: InsertDelivery): Promise; - updateDelivery(id: number, delivery: Partial): Promise; - deleteDelivery(id: number): Promise; - validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise; - markDeliveryControlValidated(id: number, userId: string): Promise; - - // User-Group operations - getUserGroups(userId: string): Promise; - assignUserToGroup(userGroup: InsertUserGroup): Promise; - removeUserFromGroup(userId: string, groupId: number): Promise; - removeUserFromAllGroups(userId: string): Promise; - - // Statistics - getMonthlyStats(year: number, month: number, groupIds?: number[]): Promise<{ - ordersCount: number; - deliveriesCount: number; - pendingOrdersCount: number; - averageDeliveryTime: number; - totalPalettes: number; - totalPackages: number; - }>; - getYearlyStats(year: number, groupIds?: number[]): Promise<{ - ordersCount: number; - deliveriesCount: number; - pendingOrdersCount: number; - averageDeliveryTime: number; - totalPalettes: number; - totalPackages: number; - }>; - - // Publicity operations - getPublicities(year?: number, groupIds?: number[]): Promise; - getPublicity(id: number): Promise; - createPublicity(publicity: InsertPublicity): Promise; - updatePublicity(id: number, publicity: Partial): Promise; - deletePublicity(id: number): Promise; - - // Publicity participation operations - getPublicityParticipations(publicityId: number): Promise; - setPublicityParticipations(publicityId: number, groupIds: number[]): Promise; - - // NocoDB Configuration operations - getNocodbConfigs(): Promise; - getNocodbConfig(id: number): Promise; - getActiveNocodbConfig(): Promise; - createNocodbConfig(config: InsertNocodbConfig): Promise; - updateNocodbConfig(id: number, config: Partial): Promise; - deleteNocodbConfig(id: number): Promise; - - // Invoice Verification Cache operations - getInvoiceVerificationCache(cacheKey: string): Promise; - saveInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise; - createInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise; - clearExpiredCache(): Promise; - - // Statistiques agrégées (comptages calculés en base, sans rapatrier les lignes) - getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise; - getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise; - - // Customer Order operations - getCustomerOrders(groupIds?: number[]): Promise; - getCustomerOrder(id: number): Promise; - createCustomerOrder(customerOrder: InsertCustomerOrder): Promise; - updateCustomerOrder(id: number, customerOrder: Partial): Promise; - deleteCustomerOrder(id: number): Promise; - - // Client call tracking - getPendingClientCalls(groupIds?: number[]): Promise; - markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise; - - // DLC Product operations - getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise; - getDlcProduct(id: number): Promise; - createDlcProduct(dlcProduct: InsertDlcProduct): Promise; - updateDlcProduct(id: number, dlcProduct: Partial): Promise; - deleteDlcProduct(id: number): Promise; - validateDlcProduct(id: number, validatedBy: string): Promise; - markDlcProductStockEpuise(id: number, markedBy: string): Promise; - restoreDlcProductStock(id: number): Promise; - markDlcProductAsProcessed(id: number, processedBy: string): Promise; - unmarkDlcProductAsProcessed(id: number): Promise; - getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>; - - // Task operations - getTasks(groupIds?: number[], userRole?: string): Promise; - getTask(id: number): Promise; - createTask(task: InsertTask): Promise; - updateTask(id: number, task: Partial): Promise; - deleteTask(id: number): Promise; - completeTask(id: number, completedBy?: string): Promise; - - // Announcement operations - createAnnouncement(announcement: InsertAnnouncement): Promise; - getAnnouncements(groupIds?: number[]): Promise; - getAnnouncement(id: number): Promise; - updateAnnouncement(id: number, announcement: Partial): Promise; - deleteAnnouncement(id: number): Promise; - - // Avoir operations - getAvoirs(groupIds?: number[]): Promise; - getAvoir(id: number): Promise; - createAvoir(avoir: InsertAvoir): Promise; - updateAvoir(id: number, avoir: Partial): Promise; - deleteAvoir(id: number): Promise; - updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise; - updateAvoirNocodbVerification(id: number, verified: boolean): Promise; - - // SAV operations - getSavTickets(filters?: { - groupIds?: number[]; - status?: string; - supplierId?: number; - priority?: string; - startDate?: string; - endDate?: string; - }): Promise; - getSavTicket(id: number): Promise; - createSavTicket(ticket: InsertSavTicket): Promise; - updateSavTicket(id: number, ticket: Partial): Promise; - deleteSavTicket(id: number): Promise; - getSavTicketHistory(ticketId: number): Promise; - addSavTicketHistory(history: InsertSavTicketHistory): Promise; - getSavTicketStats(groupIds?: number[]): Promise<{ - totalTickets: number; - newTickets: number; - inProgressTickets: number; - resolvedTickets: number; - criticalTickets: number; - }>; - - // Weather operations - getWeatherSettings(): Promise; - createWeatherSettings(settings: InsertWeatherSettings): Promise; - updateWeatherSettings(id: number, settings: Partial): Promise; - getWeatherData(date: string, isCurrentYear: boolean): Promise; - createWeatherData(data: InsertWeatherData): Promise; - updateWeatherData(id: number, data: Partial): Promise; - deleteOldWeatherData(daysToKeep: number): Promise; - getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise; - clearWeatherCache(): Promise; - - // Historique des mails fournisseurs (rapprochement) - createSupplierMailLog(log: InsertSupplierMailLog): Promise; - getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise; - - // Webhook BAP Configuration - getWebhookBapConfig(): Promise; - createWebhookBapConfig(config: InsertWebhookBapConfig): Promise; - updateWebhookBapConfig(id: number, config: Partial): Promise; - - // Utilities operations - getUtilities(): Promise; - createUtilities(utilities: InsertUtilities): Promise; - updateUtilities(id: number, utilities: Partial): Promise; - - // Reconciliation Comments operations - getReconciliationComments(deliveryId: number): Promise; - getReconciliationCommentById(id: number): Promise; - createReconciliationComment(comment: InsertReconciliationComment): Promise; - updateReconciliationComment(id: number, comment: Partial): Promise; - deleteReconciliationComment(id: number): Promise; - - // Analytics operations - getAnalyticsSummary(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - status?: string[]; - }): Promise<{ - totalOrders: number; - totalDeliveries: number; - reconciliationRate: number; - totalAmount: number; - avgDeliveryDelay: number; - topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; - topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; - }>; - - getAnalyticsTimeseries(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - granularity?: 'day' | 'week' | 'month'; - }): Promise>; - - getAnalyticsBySupplier(filters: { - startDate?: Date; - endDate?: Date; - groupIds?: number[]; - }): Promise>; - - getAnalyticsByStore(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - }): Promise>; -} - -export class DatabaseStorage implements IStorage { - // User operations - async getUser(id: string): Promise { - const [user] = await db.select().from(users).where(eq(users.id, id)); - return user; - } - - async getUserByEmail(email: string): Promise { - const [user] = await db.select().from(users).where(eq(users.email, email)); - return user; - } - - async getUserByUsername(username: string): Promise { - const [user] = await db.select().from(users).where(eq(users.username, username)); - return user; - } - - async upsertUser(userData: UpsertUser): Promise { - const [user] = await db - .insert(users) - .values(userData) - .onConflictDoUpdate({ - target: users.id, - set: { - ...userData, - updatedAt: new Date(), - }, - }) - .returning(); - return user; - } - - async getUserWithGroups(id: string): Promise { - // Appelée à chaque requête authentifiée (deserializeUser) : l'utilisateur - // et ses magasins sont lus en une seule requête au lieu de deux successives - try { - const rows = await db - .select({ - user: users, - groupId: userGroups.groupId, - group: userGroupColumns, - }) - .from(users) - .leftJoin(userGroups, eq(userGroups.userId, users.id)) - .leftJoin(groups, eq(userGroups.groupId, groups.id)) - .where(eq(users.id, id)); - - if (rows.length === 0) return undefined; - - // Les affectations vers un magasin inexistant sont ignorées, comme - // avec la jointure interne d'origine - const userGroupsList = rows - .filter((row: any) => row.group !== null) - .map((row: any) => ({ - userId: id, - groupId: row.groupId, - group: row.group, - })); - - return { ...rows[0].user, userGroups: userGroupsList }; - } catch (error) { - console.error('Error fetching user with groups:', error); - const user = await this.getUser(id); - return user ? { ...user, userGroups: [] } : undefined; - } - } - - async getUsersWithGroups(): Promise { - // Deux requêtes en parallèle pour tous les utilisateurs, au lieu d'un - // getUserWithGroups par utilisateur - const [allUsers, memberships] = await Promise.all([ - db.select(publicUserColumns).from(users), - (async () => { - try { - return await db - .select({ - userId: userGroups.userId, - groupId: userGroups.groupId, - group: userGroupColumns, - }) - .from(userGroups) - .innerJoin(groups, eq(userGroups.groupId, groups.id)); - } catch (error) { - console.error('Error fetching user groups:', error); - return []; - } - })(), - ]); - - const groupsByUserId = new Map(); - for (const membership of memberships) { - const list = groupsByUserId.get(membership.userId); - if (list) { - list.push(membership); - } else { - groupsByUserId.set(membership.userId, [membership]); - } - } - - return allUsers.map((user: any) => ({ - ...user, - userGroups: groupsByUserId.get(user.id) ?? [], - })) as UserWithGroups[]; - } - - async getUsers(): Promise { - return await db.select().from(users); - } - - async createUser(userData: UpsertUser): Promise { - const [user] = await db.insert(users).values(userData).returning(); - return user; - } - - async updateUser(id: string, userData: Partial): Promise { - const [user] = await db - .update(users) - .set({ ...userData, updatedAt: new Date() }) - .where(eq(users.id, id)) - .returning(); - return user; - } - - async deleteUser(id: string): Promise { - await db.delete(users).where(eq(users.id, id)); - } - - // Group operations - async getGroups(): Promise { - return await db.select().from(groups).orderBy(groups.name); - } - - async getGroup(id: number): Promise { - const [group] = await db.select().from(groups).where(eq(groups.id, id)); - return group; - } - - async createGroup(groupData: InsertGroup): Promise { - // Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au - // moment de l'envoi d'un mail (emailService) - const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) }; - const [group] = await db.insert(groups).values(values).returning(); - return group; - } - - async updateGroup(id: number, groupData: Partial): Promise { - const values = { ...groupData }; - if (values.smtpPassword !== undefined) { - values.smtpPassword = encryptSecret(values.smtpPassword); - } - const [group] = await db - .update(groups) - .set({ ...values, updatedAt: new Date() }) - .where(eq(groups.id, id)) - .returning(); - return group; - } - - async deleteGroup(id: number): Promise { - await db.delete(groups).where(eq(groups.id, id)); - } - - // Supplier operations - async getSuppliers(): Promise { - return await db.select().from(suppliers).orderBy(suppliers.name); - } - - async createSupplier(supplierData: InsertSupplier): Promise { - const [supplier] = await db.insert(suppliers).values(supplierData).returning(); - return supplier; - } - - async updateSupplier(id: number, supplierData: Partial): Promise { - const [supplier] = await db - .update(suppliers) - .set({ ...supplierData, updatedAt: new Date() }) - .where(eq(suppliers.id, id)) - .returning(); - return supplier; - } - - async deleteSupplier(id: number): Promise { - await db.delete(suppliers).where(eq(suppliers.id, id)); - } - - // Contact operations - async getContacts(groupIds?: number[]): Promise { - if (groupIds && groupIds.length > 0) { - return await db.select().from(contacts).where(inArray(contacts.groupId, groupIds)).orderBy(contacts.name); - } - return await db.select().from(contacts).orderBy(contacts.name); - } - - async createContact(contactData: InsertContact): Promise { - const [contact] = await db.insert(contacts).values(contactData).returning(); - return contact; - } - - async updateContact(id: number, contactData: Partial): Promise { - const [contact] = await db - .update(contacts) - .set({ ...contactData, updatedAt: new Date() }) - .where(eq(contacts.id, id)) - .returning(); - return contact; - } - - async deleteContact(id: number): Promise { - await db.delete(contacts).where(eq(contacts.id, id)); - } - - // Chargement groupé des relations. - // - // Les listes de commandes et de livraisons chargeaient auparavant leurs - // relations avec une requête par ligne (N+1). Sur un historique d'un an cela - // représentait plusieurs milliers d'allers-retours par appel d'API. Ces - // helpers récupèrent tout en une seule requête et regroupent côté Node. - - private async loadDeliveriesByOrderIds(orderIds: number[]): Promise> { - const byOrderId = new Map(); - if (orderIds.length === 0) return byOrderId; - - const rows = await db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - invoiceAmountTTC: deliveries.invoiceAmountTTC, - dueDate: deliveries.dueDate, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - controlValidated: deliveries.controlValidated, - controlValidatedBy: deliveries.controlValidatedBy, - controlValidatedAt: deliveries.controlValidatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)) - .where(inArray(deliveries.orderId, orderIds)); - - for (const row of rows) { - if (row.orderId === null) continue; - const existing = byOrderId.get(row.orderId); - if (existing) { - existing.push(row); - } else { - byOrderId.set(row.orderId, [row]); - } - } - - return byOrderId; - } - - private async loadOrdersByIds(orderIds: number[]): Promise> { - const byId = new Map(); - if (orderIds.length === 0) return byId; - - const rows = await db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)) - .where(inArray(orders.id, orderIds)); - - for (const row of rows) { - byId.set(row.id, row); - } - - return byId; - } - - private async loadReconciliationCommentCounts(deliveryIds: number[]): Promise> { - const counts = new Map(); - if (deliveryIds.length === 0) return counts; - - const rows = await db - .select({ - deliveryId: reconciliationComments.deliveryId, - count: sql`count(*)` - }) - .from(reconciliationComments) - .where(inArray(reconciliationComments.deliveryId, deliveryIds)) - .groupBy(reconciliationComments.deliveryId); - - for (const row of rows) { - counts.set(row.deliveryId, Number(row.count)); - } - - return counts; - } - - // Associe à chaque livraison sa commande et son nombre de commentaires, en - // deux requêtes au total quel que soit le nombre de livraisons. - private async attachOrdersAndCommentCounts(baseDeliveries: any[]): Promise { - const orderIds = Array.from( - new Set(baseDeliveries.map((d) => d.orderId).filter((id): id is number => id !== null && id !== undefined)) - ); - const deliveryIds = baseDeliveries.map((d) => d.id); - - const [ordersById, commentCounts] = await Promise.all([ - this.loadOrdersByIds(orderIds), - this.loadReconciliationCommentCounts(deliveryIds) - ]); - - return baseDeliveries.map((delivery) => { - let associatedOrder = undefined; - - if (delivery.orderId) { - const orderData = ordersById.get(delivery.orderId); - if (orderData) { - // Une livraison ne doit jamais exposer une commande d'un autre magasin. - if (orderData.groupId !== delivery.groupId) { - console.error(`❌ PRODUCTION: Delivery #${delivery.id} (store ${delivery.groupId}) linked to order #${delivery.orderId} (store ${orderData.groupId}) - STORE MISMATCH DETECTED!`); - } else { - associatedOrder = orderData; - } - } - } - - return { - ...delivery, - order: associatedOrder, - reconciliationCommentsCount: commentCounts.get(delivery.id) ?? 0 - }; - }); - } - - // Order operations - async getOrders(groupIds?: number[]): Promise { - let query = db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)); - - if (groupIds && groupIds.length > 0) { - query = query.where(inArray(orders.groupId, groupIds)); - } - - const baseOrders = await query.orderBy(desc(orders.createdAt)); - - // Livraisons associées chargées en une seule requête pour toutes les commandes. - const deliveriesByOrderId = await this.loadDeliveriesByOrderIds(baseOrders.map((o: any) => o.id)); - - const ordersWithDeliveries = baseOrders.map((order: any) => ({ - ...order, - deliveries: deliveriesByOrderId.get(order.id) ?? [] - })); - - return ordersWithDeliveries as OrderWithRelations[]; - } - - async getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { - let query = db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)) - .where( - and( - gte(orders.plannedDate, startDate), - lte(orders.plannedDate, endDate) - ) - ); - - if (groupIds && groupIds.length > 0) { - query = query.where( - and( - gte(orders.plannedDate, startDate), - lte(orders.plannedDate, endDate), - inArray(orders.groupId, groupIds) - ) - ); - } - - const baseOrders = await query.orderBy(desc(orders.plannedDate)); - - // Livraisons associées chargées en une seule requête pour toutes les commandes. - const deliveriesByOrderId = await this.loadDeliveriesByOrderIds(baseOrders.map((o: any) => o.id)); - - const ordersWithDeliveries = baseOrders.map((order: any) => ({ - ...order, - deliveries: deliveriesByOrderId.get(order.id) ?? [] - })); - - return ordersWithDeliveries as OrderWithRelations[]; - } - - async getOrder(id: number): Promise { - // La commande et ses livraisons ne dépendent que de l'id : les deux - // requêtes partent en parallèle - const orderQuery = db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)) - .where(eq(orders.id, id)); - - // Livraisons associées à cette commande - const deliveriesQuery = db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - invoiceAmountTTC: deliveries.invoiceAmountTTC, - dueDate: deliveries.dueDate, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - controlValidated: deliveries.controlValidated, - controlValidatedBy: deliveries.controlValidatedBy, - controlValidatedAt: deliveries.controlValidatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)) - .where(eq(deliveries.orderId, id)); - - const [[order], associatedDeliveries] = await Promise.all([orderQuery, deliveriesQuery]); - - if (!order) return undefined; - - return { - ...order, - deliveries: associatedDeliveries - } as OrderWithRelations; - } - - async createOrder(orderData: InsertOrder): Promise { - const [order] = await db.insert(orders).values(orderData).returning(); - return order; - } - - async updateOrder(id: number, orderData: Partial): Promise { - const [order] = await db - .update(orders) - .set({ ...orderData, updatedAt: new Date() }) - .where(eq(orders.id, id)) - .returning(); - return order; - } - - async deleteOrder(id: number): Promise { - await db.delete(orders).where(eq(orders.id, id)); - } - - // Delivery operations - async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise { - let query = db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - invoiceAmountTTC: deliveries.invoiceAmountTTC, - dueDate: deliveries.dueDate, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - controlValidated: deliveries.controlValidated, - controlValidatedBy: deliveries.controlValidatedBy, - controlValidatedAt: deliveries.controlValidatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)); - - const conditions = []; - if (groupIds && groupIds.length > 0) { - conditions.push(inArray(deliveries.groupId, groupIds)); - } - // Filtre de statut optionnel (ex. le rapprochement ne lit que les livraisons livrées) - if (options?.status) { - conditions.push(eq(deliveries.status, options.status)); - } - if (conditions.length > 0) { - query = query.where(and(...conditions)); - } - - const baseDeliveries = await query.orderBy(desc(deliveries.createdAt)); - - const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); - - return deliveriesWithOrders as DeliveryWithRelations[]; - } - - async getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { - let query = db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - invoiceAmountTTC: deliveries.invoiceAmountTTC, - dueDate: deliveries.dueDate, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - controlValidated: deliveries.controlValidated, - controlValidatedBy: deliveries.controlValidatedBy, - controlValidatedAt: deliveries.controlValidatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)) - .where( - and( - gte(deliveries.scheduledDate, startDate), - lte(deliveries.scheduledDate, endDate) - ) - ); - - if (groupIds && groupIds.length > 0) { - query = query.where( - and( - gte(deliveries.scheduledDate, startDate), - lte(deliveries.scheduledDate, endDate), - inArray(deliveries.groupId, groupIds) - ) - ); - } - - const baseDeliveries = await query.orderBy(desc(deliveries.scheduledDate)); - - const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); - - return deliveriesWithOrders as DeliveryWithRelations[]; - } - - async getDelivery(id: number): Promise { - const [delivery] = await db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - invoiceAmountTTC: deliveries.invoiceAmountTTC, - dueDate: deliveries.dueDate, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groups - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .where(eq(deliveries.id, id)); - - if (!delivery) return undefined; - - // Créateur, commande associée et nombre de commentaires ne dépendent que de - // la livraison : les trois chargements partent en parallèle. Chacun garde - // son try/catch pour qu'un échec n'empêche pas de renvoyer la livraison. - const loadCreator = async () => { - try { - const user = await this.getUser(delivery.createdBy); - if (!user) return null; - return { - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - username: user.username, - email: user.email - }; - } catch (error) { - console.warn('⚠️ Could not load creator info:', error); - return null; - } - }; - - const loadAssociatedOrder = async () => { - if (!delivery.orderId) return undefined; - try { - const orderData = await this.getOrder(delivery.orderId); - if (!orderData) return undefined; - // CRITICAL FIX: Vérifier que la commande appartient au même magasin que la livraison - if (orderData.groupId !== delivery.groupId) { - console.error(`❌ PRODUCTION: getDelivery #${id} (store ${delivery.groupId}) linked to order #${delivery.orderId} (store ${orderData.groupId}) - STORE MISMATCH DETECTED!`); - // Ne pas inclure la commande si elle n'appartient pas au bon magasin - return undefined; - } - return orderData; - } catch (error) { - console.error(`❌ PRODUCTION: Failed to retrieve associated order #${delivery.orderId} for delivery #${id}:`, error); - return undefined; - } - }; - - // Compter les commentaires de rapprochement pour cette livraison - const countComments = async () => { - try { - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(reconciliationComments) - .where(eq(reconciliationComments.deliveryId, id)); - - return Number(countResult?.count || 0); - } catch (error) { - console.error(`Failed to count reconciliation comments for delivery #${id}:`, error); - return 0; - } - }; - - const [creator, associatedOrder, commentsCount] = await Promise.all([ - loadCreator(), - loadAssociatedOrder(), - countComments() - ]); - - return { - ...delivery, - creator, - order: associatedOrder, - reconciliationCommentsCount: commentsCount - } as DeliveryWithRelations; - } - - async createDelivery(deliveryData: InsertDelivery): Promise { - // Créer la livraison avec statut "planned" par défaut - const deliveryDataWithStatus = { - ...deliveryData, - status: deliveryData.status || 'planned' // Force planned status by default - }; - - const [delivery] = await db.insert(deliveries).values(deliveryDataWithStatus).returning(); - - // Si une commande est liée, la marquer comme "planned" (pas delivered!) - if (deliveryData.orderId) { - try { - console.log(`🔗 PRODUCTION: Delivery #${delivery.id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); - - // Récupérer la commande actuelle - const [currentOrder] = await db - .select() - .from(orders) - .where(eq(orders.id, deliveryData.orderId)); - - if (currentOrder && currentOrder.status === 'pending') { - await db - .update(orders) - .set({ - status: 'planned', - updatedAt: new Date() - }) - .where(eq(orders.id, deliveryData.orderId)); - - console.log(`✅ PRODUCTION: Order #${deliveryData.orderId} status updated to 'planned'`); - } - } catch (error) { - console.error(`❌ PRODUCTION: Failed to update order #${deliveryData.orderId} status to planned:`, error); - } - } - - return delivery; - } - - async updateDelivery(id: number, deliveryData: Partial): Promise { - const [delivery] = await db - .update(deliveries) - .set({ ...deliveryData, updatedAt: new Date() }) - .where(eq(deliveries.id, id)) - .returning(); - - // CRITICAL FIX: Si une commande est liée lors de l'édition, la marquer comme "planned" - if (deliveryData.orderId) { - try { - console.log(`🔗 CALENDAR EDIT: Delivery #${delivery.id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); - - // Récupérer la commande actuelle - const [currentOrder] = await db - .select() - .from(orders) - .where(eq(orders.id, deliveryData.orderId)); - - if (currentOrder && currentOrder.status === 'pending') { - await db - .update(orders) - .set({ - status: 'planned', - updatedAt: new Date() - }) - .where(eq(orders.id, deliveryData.orderId)); - - console.log(`✅ CALENDAR EDIT: Order #${deliveryData.orderId} status updated to 'planned'`); - } else if (currentOrder) { - console.log(`ℹ️ CALENDAR EDIT: Order #${deliveryData.orderId} status is '${currentOrder.status}', no update needed`); - } - } catch (error) { - console.error(`❌ CALENDAR EDIT: Failed to update order #${deliveryData.orderId} status to planned:`, error); - } - } - - return delivery; - } - - async deleteDelivery(id: number): Promise { - await db.delete(deliveries).where(eq(deliveries.id, id)); - } - - async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise { - const updateData: any = { - status: 'delivered', - deliveredDate: new Date(), - updatedAt: new Date(), - }; - - if (blData) { - updateData.blNumber = blData.blNumber; - if (blData.blAmount !== undefined) { - updateData.blAmount = blData.blAmount.toString(); - } - } - - await db.update(deliveries).set(updateData).where(eq(deliveries.id, id)); - } - - async markDeliveryControlValidated(id: number, userId: string): Promise { - await db.update(deliveries).set({ - controlValidated: true, - controlValidatedBy: userId, - controlValidatedAt: new Date(), - updatedAt: new Date(), - }).where(eq(deliveries.id, id)); - } - - // User-Group operations - async getUserGroups(userId: string): Promise { - return await db.select().from(userGroups).where(eq(userGroups.userId, userId)); - } - - async assignUserToGroup(userGroupData: InsertUserGroup): Promise { - const [userGroup] = await db.insert(userGroups).values(userGroupData).returning(); - return userGroup; - } - - async removeUserFromGroup(userId: string, groupId: number): Promise { - await db.delete(userGroups).where( - and(eq(userGroups.userId, userId), eq(userGroups.groupId, groupId)) - ); - } - - async removeUserFromAllGroups(userId: string): Promise { - await db.delete(userGroups).where(eq(userGroups.userId, userId)); - } - - // Statistics - async getMonthlyStats(year: number, month: number, groupIds?: number[]): Promise<{ - ordersCount: number; - deliveriesCount: number; - pendingOrdersCount: number; - averageDeliveryTime: number; - totalPalettes: number; - totalPackages: number; - }> { - // En mode développement avec MemStorage, retourner des statistiques simulées - if (process.env.NODE_ENV === 'development') { - return { - ordersCount: 12, - deliveriesCount: 8, - pendingOrdersCount: 3, - averageDeliveryTime: 2.5, - totalPalettes: 45, - totalPackages: 156, - }; - } - - // En production, calculer les vraies statistiques - const startDate = `${year}-${month.toString().padStart(2, '0')}-01`; - const endDate = month === 12 - ? `${year + 1}-01-01` - : `${year}-${(month + 1).toString().padStart(2, '0')}-01`; - - try { - // Construire les conditions pour le filtrage par groupe - UTILISER LE BON CHAMP DE DATE - let ordersWhereCondition = and( - gte(orders.plannedDate, startDate), - lt(orders.plannedDate, endDate) - ); - // Pour les livraisons, utiliser deliveredDate pour celles livrées, sinon scheduledDate - let deliveriesWhereCondition = and( - gte(deliveries.scheduledDate, startDate), - lt(deliveries.scheduledDate, endDate) - ); - - // Ajouter le filtre par groupe si spécifié - if (groupIds && groupIds.length > 0) { - ordersWhereCondition = and(ordersWhereCondition, inArray(orders.groupId, groupIds)); - deliveriesWhereCondition = and(deliveriesWhereCondition, inArray(deliveries.groupId, groupIds)); - } - - // Compter les commandes du mois - const ordersQuery = db - .select({ count: sql`count(*)` }) - .from(orders) - .where(ordersWhereCondition); - - // Compter les livraisons du mois - const deliveriesQuery = db - .select({ count: sql`count(*)` }) - .from(deliveries) - .where(deliveriesWhereCondition); - - // Commandes en attente (sans date de livraison ou pas encore livrées) - let pendingWhereCondition = eq(orders.status, 'pending'); - if (groupIds && groupIds.length > 0) { - pendingWhereCondition = and(pendingWhereCondition, inArray(orders.groupId, groupIds)) as any; - } - - const pendingQuery = db - .select({ count: sql`count(*)` }) - .from(orders) - .where(pendingWhereCondition); - - // Calculer les totaux de palettes et colis du mois (SEULEMENT livraisons delivered) - // IMPORTANT: Utiliser deliveredDate pour filtrer par le mois de livraison effective - let deliveredWhereCondition = and( - gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), - lt(deliveries.deliveredDate, sql`${endDate}::timestamp`), - eq(deliveries.status, 'delivered'), - isNotNull(deliveries.deliveredDate) - ); - - // Ajouter le filtre par groupe pour les livraisons delivered - if (groupIds && groupIds.length > 0) { - deliveredWhereCondition = and(deliveredWhereCondition, inArray(deliveries.groupId, groupIds)); - } - - // NOUVEAU: Calculer le délai moyen entre la date de commande et la date de livraison - // Uniquement pour les livraisons qui ont une commande liée - const deliveriesStatsQuery = db - .select({ - totalPalettes: sql`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`, - totalPackages: sql`COALESCE(COUNT(*), 0)`, - avgDelay: sql`COALESCE(AVG(EXTRACT(DAY FROM (${deliveries.deliveredDate} - ${orders.plannedDate}))), 0)` - }) - .from(deliveries) - .innerJoin(orders, eq(deliveries.orderId, orders.id)) - .where( - and( - deliveredWhereCondition, - isNotNull(deliveries.orderId), // S'assurer qu'il y a une commande liée - isNotNull(orders.plannedDate) // S'assurer que la commande a une date - ) - ); - - // Les quatre requêtes sont indépendantes : on les lance en parallèle - const [ordersResult, deliveriesResult, pendingResult, deliveriesStatsResult] = await Promise.all([ - ordersQuery, - deliveriesQuery, - pendingQuery, - deliveriesStatsQuery - ]); - - const ordersCount = Number(ordersResult[0]?.count || 0); - const deliveriesCount = Number(deliveriesResult[0]?.count || 0); - const pendingOrdersCount = Number(pendingResult[0]?.count || 0); - const totalPalettes = Number(deliveriesStatsResult[0]?.totalPalettes || 0); - const totalPackages = Number(deliveriesStatsResult[0]?.totalPackages || 0); - const averageDeliveryTime = Number(deliveriesStatsResult[0]?.avgDelay || 0); - - return { - ordersCount, - deliveriesCount, - pendingOrdersCount, - averageDeliveryTime, - totalPalettes, - totalPackages, - }; - - } catch (error) { - console.error('❌ Erreur calcul statistiques:', error); - // Retourner des zéros en cas d'erreur pour éviter un crash - return { - ordersCount: 0, - deliveriesCount: 0, - pendingOrdersCount: 0, - averageDeliveryTime: 0, - totalPalettes: 0, - totalPackages: 0, - }; - } - } - - // Statistics annuelles - async getYearlyStats(year: number, groupIds?: number[]): Promise<{ - ordersCount: number; - deliveriesCount: number; - pendingOrdersCount: number; - averageDeliveryTime: number; - totalPalettes: number; - totalPackages: number; - }> { - // En mode développement avec MemStorage, retourner des statistiques simulées - if (process.env.NODE_ENV === 'development') { - return { - ordersCount: 144, // 12 mois * 12 - deliveriesCount: 96, // 12 mois * 8 - pendingOrdersCount: 18, // 12 mois * 1.5 - averageDeliveryTime: 2.8, // Délai moyen annuel - totalPalettes: 540, // 12 mois * 45 - totalPackages: 1872, // 12 mois * 156 - }; - } - - // En production, calculer les vraies statistiques sur l'année - const startDate = `${year}-01-01`; - const endDate = `${year + 1}-01-01`; - - try { - // Construire les conditions pour le filtrage par groupe - let ordersWhereCondition = and( - gte(orders.plannedDate, startDate), - lt(orders.plannedDate, endDate) - ); - let deliveriesWhereCondition = and( - gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), - lt(deliveries.deliveredDate, sql`${endDate}::timestamp`) - ); - - // Ajouter le filtre par groupe si spécifié - if (groupIds && groupIds.length > 0) { - ordersWhereCondition = and(ordersWhereCondition, inArray(orders.groupId, groupIds)); - deliveriesWhereCondition = and(deliveriesWhereCondition, inArray(deliveries.groupId, groupIds)); - } - - // Compter les commandes de l'année - const ordersQuery = db - .select({ count: sql`count(*)` }) - .from(orders) - .where(ordersWhereCondition); - - // Compter les livraisons de l'année - const deliveriesQuery = db - .select({ count: sql`count(*)` }) - .from(deliveries) - .where(deliveriesWhereCondition); - - // Commandes en attente (sans date de livraison ou pas encore livrées) - let pendingWhereCondition = eq(orders.status, 'pending'); - if (groupIds && groupIds.length > 0) { - pendingWhereCondition = and(pendingWhereCondition, inArray(orders.groupId, groupIds)) as any; - } - - const pendingQuery = db - .select({ count: sql`count(*)` }) - .from(orders) - .where(pendingWhereCondition); - - // Calculer les totaux de palettes et colis de l'année (SEULEMENT livraisons delivered) - let deliveredWhereCondition = and( - gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), - lt(deliveries.deliveredDate, sql`${endDate}::timestamp`), - eq(deliveries.status, 'delivered'), - isNotNull(deliveries.deliveredDate) - ); - - // Ajouter le filtre par groupe pour les livraisons delivered - if (groupIds && groupIds.length > 0) { - deliveredWhereCondition = and(deliveredWhereCondition, inArray(deliveries.groupId, groupIds)); - } - - // NOUVEAU: Calculer le délai moyen entre la date de commande et la date de livraison - // Uniquement pour les livraisons qui ont une commande liée - const deliveriesStatsQuery = db - .select({ - totalPalettes: sql`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`, - totalPackages: sql`COALESCE(COUNT(*), 0)`, - avgDelay: sql`COALESCE(AVG(EXTRACT(DAY FROM (${deliveries.deliveredDate} - ${orders.plannedDate}))), 0)` - }) - .from(deliveries) - .innerJoin(orders, eq(deliveries.orderId, orders.id)) - .where( - and( - deliveredWhereCondition, - isNotNull(deliveries.orderId), // S'assurer qu'il y a une commande liée - isNotNull(orders.plannedDate) // S'assurer que la commande a une date - ) - ); - - // Les quatre requêtes sont indépendantes : on les lance en parallèle - const [ordersResult, deliveriesResult, pendingResult, deliveriesStatsResult] = await Promise.all([ - ordersQuery, - deliveriesQuery, - pendingQuery, - deliveriesStatsQuery - ]); - - const ordersCount = Number(ordersResult[0]?.count || 0); - const deliveriesCount = Number(deliveriesResult[0]?.count || 0); - const pendingOrdersCount = Number(pendingResult[0]?.count || 0); - const totalPalettes = Number(deliveriesStatsResult[0]?.totalPalettes || 0); - const totalPackages = Number(deliveriesStatsResult[0]?.totalPackages || 0); - const averageDeliveryTime = Number(deliveriesStatsResult[0]?.avgDelay || 0); - - return { - ordersCount, - deliveriesCount, - pendingOrdersCount, - averageDeliveryTime, - totalPalettes, - totalPackages, - }; - - } catch (error) { - console.error('❌ Erreur calcul statistiques annuelles:', error); - // Retourner des zéros en cas d'erreur pour éviter un crash - return { - ordersCount: 0, - deliveriesCount: 0, - pendingOrdersCount: 0, - averageDeliveryTime: 0, - totalPalettes: 0, - totalPackages: 0, - }; - } - } - - // Publicity operations - async getPublicities(year?: number, groupIds?: number[]): Promise { - let query = db - .select({ - id: publicities.id, - pubNumber: publicities.pubNumber, - designation: publicities.designation, - startDate: publicities.startDate, - endDate: publicities.endDate, - year: publicities.year, - createdBy: publicities.createdBy, - createdAt: publicities.createdAt, - updatedAt: publicities.updatedAt, - }) - .from(publicities); - - if (year) { - query = query.where(eq(publicities.year, year)); - } - - // SAFE: Use simple column ordering instead of SQL CAST which fails in production - const results = await query.orderBy(publicities.pubNumber); - - const publicityIds = results.map((p: any) => p.id); - const participations = publicityIds.length > 0 - ? await db - .select({ - publicityId: publicityParticipations.publicityId, - groupId: publicityParticipations.groupId, - group: groupSummaryColumns, - }) - .from(publicityParticipations) - .leftJoin(groups, eq(publicityParticipations.groupId, groups.id)) - .where(inArray(publicityParticipations.publicityId, publicityIds)) - : []; - - // Sort by pubNumber as integer on the server side for consistency - const sortedResults = results.sort((a: any, b: any) => { - const numA = parseInt(a.pubNumber) || 0; - const numB = parseInt(b.pubNumber) || 0; - return numA - numB; - }); - - // Participations regroupées par publicité en un seul passage - const participationsByPublicityId = new Map(); - for (const p of participations as any[]) { - const participation = { - publicityId: p.publicityId, - groupId: p.groupId, - group: p.group!, - }; - const list = participationsByPublicityId.get(p.publicityId); - if (list) { - list.push(participation); - } else { - participationsByPublicityId.set(p.publicityId, [participation]); - } - } - - return sortedResults.map((publicity: any) => ({ - ...publicity, - participations: participationsByPublicityId.get(publicity.id) ?? [], - })); - } - - async getPublicity(id: number): Promise { - const [publicity] = await db.select().from(publicities).where(eq(publicities.id, id)); - if (!publicity) return undefined; - - const participations = await db - .select({ - publicityId: publicityParticipations.publicityId, - groupId: publicityParticipations.groupId, - group: groupSummaryColumns, - }) - .from(publicityParticipations) - .leftJoin(groups, eq(publicityParticipations.groupId, groups.id)) - .where(eq(publicityParticipations.publicityId, id)); - - return { - ...publicity, - participations: participations.map((p: any) => ({ - publicityId: p.publicityId, - groupId: p.groupId, - group: p.group!, - })), - }; - } - - async createPublicity(publicityData: InsertPublicity): Promise { - const [publicity] = await db.insert(publicities).values(publicityData).returning(); - return publicity; - } - - async updatePublicity(id: number, publicityData: Partial): Promise { - const [publicity] = await db - .update(publicities) - .set({ ...publicityData, updatedAt: new Date() }) - .where(eq(publicities.id, id)) - .returning(); - return publicity; - } - - async deletePublicity(id: number): Promise { - try { - // First, delete all participations (defensive approach for production DB constraints) - await db.delete(publicityParticipations).where(eq(publicityParticipations.publicityId, id)); - - // Then delete the publicity itself - const deletedPublicity = await db.delete(publicities).where(eq(publicities.id, id)).returning(); - - if (deletedPublicity.length === 0) { - throw new Error(`Publicity with ID ${id} not found`); - } - } catch (error) { - console.error(`❌ [DELETION] Failed to delete publicity ID: ${id}`, error); - throw error; - } - } - - async getPublicityParticipations(publicityId: number): Promise { - return await db - .select() - .from(publicityParticipations) - .where(eq(publicityParticipations.publicityId, publicityId)); - } - - async setPublicityParticipations(publicityId: number, groupIds: number[]): Promise { - await db.delete(publicityParticipations).where(eq(publicityParticipations.publicityId, publicityId)); - - if (groupIds.length > 0) { - const participationsData = groupIds.map(groupId => ({ - publicityId, - groupId, - })); - await db.insert(publicityParticipations).values(participationsData); - } - } - - // NocoDB Configuration operations - // Le jeton API est chiffré en base ; les lecteurs (vérification de facture, - // page d'admin) reçoivent la valeur en clair comme avant le chiffrement - private decryptNocodbConfig(config: T): T { - if (!config) return config; - try { - return { ...config, apiToken: decryptSecret(config.apiToken) } as T; - } catch (error) { - console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error); - return { ...config, apiToken: '' } as T; - } - } - - async getNocodbConfigs(): Promise { - const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt)); - return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c)); - } - - async getNocodbConfig(id: number): Promise { - const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id)); - return this.decryptNocodbConfig(config); - } - - async getActiveNocodbConfig(): Promise { - try { - const [config] = await db - .select() - .from(nocodbConfig) - .where(eq(nocodbConfig.isActive, true)) - .limit(1); - - return this.decryptNocodbConfig(config); - } catch (error) { - console.error('❌ Erreur récupération config NocoDB:', error); - return undefined; - } - } - - async createNocodbConfig(configData: InsertNocodbConfig): Promise { - const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' }; - const [config] = await db.insert(nocodbConfig).values(values).returning(); - return this.decryptNocodbConfig(config); - } - - async updateNocodbConfig(id: number, configData: Partial): Promise { - const values = { ...configData }; - if (values.apiToken !== undefined) { - values.apiToken = encryptSecret(values.apiToken) || ''; - } - const [config] = await db - .update(nocodbConfig) - .set({ ...values, updatedAt: new Date() }) - .where(eq(nocodbConfig.id, id)) - .returning(); - return this.decryptNocodbConfig(config); - } - - async deleteNocodbConfig(id: number): Promise { - await db.delete(nocodbConfig).where(eq(nocodbConfig.id, id)); - } - - // Invoice Verification Cache operations - async getInvoiceVerificationCache(cacheKey: string): Promise { - const [cache] = await db - .select() - .from(invoiceVerificationCache) - .where(eq(invoiceVerificationCache.cacheKey, cacheKey)); - - // ✅ Vérification d'expiration ajoutée pour production - if (cache) { - const now = new Date(); - const expiresAt = new Date(cache.expiresAt); - - if (now < expiresAt) { - return cache; // Cache valide - } else { - console.log('⏰ [DATABASE-CACHE] Cache expiré, suppression:', { - cacheKey, - expiredAt: cache.expiresAt - }); - // Supprimer le cache expiré - await db.delete(invoiceVerificationCache) - .where(eq(invoiceVerificationCache.cacheKey, cacheKey)); - return undefined; // Cache expiré - } - } - - return undefined; // Pas de cache - } - - async createInvoiceVerificationCache(cacheData: InsertInvoiceVerificationCache): Promise { - try { - // Utiliser UPSERT pour gérer les conflits de clés - const [cache] = await db - .insert(invoiceVerificationCache) - .values(cacheData) - .onConflictDoUpdate({ - target: invoiceVerificationCache.cacheKey, - set: { - exists: cacheData.exists, - matchType: cacheData.matchType, - errorMessage: cacheData.errorMessage, - supplierName: cacheData.supplierName, - invoiceReference: cacheData.invoiceReference, - invoiceAmount: cacheData.invoiceAmount, - dueDate: cacheData.dueDate, - isReconciled: cacheData.isReconciled, - cacheHit: cacheData.cacheHit, - apiCallTime: cacheData.apiCallTime, - updatedAt: new Date() - } - }) - .returning(); - return cache; - } catch (error) { - console.error('❌ Erreur création cache:', error); - throw error; - } - } - - async saveInvoiceVerificationCache(cacheData: InsertInvoiceVerificationCache): Promise { - return this.createInvoiceVerificationCache(cacheData); - } - - async clearExpiredCache(): Promise { - await db.delete(invoiceVerificationCache).where( - sql`expires_at < NOW()` - ); - } - - // Statistiques agrégées. - // - // Les pages Magasins et Fournisseurs n'affichent que des comptages. Elles - // téléchargeaient auparavant l'intégralité des commandes et des livraisons, - // relations imbriquées comprises, pour ne faire que des .filter().length côté - // navigateur. Ces deux méthodes font le comptage en base. - - private mergeActivityStats( - orderRows: { id: number | null; count: unknown }[], - deliveryRows: { id: number | null; count: unknown; delivered: unknown }[] - ): EntityActivityStats[] { - const byId = new Map(); - - const entryFor = (id: number): EntityActivityStats => { - let entry = byId.get(id); - if (!entry) { - entry = { id, orders: 0, deliveries: 0, delivered: 0 }; - byId.set(id, entry); - } - return entry; - }; - - for (const row of orderRows) { - if (row.id === null) continue; - entryFor(row.id).orders = Number(row.count) || 0; - } - - for (const row of deliveryRows) { - if (row.id === null) continue; - const entry = entryFor(row.id); - entry.deliveries = Number(row.count) || 0; - entry.delivered = Number(row.delivered) || 0; - } - - return Array.from(byId.values()); - } - - async getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise { - const scoped = groupIds && groupIds.length > 0; - - const orderQuery = db - .select({ id: orders.groupId, count: sql`count(*)` }) - .from(orders) - .groupBy(orders.groupId); - - const deliveryQuery = db - .select({ - id: deliveries.groupId, - count: sql`count(*)`, - delivered: sql`count(*) filter (where ${deliveries.status} = 'delivered')` - }) - .from(deliveries) - .groupBy(deliveries.groupId); - - const [orderRows, deliveryRows] = await Promise.all([ - scoped ? orderQuery.where(inArray(orders.groupId, groupIds!)) : orderQuery, - scoped ? deliveryQuery.where(inArray(deliveries.groupId, groupIds!)) : deliveryQuery - ]); - - return this.mergeActivityStats(orderRows, deliveryRows); - } - - async getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise { - const scoped = groupIds && groupIds.length > 0; - - const orderQuery = db - .select({ id: orders.supplierId, count: sql`count(*)` }) - .from(orders) - .groupBy(orders.supplierId); - - const deliveryQuery = db - .select({ - id: deliveries.supplierId, - count: sql`count(*)`, - delivered: sql`count(*) filter (where ${deliveries.status} = 'delivered')` - }) - .from(deliveries) - .groupBy(deliveries.supplierId); - - const [orderRows, deliveryRows] = await Promise.all([ - scoped ? orderQuery.where(inArray(orders.groupId, groupIds!)) : orderQuery, - scoped ? deliveryQuery.where(inArray(deliveries.groupId, groupIds!)) : deliveryQuery - ]); - - return this.mergeActivityStats(orderRows, deliveryRows); - } - - // Real implementations for production operations - async getCustomerOrders(groupIds?: number[]): Promise { - let query = db - .select({ - customerOrder: customerOrders, - supplier: suppliers, - group: groupSummaryColumns - }) - .from(customerOrders) - .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) - .leftJoin(groups, eq(customerOrders.groupId, groups.id)); - - if (groupIds && groupIds.length > 0) { - query = query.where(inArray(customerOrders.groupId, groupIds)); - } - - const results = await query.orderBy(desc(customerOrders.createdAt)); - return results.map((row: any) => ({ - ...row.customerOrder, - supplier: row.supplier, - group: row.group, - creator: row.creator || { id: row.customerOrder.createdBy, username: 'unknown' } - })); - } - - async getCustomerOrder(id: number): Promise { - const [result] = await db - .select({ - customerOrder: customerOrders, - supplier: suppliers, - group: groups - }) - .from(customerOrders) - .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) - .leftJoin(groups, eq(customerOrders.groupId, groups.id)) - .where(eq(customerOrders.id, id)); - - if (!result) return undefined; - return { - ...result.customerOrder, - supplier: result.supplier, - group: result.group, - creator: result.creator || { id: result.customerOrder.createdBy, username: 'unknown' } - }; - } - - async createCustomerOrder(orderData: InsertCustomerOrder): Promise { - const [customerOrder] = await db.insert(customerOrders).values(orderData).returning(); - return customerOrder; - } - - async updateCustomerOrder(id: number, orderData: Partial): Promise { - const [customerOrder] = await db - .update(customerOrders) - .set({ ...orderData, updatedAt: new Date() }) - .where(eq(customerOrders.id, id)) - .returning(); - return customerOrder; - } - - async deleteCustomerOrder(id: number): Promise { - await db.delete(customerOrders).where(eq(customerOrders.id, id)); - } - - async getPendingClientCalls(groupIds?: number[]): Promise { - const conditions = [ - eq(customerOrders.customerNotified, false), - inArray(customerOrders.status, ['Disponible', 'disponible', 'Arrivé', 'arrivé', 'Prêt', 'prêt']) - ]; - - if (groupIds && groupIds.length > 0) { - conditions.push(inArray(customerOrders.groupId, groupIds)); - } - - const results = await db - .select({ - customerOrder: customerOrders, - supplier: suppliers, - group: groupSummaryColumns - }) - .from(customerOrders) - .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) - .leftJoin(groups, eq(customerOrders.groupId, groups.id)) - .where(and(...conditions)) - .orderBy(desc(customerOrders.createdAt)); - - return results.map((result: any) => ({ - ...result.customerOrder, - supplier: result.supplier!, - group: result.group!, - creator: null // Will be populated if needed - })); - } - - async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise { - const [updated] = await db - .update(customerOrders) - .set({ - customerNotified: true, - notifiedAt: new Date(), - notifiedComment: comment || null, - updatedAt: new Date() - }) - .where(eq(customerOrders.id, customerOrderId)) - .returning(); - - return updated; - } - - async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { - let query = db - .select({ - dlcProduct: dlcProducts, - supplier: suppliers, - group: groupSummaryColumns - }) - .from(dlcProducts) - .leftJoin(suppliers, eq(dlcProducts.supplierId, suppliers.id)) - .leftJoin(groups, eq(dlcProducts.groupId, groups.id)); - - const conditions = []; - - if (groupIds && groupIds.length > 0) { - conditions.push(inArray(dlcProducts.groupId, groupIds)); - } - - if (filters) { - // Handle dynamic status filtering based on expiry date calculation - if (filters.status && filters.status !== 'all') { - const today = new Date(); - today.setHours(0, 0, 0, 0); // Set to start of day for accurate comparison - - if (filters.status === 'expires_soon') { - // Products expiring within 15 days but not expired yet - // Ne pas afficher les produits déjà traités (processedUntilExpiry = true) - const in15Days = new Date(); - in15Days.setDate(today.getDate() + 15); - in15Days.setHours(23, 59, 59, 999); // End of day - conditions.push( - and( - gt(dlcProducts.expiryDate, today as any), - lte(dlcProducts.expiryDate, in15Days as any), - ne(dlcProducts.status, 'valides'), - or( - isNull(dlcProducts.processedUntilExpiry), - eq(dlcProducts.processedUntilExpiry, false) - ) - ) - ); - } else if (filters.status === 'expires') { - // Products already expired - // Ne pas afficher les produits déjà traités (processedUntilExpiry = true) - conditions.push( - and( - lte(dlcProducts.expiryDate, today as any), - ne(dlcProducts.status, 'valides'), - or( - isNull(dlcProducts.processedUntilExpiry), - eq(dlcProducts.processedUntilExpiry, false) - ) - ) - ); - } else if (filters.status === 'en_cours') { - // Products active (more than 15 days until expiry) - const in15Days = new Date(); - in15Days.setDate(today.getDate() + 15); - in15Days.setHours(23, 59, 59, 999); - conditions.push( - and( - gt(dlcProducts.expiryDate, in15Days as any), - ne(dlcProducts.status, 'valides') - ) - ); - } else if (filters.status === 'valides') { - // Products that are validated regardless of date - conditions.push(eq(dlcProducts.status, 'valides')); - } - } - - if (filters.supplierId) { - conditions.push(eq(dlcProducts.supplierId, filters.supplierId)); - } - if (filters.search) { - conditions.push( - or( - sql`LOWER(${dlcProducts.productName}) LIKE LOWER(${'%' + filters.search + '%'})`, - sql`LOWER(${suppliers.name}) LIKE LOWER(${'%' + filters.search + '%'})` - ) - ); - } - } - - if (conditions.length > 0) { - query = query.where(and(...conditions)); - } - - // Tri intelligent : produits actifs (non grisés) EN PREMIER, puis lignes grisées en dernier - const results = await query.orderBy( - // Priorité : Actifs → Traités → Stock épuisé → Validés - sql`CASE - WHEN ${dlcProducts.status} != 'valides' - AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) - AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) THEN 1 - WHEN ${dlcProducts.processedUntilExpiry} = true THEN 2 - WHEN ${dlcProducts.stockEpuise} = true THEN 3 - WHEN ${dlcProducts.status} = 'valides' THEN 4 - ELSE 1 - END`, - // Priorité 2 : Date d'expiration (croissante) - asc(dlcProducts.expiryDate) - ); - - return results.map((row: any) => ({ - ...row.dlcProduct, - dlcDate: row.dlcProduct.expiryDate, // ✅ Mapping expiryDate vers dlcDate pour frontend - supplier: row.supplier, - group: row.group, - creator: row.creator || { id: row.dlcProduct.createdBy, username: 'unknown' } - })); - } - - async getDlcProduct(id: number): Promise { - const [result] = await db - .select({ - dlcProduct: dlcProducts, - supplier: suppliers, - group: groups - }) - .from(dlcProducts) - .leftJoin(suppliers, eq(dlcProducts.supplierId, suppliers.id)) - .leftJoin(groups, eq(dlcProducts.groupId, groups.id)) - .where(eq(dlcProducts.id, id)); - - if (!result) return undefined; - return { - ...result.dlcProduct, - dlcDate: result.dlcProduct.expiryDate, // ✅ Mapping expiryDate vers dlcDate pour frontend - supplier: result.supplier, - group: result.group, - creator: result.creator || { id: result.dlcProduct.createdBy, username: 'unknown' } - }; - } - - async createDlcProduct(productData: InsertDlcProduct): Promise { - const [dlcProduct] = await db.insert(dlcProducts).values(productData).returning(); - return { ...dlcProduct, dlcDate: new Date(dlcProduct.expiryDate) } as DlcProductFrontend; - } - - async updateDlcProduct(id: number, productData: Partial): Promise { - const [dlcProduct] = await db - .update(dlcProducts) - .set({ ...productData, updatedAt: new Date() }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - - async deleteDlcProduct(id: number): Promise { - await db.delete(dlcProducts).where(eq(dlcProducts.id, id)); - } - - async validateDlcProduct(id: number, validatedBy: string): Promise { - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - validatedBy, - validatedAt: new Date(), - status: "valides", - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - - async markDlcProductStockEpuise(id: number, markedBy: string): Promise { - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - stockEpuise: true, - stockEpuiseBy: markedBy, - stockEpuiseAt: new Date(), - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - - async restoreDlcProductStock(id: number): Promise { - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - stockEpuise: false, - stockEpuiseBy: null, - stockEpuiseAt: null, - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - - async markDlcProductAsProcessed(id: number, processedBy: string): Promise { - try { - // Essayer avec les nouveaux champs si ils existent - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - processedAt: new Date(), - processedBy: processedBy, - processedUntilExpiry: true, - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } catch (error) { - // Fallback : juste mettre à jour la date de modification - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - } - - async unmarkDlcProductAsProcessed(id: number): Promise { - try { - // Essayer avec les nouveaux champs si ils existent - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - processedAt: null, - processedBy: null, - processedUntilExpiry: false, - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } catch (error) { - // Fallback : juste mettre à jour la date de modification - const [dlcProduct] = await db - .update(dlcProducts) - .set({ - updatedAt: new Date() - }) - .where(eq(dlcProducts.id, id)) - .returning(); - return dlcProduct as DlcProductFrontend; - } - } - - async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { - const today = new Date(); - const alertDate = new Date(); - alertDate.setDate(today.getDate() + 15); - - let whereCondition = sql`1 = 1`; - if (groupIds && groupIds.length > 0) { - whereCondition = inArray(dlcProducts.groupId, groupIds); - } - - // IMPORTANT: Filtrer sur processedUntilExpiry pour ne pas réafficher les produits déjà traités - // Les produits "traités" (processedUntilExpiry=true) ne doivent pas apparaître dans les alertes quotidiennes - const [stats] = await db - .select({ - active: sql`COUNT(CASE - WHEN ${dlcProducts.expiryDate} > ${today.toISOString().split('T')[0]} - AND ${dlcProducts.status} != 'valides' - AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) - AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) - THEN 1 END)`, - expiringSoon: sql`COUNT(CASE - WHEN ${dlcProducts.expiryDate} BETWEEN ${today.toISOString().split('T')[0]} AND ${alertDate.toISOString().split('T')[0]} - AND ${dlcProducts.status} != 'valides' - AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) - AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) - THEN 1 END)`, - expired: sql`COUNT(CASE - WHEN ${dlcProducts.expiryDate} <= ${today.toISOString().split('T')[0]} - AND ${dlcProducts.status} != 'valides' - AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) - AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) - THEN 1 END)` - }) - .from(dlcProducts) - .where(whereCondition); - - return { - active: stats.active || 0, - expiringSoon: stats.expiringSoon || 0, - expired: stats.expired || 0 - }; - } - - async getTasks(groupIds?: number[], userRole?: string): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - let query = db - .select({ - task: tasks, - group: groupSummaryColumns - }) - .from(tasks) - .leftJoin(groups, eq(tasks.groupId, groups.id)); - - // Appliquer le filtrage par magasin si spécifié - let whereConditions = []; - if (groupIds && groupIds.length > 0) { - whereConditions.push(inArray(tasks.groupId, groupIds)); - } - - // Filtrage par rôle pour la date de départ - if (userRole === 'manager' || userRole === 'employee') { - // Managers et employés : seulement les tâches dont la date de départ est atteinte ou passée - whereConditions.push( - or( - isNull(tasks.startDate), // Tâches sans date de départ (toujours visibles) - lte(tasks.startDate, today) // Tâches dont la date de départ est arrivée - ) - ); - } - // Admin et directeur voient toutes les tâches, y compris les tâches programmées - - if (whereConditions.length > 0) { - query = query.where(and(...whereConditions)); - } - - const results = await query.orderBy(desc(tasks.createdAt)); - - return results - .filter((row: any) => row.task) // Filtrer les tâches nulles - .map((row: any) => { - try { - const task = row.task; - const group = row.group || null; - - // Calculer si la tâche est future de manière sécurisée - let isFutureTask = false; - if (task.startDate) { - try { - const startDate = new Date(task.startDate); - if (!isNaN(startDate.getTime())) { - isFutureTask = startDate > today; - } - } catch (error) { - console.warn('Error parsing startDate:', error, task.startDate); - } - } - - return { - ...task, - group, - isFutureTask - }; - } catch (error) { - console.error('Error processing task row:', error, row); - return null; - } - }) - .filter(Boolean); - } - - async getTask(id: number): Promise { - const [result] = await db - .select({ - task: tasks, - group: groups - }) - .from(tasks) - .leftJoin(groups, eq(tasks.groupId, groups.id)) - .where(eq(tasks.id, id)); - - if (!result) return undefined; - return { - ...result.task, - group: result.group - }; - } - - async createTask(taskData: InsertTask): Promise { - console.log('💾 DatabaseStorage.createTask - Input:', { - taskData, - dueDate: taskData.dueDate, - dueDateType: typeof taskData.dueDate, - dueDateISO: taskData.dueDate instanceof Date ? taskData.dueDate.toISOString() : taskData.dueDate - }); - - // Convert string dates to Date objects for Drizzle PostgreSQL - const processedTaskData = { - ...taskData, - startDate: taskData.startDate ? new Date(taskData.startDate) : null, - dueDate: taskData.dueDate ? new Date(taskData.dueDate) : null, - }; - - const [task] = await db.insert(tasks).values(processedTaskData).returning(); - - console.log('💾 DatabaseStorage.createTask - Result:', { - taskId: task.id, - title: task.title, - dueDate: task.dueDate, - dueDateType: typeof task.dueDate - }); - - return task; - } - - async updateTask(id: number, taskData: Partial): Promise { - console.log('💾 DatabaseStorage.updateTask - Input:', { - taskId: id, - taskData, - dueDate: taskData.dueDate, - dueDateType: typeof taskData.dueDate, - dueDateISO: taskData.dueDate instanceof Date ? taskData.dueDate.toISOString() : taskData.dueDate - }); - - // Validation des données avant update - if (!taskData || typeof taskData !== 'object') { - throw new Error('Invalid task data provided'); - } - - // Nettoyer les données pour éviter les problèmes de type - const cleanData: any = {}; - - if (taskData.title !== undefined) cleanData.title = taskData.title; - if (taskData.description !== undefined) cleanData.description = taskData.description; - if (taskData.priority !== undefined) cleanData.priority = taskData.priority; - if (taskData.status !== undefined) cleanData.status = taskData.status; - if (taskData.assignedTo !== undefined) cleanData.assignedTo = taskData.assignedTo; - - // Gestion spéciale des dates selon les types PostgreSQL - if (taskData.startDate !== undefined) { - if ((taskData.startDate as any) === '' || taskData.startDate === null) { - cleanData.startDate = null; - } else { - // start_date est un timestamp en PostgreSQL - on peut passer une date - cleanData.startDate = new Date(taskData.startDate); - } - } - - if (taskData.dueDate !== undefined) { - if ((taskData.dueDate as any) === '' || taskData.dueDate === null) { - cleanData.dueDate = null; - } else { - // due_date est un timestamp en PostgreSQL - convertir en Date object - cleanData.dueDate = new Date(taskData.dueDate); - } - } - - cleanData.updatedAt = new Date(); - - console.log('🧹 Cleaned data for database update:', { - ...cleanData, - startDateType: cleanData.startDate ? typeof cleanData.startDate : 'null', - dueDateType: cleanData.dueDate ? typeof cleanData.dueDate : 'null', - startDateIsDate: cleanData.startDate instanceof Date, - }); - - try { - const [task] = await db - .update(tasks) - .set(cleanData) - .where(eq(tasks.id, id)) - .returning(); - - console.log('✅ DatabaseStorage.updateTask - Success:', { - taskId: task.id, - title: task.title, - dueDate: task.dueDate, - dueDateType: typeof task.dueDate - }); - - return task; - } catch (error) { - console.error('❌ DatabaseStorage.updateTask - Database Error:', { - taskId: id, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - cleanData, - originalTaskData: taskData - }); - throw new Error(`Failed to update task in database: ${error instanceof Error ? error.message : String(error)}`); - } - } - - async deleteTask(id: number): Promise { - await db.delete(tasks).where(eq(tasks.id, id)); - } - - async completeTask(id: number, completedBy: string): Promise { - await db - .update(tasks) - .set({ - status: 'completed', - completedAt: new Date(), - completedBy, - updatedAt: new Date() - }) - .where(eq(tasks.id, id)); - } - - // Announcement operations - async createAnnouncement(announcementData: InsertAnnouncement): Promise { - const announcementStorage = getAnnouncementStorage( - () => this.getUsers(), - () => this.getGroups() - ); - return await announcementStorage.createAnnouncement(announcementData); - } - - async getAnnouncements(groupIds?: number[]): Promise { - const announcementStorage = getAnnouncementStorage( - () => this.getUsers(), - () => this.getGroups() - ); - return await announcementStorage.getAnnouncements(groupIds); - } - - async getAnnouncement(id: number): Promise { - const announcementStorage = getAnnouncementStorage( - () => this.getUsers(), - () => this.getGroups() - ); - return await announcementStorage.getAnnouncement(id); - } - - async updateAnnouncement(id: number, announcementData: Partial): Promise { - const announcementStorage = getAnnouncementStorage( - () => this.getUsers(), - () => this.getGroups() - ); - return await announcementStorage.updateAnnouncement(id, announcementData); - } - - async deleteAnnouncement(id: number): Promise { - const announcementStorage = getAnnouncementStorage( - () => this.getUsers(), - () => this.getGroups() - ); - return await announcementStorage.deleteAnnouncement(id); - } - - // Avoir operations - async getAvoirs(groupIds?: number[]): Promise { - let query = db - .select({ - id: avoirs.id, - supplierId: avoirs.supplierId, - groupId: avoirs.groupId, - invoiceReference: avoirs.invoiceReference, - amount: avoirs.amount, - comment: avoirs.comment, - commercialProcessed: avoirs.commercialProcessed, - status: avoirs.status, - webhookSent: avoirs.webhookSent, - nocodbVerified: avoirs.nocodbVerified, - nocodbVerifiedAt: avoirs.nocodbVerifiedAt, - processedAt: avoirs.processedAt, - createdBy: avoirs.createdBy, - createdAt: avoirs.createdAt, - updatedAt: avoirs.updatedAt, - supplier: suppliers, - group: groupReconciliationColumns, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(avoirs) - .leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id)) - .leftJoin(groups, eq(avoirs.groupId, groups.id)) - .leftJoin(users, eq(avoirs.createdBy, users.id)); - - if (groupIds && groupIds.length > 0) { - query = query.where(inArray(avoirs.groupId, groupIds)); - } - - const results = await query.orderBy(desc(avoirs.createdAt)); - - return results.map((result: any) => ({ - ...result, - supplier: result.supplier!, - group: result.group!, - creator: result.creator!, - })); - } - - async getAvoir(id: number): Promise { - const [result] = await db - .select({ - id: avoirs.id, - supplierId: avoirs.supplierId, - groupId: avoirs.groupId, - invoiceReference: avoirs.invoiceReference, - amount: avoirs.amount, - comment: avoirs.comment, - commercialProcessed: avoirs.commercialProcessed, - status: avoirs.status, - webhookSent: avoirs.webhookSent, - nocodbVerified: avoirs.nocodbVerified, - nocodbVerifiedAt: avoirs.nocodbVerifiedAt, - processedAt: avoirs.processedAt, - createdBy: avoirs.createdBy, - createdAt: avoirs.createdAt, - updatedAt: avoirs.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(avoirs) - .leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id)) - .leftJoin(groups, eq(avoirs.groupId, groups.id)) - .leftJoin(users, eq(avoirs.createdBy, users.id)) - .where(eq(avoirs.id, id)); - - if (!result) return undefined; - - return { - ...result, - supplier: result.supplier!, - group: result.group!, - creator: result.creator!, - }; - } - - async createAvoir(avoirData: InsertAvoir): Promise { - const [avoir] = await db.insert(avoirs).values(avoirData).returning(); - return avoir; - } - - async updateAvoir(id: number, avoirData: Partial): Promise { - const [avoir] = await db - .update(avoirs) - .set({ ...avoirData, updatedAt: new Date() }) - .where(eq(avoirs.id, id)) - .returning(); - return avoir; - } - - async deleteAvoir(id: number): Promise { - await db.delete(avoirs).where(eq(avoirs.id, id)); - } - - async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise { - await db - .update(avoirs) - .set({ webhookSent, updatedAt: new Date() }) - .where(eq(avoirs.id, id)); - } - - async updateAvoirNocodbVerification(id: number, verified: boolean): Promise { - await db - .update(avoirs) - .set({ - nocodbVerified: verified, - nocodbVerifiedAt: verified ? new Date() : null, - updatedAt: new Date() - }) - .where(eq(avoirs.id, id)); - } - - // SAV operations - async getSavTickets(filters?: { - groupIds?: number[]; - status?: string; - supplierId?: number; - priority?: string; - startDate?: string; - endDate?: string; - }): Promise { - let query = db - .select({ - ticket: savTickets, - supplier: suppliers, - group: groupSummaryColumns, - creator: userSummaryColumns, - }) - .from(savTickets) - .leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id)) - .leftJoin(groups, eq(savTickets.groupId, groups.id)) - .leftJoin(users, eq(savTickets.createdBy, users.id)); - - const conditions = []; - - if (filters?.groupIds?.length) { - conditions.push(inArray(savTickets.groupId, filters.groupIds)); - } - if (filters?.status) { - conditions.push(eq(savTickets.status, filters.status)); - } - if (filters?.supplierId) { - conditions.push(eq(savTickets.supplierId, filters.supplierId)); - } - if (filters?.priority) { - conditions.push(eq(savTickets.priority, filters.priority)); - } - if (filters?.startDate) { - conditions.push(gte(savTickets.createdAt, new Date(filters.startDate))); - } - if (filters?.endDate) { - conditions.push(lte(savTickets.createdAt, new Date(filters.endDate))); - } - - if (conditions.length) { - query = query.where(and(...conditions)); - } - - const results = await query.orderBy(desc(savTickets.createdAt)); - - // L'historique SAV est désactivé (getSavTicketHistory renvoie toujours []) : - // pas d'appel par ticket. Si on le réactive, le charger en une seule - // requête groupée plutôt que ticket par ticket. - return results.map((result: any) => ({ - ...result.ticket, - supplier: result.supplier!, - group: result.group!, - creator: result.creator!, - history: [], - })); - } - - async getSavTicket(id: number): Promise { - const [result] = await db - .select({ - ticket: savTickets, - supplier: suppliers, - group: groups, - creator: userSummaryColumns, - }) - .from(savTickets) - .leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id)) - .leftJoin(groups, eq(savTickets.groupId, groups.id)) - .leftJoin(users, eq(savTickets.createdBy, users.id)) - .where(eq(savTickets.id, id)); - - if (!result) return undefined; - - const history = await this.getSavTicketHistory(id); - - return { - ...result.ticket, - supplier: result.supplier!, - group: result.group!, - creator: result.creator!, - history, - }; - } - - async createSavTicket(ticketData: InsertSavTicket): Promise { - // Generate ticket number - const currentYear = new Date().getFullYear(); - // Bornes de l'année plutôt qu'EXTRACT(year ...), pour que l'index sur created_at serve - const count = await db - .select({ count: sql`count(*)` }) - .from(savTickets) - .where(sql`${savTickets.createdAt} >= make_date(${currentYear}::int, 1, 1) AND ${savTickets.createdAt} < make_date(${currentYear + 1}::int, 1, 1)`); - - const ticketNumber = `SAV-${currentYear}-${String((count[0]?.count || 0) + 1).padStart(4, '0')}`; - - const [ticket] = await db - .insert(savTickets) - .values({ ...ticketData, ticketNumber }) - .returning(); - - // Add initial history entry - // await this.addSavTicketHistory({ - // ticketId: ticket.id, - // action: 'created', - // description: `Ticket créé avec le statut "${ticket.status}"`, - // createdBy: ticket.createdBy, - // }); - - return ticket; - } - - async updateSavTicket(id: number, ticketData: Partial): Promise { - const [ticket] = await db - .update(savTickets) - .set({ ...ticketData, updatedAt: new Date() }) - .where(eq(savTickets.id, id)) - .returning(); - - return ticket; - } - - async deleteSavTicket(id: number): Promise { - // Delete history first - // await db.delete(savTicketHistory).where(eq(savTicketHistory.ticketId, id)); - // Delete ticket - await db.delete(savTickets).where(eq(savTickets.id, id)); - } - - async getSavTicketHistory(ticketId: number): Promise { - // const results = await db - // .select({ - // history: savTicketHistory, - // creator: users, - // }) - // .from(savTicketHistory) - // .leftJoin(users, eq(savTicketHistory.createdBy, users.id)) - // .where(eq(savTicketHistory.ticketId, ticketId)) - // .orderBy(desc(savTicketHistory.createdAt)); - - // return results.map(result => ({ - // ...result.history, - // creator: result.creator!, - // })); - return []; - } - - async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise { - // const [history] = await db - // .insert(savTicketHistory) - // .values(historyData) - // .returning(); - - // return history; - return null; - } - - async getSavTicketStats(groupIds?: number[]): Promise<{ - totalTickets: number; - newTickets: number; - inProgressTickets: number; - resolvedTickets: number; - criticalTickets: number; - }> { - // Tous les compteurs en une seule requête. « Critiques » compte tous les - // tickets de priorité critique, quel que soit leur statut. - const [result] = await db - .select({ - total: sql`count(*)`, - newCount: sql`count(*) filter (where ${savTickets.status} = 'nouveau')`, - inProgressCount: sql`count(*) filter (where ${savTickets.status} in ('en_cours', 'attente_pieces', 'attente_echange'))`, - resolvedCount: sql`count(*) filter (where ${savTickets.status} in ('resolu', 'ferme'))`, - criticalCount: sql`count(*) filter (where ${savTickets.priority} = 'critique')` - }) - .from(savTickets) - .where(groupIds?.length ? inArray(savTickets.groupId, groupIds) : undefined); - - return { - totalTickets: Number(result?.total || 0), - newTickets: Number(result?.newCount || 0), - inProgressTickets: Number(result?.inProgressCount || 0), - resolvedTickets: Number(result?.resolvedCount || 0), - criticalTickets: Number(result?.criticalCount || 0), - }; - } - - // Weather operations - async getWeatherSettings(): Promise { - const [settings] = await db.select().from(weatherSettings).limit(1); - return settings; - } - - async createWeatherSettings(settingsData: InsertWeatherSettings): Promise { - const [settings] = await db.insert(weatherSettings).values(settingsData).returning(); - return settings; - } - - async updateWeatherSettings(id: number, settingsData: Partial): Promise { - const [settings] = await db - .update(weatherSettings) - .set({ ...settingsData, updatedAt: new Date() }) - .where(eq(weatherSettings.id, id)) - .returning(); - return settings; - } - - async getWeatherData(date: string, isCurrentYear: boolean): Promise { - const [data] = await db - .select() - .from(weatherData) - .where(and( - eq(weatherData.date, date), - eq(weatherData.isCurrentYear, isCurrentYear) - )) - .limit(1); - return data; - } - - async createWeatherData(data: InsertWeatherData): Promise { - const [weatherInfo] = await db.insert(weatherData).values(data).returning(); - return weatherInfo; - } - - // Repli : ligne en cache la plus proche de la date demandée (à N jours près). - // Utilisé quand l'API météo historique échoue (quota, panne) pour continuer - // d'afficher la comparaison avec l'année dernière. - async getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise { - const [data] = await db - .select() - .from(weatherData) - .where(and( - eq(weatherData.isCurrentYear, isCurrentYear), - sql`ABS(${weatherData.date} - ${date}::date) <= ${maxDistanceDays}` - )) - .orderBy(sql`ABS(${weatherData.date} - ${date}::date)`) - .limit(1); - return data; - } - - async updateWeatherData(id: number, data: Partial): Promise { - const [weatherInfo] = await db - .update(weatherData) - .set({ ...data, updatedAt: new Date() }) - .where(eq(weatherData.id, id)) - .returning(); - return weatherInfo; - } - - async deleteOldWeatherData(daysToKeep: number): Promise { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); - - await db - .delete(weatherData) - .where(lt(weatherData.createdAt, cutoffDate)); - - console.log(`🧹 Deleted weather data older than ${daysToKeep} days`); - } - - async clearWeatherCache(): Promise { - await db.delete(weatherData); - console.log('🧹 Weather cache cleared due to location change'); - } - - // Historique des mails fournisseurs (rapprochement) - async createSupplierMailLog(logData: InsertSupplierMailLog): Promise { - const [log] = await db.insert(supplierMailLogs).values(logData).returning(); - return log; - } - - async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise { - const conditions = []; - if (groupIds && groupIds.length > 0) { - conditions.push(inArray(supplierMailLogs.groupId, groupIds)); - } - if (deliveryId !== undefined) { - conditions.push(eq(supplierMailLogs.deliveryId, deliveryId)); - } - - let query = db.select().from(supplierMailLogs).$dynamic(); - if (conditions.length > 0) { - query = query.where(and(...conditions)); - } - // Borné : la page de rapprochement n'a besoin que de l'historique récent - return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500); - } - - // Webhook BAP Configuration - async getWebhookBapConfig(): Promise { - const [config] = await db.select().from(webhookBapConfig).limit(1); - return config; - } - - async createWebhookBapConfig(configData: InsertWebhookBapConfig): Promise { - const [config] = await db - .insert(webhookBapConfig) - .values(configData) - .returning(); - return config; - } - - async updateWebhookBapConfig(id: number, configData: Partial): Promise { - const [config] = await db - .update(webhookBapConfig) - .set({ ...configData, updatedAt: new Date() }) - .where(eq(webhookBapConfig.id, id)) - .returning(); - return config; - } - - // Utilities operations - async getUtilities(): Promise { - const [result] = await db.select().from(utilities).limit(1); - return result; - } - - async createUtilities(utilitiesData: InsertUtilities): Promise { - const [result] = await db.insert(utilities).values(utilitiesData).returning(); - return result; - } - - async updateUtilities(id: number, utilitiesData: Partial): Promise { - const [result] = await db - .update(utilities) - .set({ ...utilitiesData, updatedAt: new Date() }) - .where(eq(utilities.id, id)) - .returning(); - return result; - } - - // Reconciliation Comments operations - async getReconciliationComments(deliveryId: number): Promise { - const comments = await db - .select({ - id: reconciliationComments.id, - deliveryId: reconciliationComments.deliveryId, - groupId: reconciliationComments.groupId, - content: reconciliationComments.content, - type: reconciliationComments.type, - authorId: reconciliationComments.authorId, - createdAt: reconciliationComments.createdAt, - updatedAt: reconciliationComments.updatedAt, - author: { ...userSummaryColumns, role: users.role }, - group: groupSummaryColumns, - delivery: { - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - quantity: deliveries.quantity, - unit: deliveries.unit, - status: deliveries.status, - notes: deliveries.notes, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - } - }) - .from(reconciliationComments) - .leftJoin(users, eq(reconciliationComments.authorId, users.id)) - .leftJoin(groups, eq(reconciliationComments.groupId, groups.id)) - .leftJoin(deliveries, eq(reconciliationComments.deliveryId, deliveries.id)) - .where(eq(reconciliationComments.deliveryId, deliveryId)) - .orderBy(desc(reconciliationComments.createdAt)); - - return comments.map((comment: any) => ({ - ...comment, - delivery: { - ...comment.delivery, - supplier: {} as any, // Will be filled by relations if needed - group: comment.group!, - creator: {} as any, // Will be filled by relations if needed - } - })) as ReconciliationCommentWithRelations[]; - } - - async getReconciliationCommentById(id: number): Promise { - const [comment] = await db - .select({ - id: reconciliationComments.id, - deliveryId: reconciliationComments.deliveryId, - groupId: reconciliationComments.groupId, - content: reconciliationComments.content, - type: reconciliationComments.type, - authorId: reconciliationComments.authorId, - createdAt: reconciliationComments.createdAt, - updatedAt: reconciliationComments.updatedAt, - author: { ...userSummaryColumns, role: users.role }, - group: groups, - delivery: { - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - quantity: deliveries.quantity, - unit: deliveries.unit, - status: deliveries.status, - notes: deliveries.notes, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - } - }) - .from(reconciliationComments) - .leftJoin(users, eq(reconciliationComments.authorId, users.id)) - .leftJoin(groups, eq(reconciliationComments.groupId, groups.id)) - .leftJoin(deliveries, eq(reconciliationComments.deliveryId, deliveries.id)) - .where(eq(reconciliationComments.id, id)); - - if (!comment) return undefined; - - return { - ...comment, - delivery: { - ...comment.delivery, - supplier: {} as any, // Will be filled by relations if needed - group: comment.group!, - creator: {} as any, // Will be filled by relations if needed - } - } as ReconciliationCommentWithRelations; - } - - async createReconciliationComment(commentData: InsertReconciliationComment): Promise { - const [comment] = await db - .insert(reconciliationComments) - .values(commentData) - .returning(); - return comment; - } - - async updateReconciliationComment(id: number, commentData: Partial): Promise { - const [comment] = await db - .update(reconciliationComments) - .set({ ...commentData, updatedAt: new Date() }) - .where(eq(reconciliationComments.id, id)) - .returning(); - return comment; - } - - async deleteReconciliationComment(id: number): Promise { - await db - .delete(reconciliationComments) - .where(eq(reconciliationComments.id, id)); - } - - // Analytics implementations - async getAnalyticsSummary(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - status?: string[]; - }): Promise<{ - totalOrders: number; - totalDeliveries: number; - reconciliationRate: number; - totalAmount: number; - avgDeliveryDelay: number; - topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; - topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; - }> { - try { - const conditions: any[] = []; - const orderConditions: any[] = []; - const deliveryConditions: any[] = []; - - // Apply filters - if (filters.startDate) { - const startDateStr = filters.startDate.toISOString().split('T')[0]; - orderConditions.push(gte(orders.plannedDate, startDateStr)); - deliveryConditions.push(gte(deliveries.scheduledDate, startDateStr)); - } - if (filters.endDate) { - const endDateStr = filters.endDate.toISOString().split('T')[0]; - orderConditions.push(lte(orders.plannedDate, endDateStr)); - deliveryConditions.push(lte(deliveries.scheduledDate, endDateStr)); - } - if (filters.supplierIds?.length) { - orderConditions.push(inArray(orders.supplierId, filters.supplierIds)); - deliveryConditions.push(inArray(deliveries.supplierId, filters.supplierIds)); - } - if (filters.groupIds?.length) { - orderConditions.push(inArray(orders.groupId, filters.groupIds)); - deliveryConditions.push(inArray(deliveries.groupId, filters.groupIds)); - } - if (filters.status?.length) { - orderConditions.push(inArray(orders.status, filters.status)); - deliveryConditions.push(inArray(deliveries.status, filters.status)); - } - - // Get total orders - const orderQuery = db.select({ count: sql`COUNT(*)` }).from(orders); - if (orderConditions.length) orderQuery.where(and(...orderConditions)); - - // Get total deliveries and amounts - const deliveryQuery = db.select({ - count: sql`COUNT(*)`, - totalAmount: sql`COALESCE(SUM(CAST(bl_amount AS NUMERIC)), 0) + COALESCE(SUM(CAST(invoice_amount AS NUMERIC)), 0)`, - reconciled: sql`COUNT(CASE WHEN reconciled = true THEN 1 END)`, - avgDelay: sql`AVG(EXTRACT(EPOCH FROM (delivered_date - scheduled_date)) / 86400)` // days - }).from(deliveries); - if (deliveryConditions.length) deliveryQuery.where(and(...deliveryConditions)); - - // Get top suppliers - const supplierQuery = db.select({ - id: suppliers.id, - name: suppliers.name, - count: sql`COUNT(${deliveries.id})`, - amount: sql`COALESCE(SUM(CAST(${deliveries.blAmount} AS NUMERIC)), 0)` - }) - .from(deliveries) - .innerJoin(suppliers, eq(deliveries.supplierId, suppliers.id)); - - if (deliveryConditions.length) supplierQuery.where(and(...deliveryConditions)); - - // Get top stores : commandes et livraisons comptées séparément par magasin - // puis jointes, au lieu d'une jointure orders × deliveries qui multipliait - // les lignes. Comme avant, seul le filtre magasin s'applique ici. - const storeGroupIds = filters.groupIds?.length ? filters.groupIds : null; - const storeQuery = db.execute(sql` - SELECT - g.id AS id, - g.name AS name, - COALESCE(o.count, 0) AS orders, - COALESCE(d.count, 0) AS deliveries - FROM groups g - LEFT JOIN ( - SELECT group_id, COUNT(*) AS count - FROM orders - ${storeGroupIds ? sql`WHERE group_id IN ${storeGroupIds}` : sql``} - GROUP BY group_id - ) o ON o.group_id = g.id - LEFT JOIN ( - SELECT group_id, COUNT(*) AS count - FROM deliveries - ${storeGroupIds ? sql`WHERE group_id IN ${storeGroupIds}` : sql``} - GROUP BY group_id - ) d ON d.group_id = g.id - ${storeGroupIds ? sql`WHERE g.id IN ${storeGroupIds}` : sql``} - ORDER BY COALESCE(o.count, 0) + COALESCE(d.count, 0) DESC - LIMIT 5 - `); - - // Les quatre requêtes sont indépendantes : on les lance en parallèle - const [[{ count: totalOrders }], [deliveryStats], topSuppliers, storeResult] = await Promise.all([ - orderQuery, - deliveryQuery, - supplierQuery - .groupBy(suppliers.id, suppliers.name) - .orderBy(desc(sql`COUNT(${deliveries.id})`)) - .limit(5), - storeQuery - ]); - const topStores = storeResult.rows; - - return { - totalOrders: Number(totalOrders) || 0, - totalDeliveries: Number(deliveryStats.count) || 0, - reconciliationRate: deliveryStats.count ? (Number(deliveryStats.reconciled) / Number(deliveryStats.count)) * 100 : 0, - totalAmount: Number(deliveryStats.totalAmount) || 0, - avgDeliveryDelay: Number(deliveryStats.avgDelay) || 0, - topSuppliers: topSuppliers.map((s: any) => ({ - id: s.id, - name: s.name, - count: Number(s.count), - amount: Number(s.amount) - })), - topStores: topStores.map((s: any) => ({ - id: s.id, - name: s.name, - orders: Number(s.orders), - deliveries: Number(s.deliveries) - })) - }; - } catch (error) { - console.error('Analytics summary error:', error); - throw error; - } - } - - async getAnalyticsTimeseries(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - granularity?: 'day' | 'week' | 'month'; - }): Promise> { - const granularity = filters.granularity || 'day'; - const dateFormat = granularity === 'day' ? 'YYYY-MM-DD' : - granularity === 'week' ? 'YYYY-IW' : 'YYYY-MM'; - - // Build WHERE clauses with direct value substitution - const orderConditions: string[] = []; - const deliveryConditions: string[] = []; - - if (filters.startDate) { - const startDate = filters.startDate.toISOString().split('T')[0]; - orderConditions.push(`planned_date >= '${startDate}'`); - deliveryConditions.push(`scheduled_date >= '${startDate}'`); - } - if (filters.endDate) { - const endDate = filters.endDate.toISOString().split('T')[0]; - orderConditions.push(`planned_date <= '${endDate}'`); - deliveryConditions.push(`scheduled_date <= '${endDate}'`); - } - if (filters.supplierIds?.length) { - const supplierIds = filters.supplierIds.join(','); - orderConditions.push(`supplier_id IN (${supplierIds})`); - deliveryConditions.push(`supplier_id IN (${supplierIds})`); - } - if (filters.groupIds?.length) { - const groupIds = filters.groupIds.join(','); - orderConditions.push(`group_id IN (${groupIds})`); - deliveryConditions.push(`group_id IN (${groupIds})`); - } - - const orderWhere = orderConditions.length > 0 ? `WHERE ${orderConditions.join(' AND ')}` : ''; - const deliveryWhere = deliveryConditions.length > 0 ? `WHERE ${deliveryConditions.join(' AND ')}` : ''; - - // Get orders by date using raw SQL - const ordersSql = ` - SELECT TO_CHAR(planned_date, '${dateFormat}') as date, COUNT(*) as count - FROM orders - ${orderWhere} - GROUP BY TO_CHAR(planned_date, '${dateFormat}') - ORDER BY TO_CHAR(planned_date, '${dateFormat}') - `; - - // Get deliveries by date using raw SQL - const deliveriesSql = ` - SELECT TO_CHAR(scheduled_date, '${dateFormat}') as date, COUNT(*) as count - FROM deliveries - ${deliveryWhere} - GROUP BY TO_CHAR(scheduled_date, '${dateFormat}') - ORDER BY TO_CHAR(scheduled_date, '${dateFormat}') - `; - - // Les deux séries sont indépendantes : on les charge en parallèle - const [ordersData, deliveriesData] = await Promise.all([ - db.execute(sql.raw(ordersSql)), - db.execute(sql.raw(deliveriesSql)) - ]); - - // Merge data - const dataMap = new Map(); - (ordersData.rows as any[]).forEach(row => { - dataMap.set(row.date, { orders: Number(row.count), deliveries: 0 }); - }); - (deliveriesData.rows as any[]).forEach(row => { - const existing = dataMap.get(row.date) || { orders: 0, deliveries: 0 }; - dataMap.set(row.date, { ...existing, deliveries: Number(row.count) }); - }); - - return Array.from(dataMap.entries()) - .map(([date, data]) => ({ date, ...data })) - .sort((a, b) => a.date.localeCompare(b.date)); - } - - async getAnalyticsBySupplier(filters: { - startDate?: Date; - endDate?: Date; - groupIds?: number[]; - }): Promise> { - const conditions: any[] = []; - if (filters.startDate) { - const startDateStr = filters.startDate.toISOString().split('T')[0]; - conditions.push(gte(deliveries.scheduledDate, startDateStr)); - } - if (filters.endDate) { - const endDateStr = filters.endDate.toISOString().split('T')[0]; - conditions.push(lte(deliveries.scheduledDate, endDateStr)); - } - if (filters.groupIds?.length) conditions.push(inArray(deliveries.groupId, filters.groupIds)); - - const query = db.select({ - supplierId: suppliers.id, - supplierName: suppliers.name, - deliveries: sql`COUNT(${deliveries.id})`, - amount: sql`COALESCE(SUM(CAST(${deliveries.blAmount} AS NUMERIC)), 0)` - }) - .from(deliveries) - .innerJoin(suppliers, eq(deliveries.supplierId, suppliers.id)); - - if (conditions.length) query.where(and(...conditions)); - - const result = await query - .groupBy(suppliers.id, suppliers.name) - .orderBy(desc(sql`COUNT(${deliveries.id})`)); - - return result.map((row: any) => ({ - supplierId: row.supplierId, - supplierName: row.supplierName, - deliveries: Number(row.deliveries), - amount: Number(row.amount) - })); - } - - async getAnalyticsByStore(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - }): Promise> { - - try { - // Build date filter conditions as strings for raw SQL - let orderDateFilter = '1=1'; - let deliveryDateFilter = '1=1'; - let supplierFilter = '1=1'; - - if (filters.startDate) { - const startDate = filters.startDate.toISOString().split('T')[0]; - orderDateFilter += ` AND planned_date >= '${startDate}'`; - deliveryDateFilter += ` AND scheduled_date >= '${startDate}'`; - } - if (filters.endDate) { - const endDate = filters.endDate.toISOString().split('T')[0]; - orderDateFilter += ` AND planned_date <= '${endDate}'`; - deliveryDateFilter += ` AND scheduled_date <= '${endDate}'`; - } - if (filters.supplierIds?.length) { - const supplierIds = filters.supplierIds.join(','); - orderDateFilter += ` AND supplier_id IN (${supplierIds})`; - deliveryDateFilter += ` AND supplier_id IN (${supplierIds})`; - } - - const result = await db.execute(sql` - SELECT - g.id as "groupId", - g.name as "storeName", - COALESCE(o.count, 0) as orders, - COALESCE(d.count, 0) as deliveries - FROM groups g - LEFT JOIN ( - SELECT group_id, COUNT(*) as count - FROM orders - WHERE ${sql.raw(orderDateFilter)} - GROUP BY group_id - ) o ON g.id = o.group_id - LEFT JOIN ( - SELECT group_id, COUNT(*) as count - FROM deliveries - WHERE ${sql.raw(deliveryDateFilter)} - GROUP BY group_id - ) d ON g.id = d.group_id - ORDER BY (COALESCE(o.count, 0) + COALESCE(d.count, 0)) DESC - `); - - const mappedResult = result.rows.map((row: any) => ({ - groupId: row.groupId, - storeName: row.storeName, - orders: Number(row.orders), - deliveries: Number(row.deliveries) - })); - - - return mappedResult; - } catch (error) { - console.error('Error in getAnalyticsByStore:', error); - return []; - } - } -} - -// MemStorage class for development -export class MemStorage implements IStorage { - private users = new Map(); - private groups = new Map(); - private suppliers = new Map(); - private orders = new Map(); - private deliveries = new Map(); - private userGroups = new Map(); - private publicities = new Map(); - private publicityParticipations = new Map(); - private customerOrders = new Map(); - private dlcProducts = new Map(); - private tasks = new Map(); - private reconciliationComments = new Map(); - private announcements = new Map(); - private avoirs = new Map(); - private savTickets = new Map(); - private invoiceVerificationCache = new Map(); - private contactsMap = new Map(); - - private idCounters = { - group: 1, - supplier: 1, - order: 1, - delivery: 1, - publicity: 1, - customerOrder: 1, - dlcProduct: 1, - task: 1, - announcement: 1, - avoir: 1, - savTicket: 1, - reconciliationComment: 1, - contact: 1, - }; - - constructor() { - this.initializeTestData(); - this.initializeExtraTestData(); - } - - private initializeTestData() { - // Create admin user - const adminUser: User = { - id: 'admin', - username: 'admin', - email: 'admin@example.com', - name: 'Admin', - firstName: 'Admin', - lastName: 'User', - profileImageUrl: null, - password: 'admin', // In real app, this would be hashed - role: 'admin', - passwordChanged: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.users.set(adminUser.id, adminUser); - - // Initialize group counter - this.idCounters.group = 2; - - // Initialize supplier counter - this.idCounters.supplier = 2; - - // Create test groups - const testGroup: Group = { - id: 1, - name: 'Magasin Test', - color: '#3B82F6', - nocodbConfigId: 1, // Référence vers la config NocoDB de test - nocodbTableName: 'invoices', - nocodbTableId: 'mrr733dfb8wtt9b', // ID réel de la table NocoDB - invoiceColumnName: 'invoice_reference', - nocodbBlColumnName: 'bl_number', - nocodbAmountColumnName: 'amount', - nocodbSupplierColumnName: 'supplier', - webhookUrl: 'https://webhook.test/invoice-upload', - createdAt: new Date(), - updatedAt: new Date(), - }; - this.groups.set(testGroup.id, testGroup); - this.idCounters.group = 2; - - // Create test supplier - const testSupplier: Supplier = { - id: 1, - name: 'Fournisseur Test', - contact: 'test@supplier.com', - phone: '0123456789', - hasDlc: false, - automaticReconciliation: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.suppliers.set(testSupplier.id, testSupplier); - this.idCounters.supplier = 2; - - console.log('✅ MemStorage initialized with admin user (admin/admin)'); - } - - // User operations - async getUser(id: string): Promise { - return this.users.get(id); - } - - async getUserByEmail(email: string): Promise { - for (const user of this.users.values()) { - if (user.email === email) return user; - } - return undefined; - } - - async getUserByUsername(username: string): Promise { - for (const user of this.users.values()) { - if (user.username === username) return user; - } - return undefined; - } - - async upsertUser(userData: UpsertUser): Promise { - const existingUser = this.users.get(userData.id); - const user: User = { - id: userData.id, - username: userData.username, - email: userData.email || null, - name: userData.name || null, - firstName: userData.firstName || null, - lastName: userData.lastName || null, - profileImageUrl: userData.profileImageUrl || null, - password: userData.password || null, - role: userData.role || 'user', - passwordChanged: userData.passwordChanged || false, - createdAt: existingUser?.createdAt || new Date(), - updatedAt: new Date(), - }; - this.users.set(user.id, user); - return user; - } - - async getUserWithGroups(id: string): Promise { - const user = await this.getUser(id); - if (!user) return undefined; - - const userGroupsList = this.userGroups.get(id) || []; - const userGroupsWithGroups = userGroupsList.map(ug => ({ - ...ug, - group: this.groups.get(ug.groupId)!, - })); - - return { ...user, userGroups: userGroupsWithGroups }; - } - - async getUsersWithGroups(): Promise { - return Array.from(this.users.values()).map(({ password: _password, ...user }) => { - const userGroupsList = this.userGroups.get(user.id) || []; - return { - ...user, - userGroups: userGroupsList.map(ug => ({ - ...ug, - group: this.groups.get(ug.groupId)!, - })), - } as UserWithGroups; - }); - } - - async getUsers(): Promise { - return Array.from(this.users.values()); - } - - async createUser(userData: UpsertUser): Promise { - return this.upsertUser(userData); - } - - async updateUser(id: string, userData: Partial): Promise { - const existingUser = this.users.get(id); - if (!existingUser) throw new Error('User not found'); - - const updatedUser = { ...existingUser, ...userData, updatedAt: new Date() }; - this.users.set(id, updatedUser); - return updatedUser; - } - - async deleteUser(id: string): Promise { - this.users.delete(id); - this.userGroups.delete(id); - } - - // Group operations - async getGroups(): Promise { - return Array.from(this.groups.values()); - } - - async getGroup(id: number): Promise { - return this.groups.get(id); - } - - async createGroup(groupData: InsertGroup): Promise { - const id = this.idCounters.group++; - const group: Group = { - id, - name: groupData.name, - color: groupData.color || null, - nocodbConfigId: groupData.nocodbConfigId || null, - nocodbTableName: groupData.nocodbTableName || null, - nocodbTableId: groupData.nocodbTableId || null, - invoiceColumnName: groupData.invoiceColumnName || null, - nocodbBlColumnName: groupData.nocodbBlColumnName || null, - nocodbAmountColumnName: groupData.nocodbAmountColumnName || null, - nocodbSupplierColumnName: groupData.nocodbSupplierColumnName || null, - webhookUrl: groupData.webhookUrl || null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.groups.set(id, group); - return group; - } - - async updateGroup(id: number, groupData: Partial): Promise { - const existingGroup = this.groups.get(id); - if (!existingGroup) throw new Error('Group not found'); - - const updatedGroup = { ...existingGroup, ...groupData, updatedAt: new Date() }; - this.groups.set(id, updatedGroup); - return updatedGroup; - } - - async deleteGroup(id: number): Promise { - this.groups.delete(id); - } - - // Supplier operations - async getSuppliers(): Promise { - return Array.from(this.suppliers.values()); - } - - async createSupplier(supplierData: InsertSupplier): Promise { - const id = this.idCounters.supplier++; - const supplier: Supplier = { - id, - name: supplierData.name, - contact: supplierData.contact || null, - phone: supplierData.phone || null, - hasDlc: supplierData.hasDlc || null, - automaticReconciliation: supplierData.automaticReconciliation || null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.suppliers.set(id, supplier); - return supplier; - } - - async updateSupplier(id: number, supplierData: Partial): Promise { - const existingSupplier = this.suppliers.get(id); - if (!existingSupplier) throw new Error('Supplier not found'); - - const updatedSupplier = { ...existingSupplier, ...supplierData, updatedAt: new Date() }; - this.suppliers.set(id, updatedSupplier); - return updatedSupplier; - } - - async deleteSupplier(id: number): Promise { - this.suppliers.delete(id); - } - - // Contact operations - async getContacts(groupIds?: number[]): Promise { - const all = Array.from(this.contactsMap.values()); - if (groupIds && groupIds.length > 0) { - return all.filter(c => groupIds.includes(c.groupId)); - } - return all; - } - - async createContact(contactData: InsertContact): Promise { - const id = this.idCounters.contact++; - const contact: Contact = { - id, - groupId: contactData.groupId, - name: contactData.name, - company: contactData.company || null, - role: contactData.role || null, - phone: contactData.phone || null, - email: contactData.email || null, - notes: contactData.notes || null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.contactsMap.set(id, contact); - return contact; - } - - async updateContact(id: number, contactData: Partial): Promise { - const existing = this.contactsMap.get(id); - if (!existing) throw new Error('Contact not found'); - const updated = { ...existing, ...contactData, updatedAt: new Date() }; - this.contactsMap.set(id, updated); - return updated; - } - - async deleteContact(id: number): Promise { - this.contactsMap.delete(id); - } - - // NocoDB Configuration methods - async getNocodbConfigs(): Promise { - return []; // Empty array for development - } - - async getNocodbConfig(id: number): Promise { - return undefined; // Not found in development - } - - async getActiveNocodbConfig(): Promise { - // Configuration corrigée pour votre serveur NocoDB - const testConfig: NocodbConfig = { - id: 1, - name: 'Configuration Production NocoDB FFNancy', - baseUrl: 'https://nocodb.ffnancy.fr', // URL corrigée - projectId: 'pcg4uw79ukvycxc', // Project ID de vos logs - apiToken: 'xc-token-production-invoice-system', - description: 'Configuration de production pour vérification des factures FFNancy', - isActive: true, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - console.log('🔧 Configuration NocoDB active:', { - name: testConfig.name, - baseUrl: testConfig.baseUrl, - projectId: testConfig.projectId, - hasToken: !!testConfig.apiToken - }); - return testConfig; - } - - async createNocodbConfig(config: InsertNocodbConfig): Promise { - const newConfig: NocodbConfig = { - id: 1, - name: config.name, - baseUrl: config.baseUrl, - projectId: config.projectId, - apiToken: config.apiToken, - description: config.description || null, - isActive: config.isActive ?? true, - createdBy: config.createdBy || null, - createdAt: new Date(), - updatedAt: new Date(), - }; - return newConfig; - } - - async updateNocodbConfig(id: number, config: Partial): Promise { - const updatedConfig: NocodbConfig = { - id, - name: config.name || 'Test Config', - baseUrl: config.baseUrl || 'https://nocodb.example.com', - projectId: config.projectId || 'test-project', - apiToken: config.apiToken || 'test-token', - description: config.description || null, - isActive: config.isActive ?? true, - createdBy: config.createdBy || null, - createdAt: new Date(), - updatedAt: new Date(), - }; - return updatedConfig; - } - - async deleteNocodbConfig(id: number): Promise { - // No-op in development - } - - async getInvoiceVerificationCache(cacheKey: string): Promise { - // Implémentation temporaire d'un cache en mémoire pour tester - const cached = this.invoiceVerificationCache.get(cacheKey); - if (cached && new Date() < new Date(cached.expiresAt)) { - console.log('✅ [MEMSTORAGE-CACHE] Cache hit pour:', { cacheKey, expires: cached.expiresAt }); - return cached; - } - if (cached && new Date() >= new Date(cached.expiresAt)) { - console.log('⏰ [MEMSTORAGE-CACHE] Cache expiré, suppression:', { cacheKey }); - this.invoiceVerificationCache.delete(cacheKey); - } - console.log('❌ [MEMSTORAGE-CACHE] Cache miss pour:', { cacheKey }); - return undefined; - } - - async createInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise { - const newCache: InvoiceVerificationCache = { - id: 1, - cacheKey: cache.cacheKey, - groupId: cache.groupId, - invoiceReference: cache.invoiceReference, - supplierName: cache.supplierName || null, - invoiceAmount: cache.invoiceAmount || null, - dueDate: cache.dueDate || null, - exists: cache.exists, - matchType: cache.matchType, - errorMessage: cache.errorMessage || null, - isReconciled: cache.isReconciled || false, - cacheHit: cache.cacheHit || false, - apiCallTime: cache.apiCallTime || null, - expiresAt: cache.expiresAt, - createdAt: new Date(), - updatedAt: new Date(), - }; - return newCache; - } - - async saveInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise { - const newCache = await this.createInvoiceVerificationCache(cache); - // Sauver en mémoire pour le cache temporaire - console.log('💾 [MEMSTORAGE-CACHE] Sauvegarde en cache:', { - cacheKey: cache.cacheKey, - exists: cache.exists, - expiresAt: cache.expiresAt - }); - this.invoiceVerificationCache.set(cache.cacheKey, newCache); - return newCache; - } - - async clearExpiredCache(): Promise { - // No-op en développement - } - - private activityStatsBy( - key: 'groupId' | 'supplierId', - groupIds?: number[] - ): EntityActivityStats[] { - const byId = new Map(); - - const entryFor = (id: number): EntityActivityStats => { - let entry = byId.get(id); - if (!entry) { - entry = { id, orders: 0, deliveries: 0, delivered: 0 }; - byId.set(id, entry); - } - return entry; - }; - - const inScope = (groupId: number) => - !groupIds || groupIds.length === 0 || groupIds.includes(groupId); - - for (const order of this.orders.values()) { - if (!inScope(order.groupId)) continue; - entryFor(order[key]).orders += 1; - } - - for (const delivery of this.deliveries.values()) { - if (!inScope(delivery.groupId)) continue; - const entry = entryFor(delivery[key]); - entry.deliveries += 1; - if (delivery.status === 'delivered') entry.delivered += 1; - } - - return Array.from(byId.values()); - } - - async getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise { - return this.activityStatsBy('groupId', groupIds); - } - - async getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise { - return this.activityStatsBy('supplierId', groupIds); - } - - // MemStorage implementations with actual data handling - async getOrders(groupIds?: number[]): Promise { - let orders = Array.from(this.orders.values()); - if (groupIds && groupIds.length > 0) { - orders = orders.filter(order => groupIds.includes(order.groupId)); - } - return orders.map(order => { - // Récupérer les livraisons associées à cette commande (DEV RELATIONS) - const associatedDeliveries = Array.from(this.deliveries.values()) - .filter(delivery => delivery.orderId === order.id) - .map(delivery => ({ - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || '') - })); - - return { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy), - deliveries: associatedDeliveries - }; - }); - } - - async getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { - const start = new Date(startDate); - const end = new Date(endDate); - let orders = Array.from(this.orders.values()).filter(order => { - const orderDate = new Date(order.plannedDate); - return orderDate >= start && orderDate <= end; - }); - - if (groupIds && groupIds.length > 0) { - orders = orders.filter(order => groupIds.includes(order.groupId)); - } - - return orders.map(order => { - // Récupérer les livraisons associées à cette commande (DEV RELATIONS) - const associatedDeliveries = Array.from(this.deliveries.values()) - .filter(delivery => delivery.orderId === order.id) - .map(delivery => ({ - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || '') - })); - - return { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy), - deliveries: associatedDeliveries - }; - }); - } - - async getOrder(id: number): Promise { - const order = this.orders.get(id); - if (!order) return undefined; - - // Récupérer les livraisons associées à cette commande - const associatedDeliveries = Array.from(this.deliveries.values()) - .filter(delivery => delivery.orderId === id) - .map(delivery => ({ - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || '') - })); - - return { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy), - deliveries: associatedDeliveries - }; - } - - async createOrder(orderData: InsertOrder): Promise { - const id = this.idCounters.order++; - const order: Order = { - id, - ...orderData, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.orders.set(id, order); - return order; - } - - async updateOrder(id: number, orderData: Partial): Promise { - const existingOrder = this.orders.get(id); - if (!existingOrder) throw new Error('Order not found'); - - const updatedOrder = { - ...existingOrder, - ...orderData, - updatedAt: new Date(), - }; - this.orders.set(id, updatedOrder); - return updatedOrder; - } - - async deleteOrder(id: number): Promise { - this.orders.delete(id); - } - - async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise { - let deliveries = Array.from(this.deliveries.values()); - if (groupIds && groupIds.length > 0) { - deliveries = deliveries.filter(delivery => groupIds.includes(delivery.groupId)); - } - if (options?.status) { - deliveries = deliveries.filter(delivery => delivery.status === options.status); - } - return deliveries.map(delivery => { - // Récupérer la commande associée à cette livraison si elle existe (DEV RELATIONS) - let associatedOrder = undefined; - if (delivery.orderId) { - const order = this.orders.get(delivery.orderId); - if (order) { - associatedOrder = { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy) - }; - } - } - - // Compter les commentaires de rapprochement pour cette livraison - const commentsCount = Array.from(this.reconciliationComments.values()) - .filter(comment => comment.deliveryId === delivery.id).length; - - return { - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || ''), - order: associatedOrder, - reconciliationCommentsCount: commentsCount - }; - }); - } - - async getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { - const start = new Date(startDate); - const end = new Date(endDate); - let deliveries = Array.from(this.deliveries.values()).filter(delivery => { - const deliveryDate = new Date(delivery.scheduledDate); - return deliveryDate >= start && deliveryDate <= end; - }); - - if (groupIds && groupIds.length > 0) { - deliveries = deliveries.filter(delivery => groupIds.includes(delivery.groupId)); - } - - return deliveries.map(delivery => { - // Récupérer la commande associée à cette livraison si elle existe (DEV RELATIONS) - let associatedOrder = undefined; - if (delivery.orderId) { - const order = this.orders.get(delivery.orderId); - if (order) { - associatedOrder = { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy) - }; - } - } - - // Compter les commentaires de rapprochement pour cette livraison - const commentsCount = Array.from(this.reconciliationComments.values()) - .filter(comment => comment.deliveryId === delivery.id).length; - - return { - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || ''), - order: associatedOrder, - reconciliationCommentsCount: commentsCount - }; - }); - } - - async getDelivery(id: number): Promise { - const delivery = this.deliveries.get(id); - if (!delivery) return undefined; - - // Récupérer la commande associée si elle existe - let associatedOrder = undefined; - if (delivery.orderId) { - const order = this.orders.get(delivery.orderId); - if (order) { - associatedOrder = { - ...order, - supplier: this.suppliers.get(order.supplierId), - group: this.groups.get(order.groupId), - creator: this.users.get(order.createdBy) - }; - } - } - - // Compter les commentaires de rapprochement pour cette livraison - const commentsCount = Array.from(this.reconciliationComments.values()) - .filter(comment => comment.deliveryId === delivery.id).length; - - return { - ...delivery, - supplier: this.suppliers.get(delivery.supplierId), - group: this.groups.get(delivery.groupId), - creator: this.users.get(delivery.createdBy || ''), - order: associatedOrder, - reconciliationCommentsCount: commentsCount - }; - } - - async createDelivery(deliveryData: InsertDelivery): Promise { - const id = this.idCounters.delivery++; - const delivery: Delivery = { - id, - ...deliveryData, - status: 'planned', // Livraisons créées sont automatiquement planifiées - createdAt: new Date(), - updatedAt: new Date(), - }; - this.deliveries.set(id, delivery); - - // Si une commande est liée, la marquer comme "planned" (pas delivered!) - if (deliveryData.orderId) { - try { - const order = this.orders.get(deliveryData.orderId); - if (order && order.status === 'pending') { - console.log(`🔗 Delivery #${id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); - await this.updateOrder(deliveryData.orderId, { status: 'planned' }); - } - } catch (error) { - console.error(`❌ Failed to update order #${deliveryData.orderId} status to planned:`, error); - } - } - - return delivery; - } - - async updateDelivery(id: number, deliveryData: Partial): Promise { - const existingDelivery = this.deliveries.get(id); - if (!existingDelivery) throw new Error('Delivery not found'); - - const updatedDelivery = { - ...existingDelivery, - ...deliveryData, - updatedAt: new Date(), - }; - this.deliveries.set(id, updatedDelivery); - - // CRITICAL FIX: Si une commande est liée lors de l'édition, la marquer comme "planned" - if (deliveryData.orderId) { - try { - const order = this.orders.get(deliveryData.orderId); - if (order && order.status === 'pending') { - console.log(`🔗 CALENDAR EDIT (DEV): Delivery #${id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); - await this.updateOrder(deliveryData.orderId, { status: 'planned' }); - console.log(`✅ CALENDAR EDIT (DEV): Order #${deliveryData.orderId} status updated to 'planned'`); - } else if (order) { - console.log(`ℹ️ CALENDAR EDIT (DEV): Order #${deliveryData.orderId} status is '${order.status}', no update needed`); - } - } catch (error) { - console.error(`❌ CALENDAR EDIT (DEV): Failed to update order #${deliveryData.orderId} status to planned:`, error); - } - } - - return updatedDelivery; - } - - async deleteDelivery(id: number): Promise { - this.deliveries.delete(id); - } - - async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise { - const delivery = this.deliveries.get(id); - if (!delivery) throw new Error('Delivery not found'); - - const updatedDelivery = { - ...delivery, - status: 'delivered' as const, - deliveredDate: new Date(), - blNumber: blData?.blNumber || delivery.blNumber, - blAmount: blData?.blAmount || delivery.blAmount, - updatedAt: new Date(), - }; - this.deliveries.set(id, updatedDelivery); - } - - async getUserGroups(): Promise { return []; } - async assignUserToGroup(): Promise { return {} as UserGroup; } - async removeUserFromGroup(): Promise { } - async removeUserFromAllGroups(userId: string): Promise { - this.userGroups.delete(userId); - } - - async getMonthlyStats(): Promise { - return { - ordersCount: 12, - deliveriesCount: 8, - pendingOrdersCount: 3, - averageDeliveryTime: 2.5, - totalPalettes: 45, - totalPackages: 156, - }; - } - - async getYearlyStats(): Promise { - return { - ordersCount: 144, // 12 mois * 12 - deliveriesCount: 96, // 12 mois * 8 - pendingOrdersCount: 18, // 12 mois * 1.5 - averageDeliveryTime: 2.8, // Délai moyen annuel - totalPalettes: 540, // 12 mois * 45 - totalPackages: 1872, // 12 mois * 156 - }; - } - - async getPublicities(year?: number, groupIds?: number[]): Promise { - let publicities = Array.from(this.publicities.values()); - - // Filter by year if provided - if (year) { - publicities = publicities.filter(pub => pub.year === year); - } - - // Sort by pubNumber in ASCENDING order (smallest first) - publicities.sort((a, b) => a.pubNumber - b.pubNumber); - - // LOG: Debug du tri des publicités - console.log(`📋 PUBLICITES (DEV): Tri par pubNumber croissant - ${publicities.length} résultats:`, - publicities.slice(0, 3).map(p => `N°${p.pubNumber}(${p.designation})`).join(', ') + - (publicities.length > 3 ? '...' : '') - ); - - // Return with empty participations for now (since we're using MemStorage) - return publicities.map(pub => ({ - ...pub, - participations: [] - })); - } - async getPublicity(): Promise { return undefined; } - async createPublicity(): Promise { return {} as Publicity; } - async updatePublicity(): Promise { return {} as Publicity; } - async deletePublicity(): Promise { } - async getPublicityParticipations(): Promise { return []; } - async setPublicityParticipations(): Promise { } - - async getCustomerOrders(groupIds?: number[]): Promise { - let orders = Array.from(this.customerOrders.values()); - - if (groupIds && groupIds.length > 0) { - orders = orders.filter(order => groupIds.includes(order.groupId)); - } - - return orders.map(order => ({ - ...order, - supplier: this.suppliers.get(order.supplierId)!, - group: this.groups.get(order.groupId)!, - creator: this.users.get(order.createdBy)! - })); - } - - async getCustomerOrder(id: number): Promise { - const order = this.customerOrders.get(id); - if (!order) return undefined; - - return { - ...order, - supplier: this.suppliers.get(order.supplierId)!, - group: this.groups.get(order.groupId)!, - creator: this.users.get(order.createdBy)! - }; - } - - async createCustomerOrder(orderData: InsertCustomerOrder): Promise { - const id = this.idCounters.customerOrder++; - const order: CustomerOrder = { - id, - ...orderData, - notes: orderData.notes || null, - status: orderData.status || 'En attente de Commande', - quantity: orderData.quantity || 1, - deposit: orderData.deposit || "0.00" as any, - isPromotionalPrice: orderData.isPromotionalPrice || false, - customerNotified: orderData.customerNotified || false, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.customerOrders.set(id, order); - return order; - } - - async updateCustomerOrder(id: number, orderData: Partial): Promise { - const existingOrder = this.customerOrders.get(id); - if (!existingOrder) throw new Error('Customer order not found'); - - const updatedOrder = { ...existingOrder, ...orderData, updatedAt: new Date() }; - this.customerOrders.set(id, updatedOrder); - return updatedOrder; - } - - async deleteCustomerOrder(id: number): Promise { - this.customerOrders.delete(id); - } - - async getPendingClientCalls(groupIds?: number[]): Promise { - let orders = Array.from(this.customerOrders.values()); - - // Filter by groupIds if provided - if (groupIds && groupIds.length > 0) { - orders = orders.filter(order => groupIds.includes(order.groupId)); - } - - // Find orders where client not notified and status is 'Disponible' - const pendingOrders = orders.filter(order => { - return !order.customerNotified && - (order.status === 'Disponible' || order.status === 'disponible' || - order.status === 'Arrivé' || order.status === 'arrivé' || - order.status === 'Prêt' || order.status === 'prêt'); - }); - - return pendingOrders.map(order => ({ - ...order, - supplier: this.suppliers.get(order.supplierId)!, - group: this.groups.get(order.groupId)!, - creator: this.users.get(order.createdBy)! - })); - } - - async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise { - const order = this.customerOrders.get(customerOrderId); - if (!order) { - throw new Error(`CustomerOrder with id ${customerOrderId} not found`); - } - - const updatedOrder = { - ...order, - customerNotified: true, - updatedAt: new Date() - }; - - this.customerOrders.set(customerOrderId, updatedOrder); - return updatedOrder; - } - - async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { - let products = Array.from(this.dlcProducts.values()); - - if (groupIds && groupIds.length > 0) { - products = products.filter(product => groupIds.includes(product.groupId)); - } - - if (filters) { - if (filters.status) { - products = products.filter(product => product.status === filters.status); - } - if (filters.supplierId) { - products = products.filter(product => product.supplierId === filters.supplierId); - } - if (filters.search) { - const searchTerm = filters.search.toLowerCase(); - products = products.filter(product => { - const supplier = this.suppliers.get(product.supplierId); - return product.productName.toLowerCase().includes(searchTerm) || - (supplier && supplier.name.toLowerCase().includes(searchTerm)); - }); - } - } - - return products.map(product => ({ - ...product, - supplier: this.suppliers.get(product.supplierId)!, - group: this.groups.get(product.groupId)!, - creator: this.users.get(product.createdBy)! - })); - } - - async getDlcProduct(id: number): Promise { - const product = this.dlcProducts.get(id); - if (!product) return undefined; - - return { - ...product, - supplier: this.suppliers.get(product.supplierId)!, - group: this.groups.get(product.groupId)!, - creator: this.users.get(product.createdBy)! - }; - } - - async createDlcProduct(productData: InsertDlcProduct): Promise { - const id = this.idCounters.dlcProduct++; - const product: DlcProduct = { - id, - ...productData, - notes: productData.notes || null, - status: productData.status || 'en_cours', - quantity: productData.quantity || 1, - unit: productData.unit || 'unité', - location: productData.location || 'Magasin', - alertThreshold: productData.alertThreshold || 15, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.dlcProducts.set(id, product); - return { ...product, dlcDate: new Date(product.expiryDate) } as DlcProductFrontend; - } - - async updateDlcProduct(id: number, productData: Partial): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { ...existingProduct, ...productData, updatedAt: new Date() }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async deleteDlcProduct(id: number): Promise { - this.dlcProducts.delete(id); - } - - async validateDlcProduct(id: number, validatedBy: string): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { - ...existingProduct, - validatedBy, - validatedAt: new Date(), - status: "valides", - updatedAt: new Date() - }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async markDlcProductStockEpuise(id: number, markedBy: string): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { - ...existingProduct, - stockEpuise: true, - stockEpuiseBy: markedBy, - stockEpuiseAt: new Date(), - updatedAt: new Date() - }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async restoreDlcProductStock(id: number): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { - ...existingProduct, - stockEpuise: false, - stockEpuiseBy: null, - stockEpuiseAt: null, - updatedAt: new Date() - }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async markDlcProductAsProcessed(id: number, processedBy: string): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { - ...existingProduct, - processedAt: new Date(), - processedBy: processedBy, - processedUntilExpiry: true, - updatedAt: new Date() - }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async unmarkDlcProductAsProcessed(id: number): Promise { - const existingProduct = this.dlcProducts.get(id); - if (!existingProduct) throw new Error('DLC Product not found'); - - const updatedProduct = { - ...existingProduct, - processedAt: null, - processedBy: null, - processedUntilExpiry: false, - updatedAt: new Date() - }; - this.dlcProducts.set(id, updatedProduct); - return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; - } - - async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { - const today = new Date(); - const alertDate = new Date(); - alertDate.setDate(today.getDate() + 15); - - let products = Array.from(this.dlcProducts.values()); - - if (groupIds && groupIds.length > 0) { - products = products.filter(product => groupIds.includes(product.groupId)); - } - - // IMPORTANT: Filtrer sur processedUntilExpiry pour ne pas réafficher les produits déjà traités - // Les produits "traités" (processedUntilExpiry=true) ne doivent pas apparaître dans les alertes quotidiennes - return { - active: products.filter(p => - new Date(p.expiryDate) > today && - p.status !== 'valides' && - !p.processedUntilExpiry && - !p.stockEpuise - ).length, - expiringSoon: products.filter(p => { - const expiry = new Date(p.expiryDate); - const isExpiringSoon = expiry >= today && expiry <= alertDate; - return isExpiringSoon && p.status !== 'valides' && !p.processedUntilExpiry && !p.stockEpuise; - }).length, - expired: products.filter(p => { - const expiry = new Date(p.expiryDate); - const isExpired = expiry <= today; - return isExpired && p.status !== 'valides' && !p.processedUntilExpiry && !p.stockEpuise; - }).length - }; - } - - async getTasks(groupIds?: number[], userRole?: string): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - let tasks = Array.from(this.tasks.values()); - - if (groupIds && groupIds.length > 0) { - tasks = tasks.filter(task => groupIds.includes(task.groupId)); - } - - // Filtrage par rôle pour la date de départ - if (userRole === 'manager' || userRole === 'employee') { - // Managers et employés : seulement les tâches dont la date de départ est atteinte ou passée - tasks = tasks.filter(task => { - if (!task.startDate) return true; // Tâches sans date de départ (toujours visibles) - const startDate = new Date(task.startDate); - return startDate <= today; // Tâches dont la date de départ est arrivée - }); - } - // Admin et directeur voient toutes les tâches, y compris les tâches programmées - - return tasks.map(task => ({ - ...task, - group: this.groups.get(task.groupId)!, - creator: this.users.get(task.createdBy)!, - isFutureTask: task.startDate ? new Date(task.startDate) > today : false - })); - } - - async getTask(id: number): Promise { - const task = this.tasks.get(id); - if (!task) return undefined; - - return { - ...task, - group: this.groups.get(task.groupId)!, - creator: this.users.get(task.createdBy)! - }; - } - - async createTask(taskData: InsertTask): Promise { - const id = this.idCounters.task++; - const task: Task = { - id, - ...taskData, - description: taskData.description || null, - status: taskData.status || 'pending', - priority: taskData.priority || 'medium', - completedAt: null, - completedBy: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.tasks.set(id, task); - return task; - } - - private initializeExtraTestData() { - // Add test customer orders - const testOrder1: CustomerOrder = { - id: 1, - supplierId: 1, - groupId: 1, - createdBy: 'admin', - orderTaker: 'Admin', - customerName: 'Marie Dupont', - customerPhone: '0123456789', - customerEmail: null, - productDesignation: 'Table de jardin', - productReference: null, - gencode: null, - quantity: 2, - deposit: '150.00' as any, - status: 'En attente de Commande', - isPromotionalPrice: false, - customerNotified: false, - notes: 'Livraison urgente demandée', - createdAt: new Date('2025-01-15'), - updatedAt: new Date('2025-01-15'), - }; - - const testOrder2: CustomerOrder = { - id: 2, - supplierId: 1, - groupId: 1, - createdBy: 'admin', - orderTaker: 'Admin', - customerName: 'Pierre Martin', - customerPhone: '0987654321', - customerEmail: null, - productDesignation: 'Chaises pliantes x4', - productReference: null, - gencode: null, - quantity: 1, - deposit: '80.00' as any, - status: 'Commande passée', - isPromotionalPrice: true, - customerNotified: true, - notes: null, - createdAt: new Date('2025-01-10'), - updatedAt: new Date('2025-01-12'), - }; - - this.customerOrders.set(1, testOrder1); - this.customerOrders.set(2, testOrder2); - this.idCounters.customerOrder = 3; - - // Add test DLC products - const testDlc1: DlcProduct = { - id: 1, - supplierId: 1, - groupId: 1, - createdBy: 'admin', - productName: 'Yaourts bio', - gencode: 'YAO001', - dateType: 'DLC', - expiryDate: '2025-08-20', - status: 'en_cours', - quantity: 24, - unit: 'pièces', - notes: 'À vendre rapidement' as string | null, - location: 'Frigo principal', - alertThreshold: 5, - validatedBy: null as string | null, - validatedAt: null, - stockEpuise: false, - stockEpuiseBy: null, - stockEpuiseAt: null, - createdAt: new Date('2025-08-10'), - updatedAt: new Date('2025-08-10'), - }; - - const testDlc2: DlcProduct = { - id: 2, - supplierId: 1, - groupId: 1, - createdBy: 'admin', - productName: 'Pain de mie complet', - gencode: 'PAIN002', - dateType: 'DLC', - expiryDate: '2025-08-16', - status: 'alerte', - quantity: 8, - unit: 'unités', - notes: 'DLC proche, promotion conseillée' as string | null, - location: 'Rayonnage A3', - alertThreshold: 3, - validatedBy: null as string | null, - validatedAt: null, - stockEpuise: false, - stockEpuiseBy: null, - stockEpuiseAt: null, - createdAt: new Date('2025-08-08'), - updatedAt: new Date('2025-08-13'), - }; - - this.dlcProducts.set(1, testDlc1); - this.dlcProducts.set(2, testDlc2); - this.idCounters.dlcProduct = 3; - - // Add test tasks - const testTask1: Task = { - id: 1, - groupId: 1, - createdBy: 'admin', - title: 'Vérifier stock yaourts', - description: 'Contrôler les DLC des yaourts et organiser la rotation', - status: 'pending', - priority: 'high', - assignedTo: 'admin', - dueDate: new Date('2025-08-15'), - completedAt: null, - completedBy: null, - createdAt: new Date('2025-08-13'), - updatedAt: new Date('2025-08-13'), - }; - - const testTask2: Task = { - id: 2, - groupId: 1, - createdBy: 'admin', - title: 'Contacter fournisseur', - description: 'Appeler le fournisseur pour confirmer la livraison de demain', - status: 'in_progress', - priority: 'medium', - assignedTo: 'admin', - dueDate: new Date('2025-08-14'), - completedAt: null, - completedBy: null, - createdAt: new Date('2025-08-12'), - updatedAt: new Date('2025-08-13'), - }; - - this.tasks.set(1, testTask1); - this.tasks.set(2, testTask2); - this.idCounters.task = 3; - - // Add test publicity data - const testPublicity1: Publicity = { - id: 1, - pubNumber: 2517, - designation: 'Prix Fracassés', - startDate: new Date('2025-10-27'), - endDate: new Date('2025-11-02'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity2: Publicity = { - id: 2, - pubNumber: 2520, - designation: 'Halloween', - startDate: new Date('2025-10-08'), - endDate: new Date('2025-10-12'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity3: Publicity = { - id: 3, - pubNumber: 2521, - designation: 'Prix Fous', - startDate: new Date('2025-10-20'), - endDate: new Date('2025-10-26'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity4: Publicity = { - id: 4, - pubNumber: 2518, - designation: 'Le mois anniversaire 1', - startDate: new Date('2025-11-17'), - endDate: new Date('2025-11-23'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity5: Publicity = { - id: 5, - pubNumber: 2523, - designation: 'Noël', - startDate: new Date('2025-11-05'), - endDate: new Date('2025-11-09'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity6: Publicity = { - id: 6, - pubNumber: 2524, - designation: 'Cadeaux et noel', - startDate: new Date('2025-11-17'), - endDate: new Date('2025-11-23'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity7: Publicity = { - id: 7, - pubNumber: 2525, - designation: 'Deco et Table en Fete', - startDate: new Date('2025-11-24'), - endDate: new Date('2025-11-30'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - const testPublicity8: Publicity = { - id: 8, - pubNumber: 2526, - designation: 'Sous le Sapin', - startDate: new Date('2025-12-03'), - endDate: new Date('2025-12-07'), - year: 2025, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }; - - this.publicities.set(1, testPublicity1); - this.publicities.set(2, testPublicity2); - this.publicities.set(3, testPublicity3); - this.publicities.set(4, testPublicity4); - this.publicities.set(5, testPublicity5); - this.publicities.set(6, testPublicity6); - this.publicities.set(7, testPublicity7); - this.publicities.set(8, testPublicity8); - this.idCounters.publicity = 9; - - // Initialize test SAV data - this.initializeSavTestData(); - } - - async updateTask(id: number, taskData: Partial): Promise { - const existingTask = this.tasks.get(id); - if (!existingTask) throw new Error('Task not found'); - - const updatedTask = { ...existingTask, ...taskData, updatedAt: new Date() }; - this.tasks.set(id, updatedTask); - return updatedTask; - } - - async deleteTask(id: number): Promise { - this.tasks.delete(id); - } - - async completeTask(id: number, completedBy: string): Promise { - const existingTask = this.tasks.get(id); - if (!existingTask) throw new Error('Task not found'); - - const updatedTask = { - ...existingTask, - status: 'completed' as const, - completedAt: new Date(), - completedBy, - updatedAt: new Date() - }; - this.tasks.set(id, updatedTask); - } - - // SAV operations - async getSavTickets(filters?: { - groupIds?: number[]; - status?: string; - supplierId?: number; - priority?: string; - startDate?: string; - endDate?: string; - }): Promise { - const tickets = Array.from(this.savTickets.values()); - - let filteredTickets = tickets; - - if (filters?.groupIds?.length) { - filteredTickets = filteredTickets.filter(ticket => filters.groupIds!.includes(ticket.groupId)); - } - if (filters?.status) { - filteredTickets = filteredTickets.filter(ticket => ticket.status === filters.status); - } - if (filters?.supplierId) { - filteredTickets = filteredTickets.filter(ticket => ticket.supplierId === filters.supplierId); - } - if (filters?.priority) { - filteredTickets = filteredTickets.filter(ticket => ticket.priority === filters.priority); - } - if (filters?.startDate) { - const startDate = new Date(filters.startDate); - filteredTickets = filteredTickets.filter(ticket => - ticket.createdAt && ticket.createdAt >= startDate - ); - } - if (filters?.endDate) { - const endDate = new Date(filters.endDate); - filteredTickets = filteredTickets.filter(ticket => - ticket.createdAt && ticket.createdAt <= endDate - ); - } - - // Sort by creation date (newest first) - filteredTickets.sort((a, b) => { - const dateA = a.createdAt || new Date(0); - const dateB = b.createdAt || new Date(0); - return dateB.getTime() - dateA.getTime(); - }); - - // Add relations for each ticket - const ticketsWithRelations = await Promise.all( - filteredTickets.map(async (ticket) => { - const supplier = this.suppliers.get(ticket.supplierId); - const group = this.groups.get(ticket.groupId); - const creator = this.users.get(ticket.createdBy); - const history = await this.getSavTicketHistory(ticket.id); - - return { - ...ticket, - supplier: supplier!, - group: group!, - creator: creator!, - history, - }; - }) - ); - - return ticketsWithRelations; - } - - async getSavTicket(id: number): Promise { - const ticket = this.savTickets.get(id); - if (!ticket) return undefined; - - const supplier = this.suppliers.get(ticket.supplierId); - const group = this.groups.get(ticket.groupId); - const creator = this.users.get(ticket.createdBy); - const history = await this.getSavTicketHistory(id); - - return { - ...ticket, - supplier: supplier!, - group: group!, - creator: creator!, - history, - }; - } - - async createSavTicket(ticketData: InsertSavTicket): Promise { - const id = this.idCounters.savTicket++; - - // Generate ticket number - const currentYear = new Date().getFullYear(); - const ticketNumber = `SAV-${currentYear}-${String(id).padStart(4, '0')}`; - - const ticket: SavTicket = { - id, - ticketNumber, - ...ticketData, - createdAt: new Date(), - updatedAt: new Date(), - resolvedAt: null, - closedAt: null, - }; - - this.savTickets.set(id, ticket); - - // Add initial history entry - await this.addSavTicketHistory({ - ticketId: id, - action: 'created', - description: `Ticket créé avec le statut "${ticket.status}"`, - createdBy: ticket.createdBy, - }); - - return ticket; - } - - async updateSavTicket(id: number, ticketData: Partial): Promise { - const existingTicket = this.savTickets.get(id); - if (!existingTicket) throw new Error('SAV ticket not found'); - - const updatedTicket = { - ...existingTicket, - ...ticketData, - updatedAt: new Date() - }; - - // Set resolved/closed dates based on status - if (ticketData.status === 'resolu' && !updatedTicket.resolvedAt) { - updatedTicket.resolvedAt = new Date(); - } - if (ticketData.status === 'ferme' && !updatedTicket.closedAt) { - updatedTicket.closedAt = new Date(); - } - - this.savTickets.set(id, updatedTicket); - return updatedTicket; - } - - async deleteSavTicket(id: number): Promise { - // Delete history first - // this.savTicketHistories.delete(id); - // Delete ticket - this.savTickets.delete(id); - } - - async getSavTicketHistory(ticketId: number): Promise { - return []; - } - - async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise { - return null; - } - - async getSavTicketStats(groupIds?: number[]): Promise<{ - totalTickets: number; - newTickets: number; - inProgressTickets: number; - resolvedTickets: number; - criticalTickets: number; - }> { - let tickets = Array.from(this.savTickets.values()); - - if (groupIds?.length) { - tickets = tickets.filter(ticket => groupIds.includes(ticket.groupId)); - } - - const stats = { - totalTickets: tickets.length, - newTickets: tickets.filter(t => t.status === 'nouveau').length, - inProgressTickets: tickets.filter(t => - ['en_cours', 'attente_pieces', 'attente_echange'].includes(t.status) - ).length, - resolvedTickets: tickets.filter(t => - ['resolu', 'ferme'].includes(t.status) - ).length, - criticalTickets: tickets.filter(t => t.priority === 'critique').length, - }; - - return stats; - } - - private initializeSavTestData() { - // Test SAV tickets - const testTicket1: SavTicket = { - id: 1, - ticketNumber: 'SAV-2025-0001', - groupId: 1, - supplierId: 1, - clientName: 'Jean Dupont', - clientPhone: '03 83 12 34 56', - problemDescription: 'Produit défaillant après 2 jours d\'utilisation. Écran cassé et boutons non fonctionnels.', - productGencode: '12345678901234', - productReference: 'REF-ABC-123', - productDesignation: 'Tablette Samsung Galaxy Tab A7', - status: 'nouveau', - priority: 'haute', - createdBy: 'admin', - createdAt: new Date('2025-08-10T08:30:00'), - updatedAt: new Date('2025-08-10T08:30:00'), - resolvedAt: null, - closedAt: null, - }; - - const testTicket2: SavTicket = { - id: 2, - ticketNumber: 'SAV-2025-0002', - groupId: 1, - supplierId: 2, - clientName: 'Marie Martin', - clientPhone: '06 12 34 56 78', - problemDescription: 'Pièces manquantes dans le colis. Manque le chargeur et les écouteurs.', - productGencode: '23456789012345', - productReference: 'REF-XYZ-456', - productDesignation: 'Smartphone iPhone 14', - status: 'en_cours', - priority: 'normale', - createdBy: 'admin', - createdAt: new Date('2025-08-09T14:15:00'), - updatedAt: new Date('2025-08-12T10:20:00'), - resolvedAt: null, - closedAt: null, - }; - - const testTicket3: SavTicket = { - id: 3, - ticketNumber: 'SAV-2025-0003', - groupId: 1, - supplierId: 1, - clientName: 'Pierre Durand', - clientPhone: '03 83 98 76 54', - problemDescription: 'En attente de la pièce de rechange. Écran de remplacement commandé chez le fournisseur.', - productGencode: '34567890123456', - productReference: 'REF-DEF-789', - productDesignation: 'Ordinateur portable Dell Inspiron', - status: 'attente_pieces', - priority: 'normale', - createdBy: 'admin', - createdAt: new Date('2025-08-08T09:45:00'), - updatedAt: new Date('2025-08-13T16:30:00'), - resolvedAt: null, - closedAt: null, - }; - - const testTicket4: SavTicket = { - id: 4, - ticketNumber: 'SAV-2025-0004', - groupId: 1, - supplierId: 3, - clientName: 'Sophie Moreau', - clientPhone: '06 98 76 54 32', - problemDescription: 'Problème résolu. Produit réparé et testé. Prêt pour le retour client.', - productGencode: '45678901234567', - productReference: 'REF-GHI-012', - productDesignation: 'Casque Sony WH-1000XM4', - status: 'resolu', - priority: 'faible', - createdBy: 'admin', - createdAt: new Date('2025-08-05T11:20:00'), - updatedAt: new Date('2025-08-14T08:00:00'), - resolvedAt: new Date('2025-08-14T08:00:00'), - closedAt: null, - }; - - const testTicket5: SavTicket = { - id: 5, - ticketNumber: 'SAV-2025-0005', - groupId: 1, - supplierId: 2, - clientName: 'Luc Bernard', - clientPhone: '03 83 45 67 89', - problemDescription: 'URGENT - Produit en panne critique pour activité professionnelle. Besoin intervention rapide.', - productGencode: '56789012345678', - productReference: 'REF-JKL-345', - productDesignation: 'Imprimante HP LaserJet Pro', - status: 'en_cours', - priority: 'critique', - createdBy: 'admin', - createdAt: new Date('2025-08-14T07:30:00'), - updatedAt: new Date('2025-08-14T07:30:00'), - resolvedAt: null, - closedAt: null, - }; - - // Store the tickets - this.savTickets.set(1, testTicket1); - this.savTickets.set(2, testTicket2); - this.savTickets.set(3, testTicket3); - this.savTickets.set(4, testTicket4); - this.savTickets.set(5, testTicket5); - - // Add history entries for each ticket - const histories = [ - // Ticket 1 history - { ticketId: 1, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-10T08:30:00') }, - - // Ticket 2 history - { ticketId: 2, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-09T14:15:00') }, - { ticketId: 2, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-12T10:20:00') }, - { ticketId: 2, action: 'comment', description: 'Contact avec le client pour confirmer les pièces manquantes. Commande des accessoires en cours.', createdBy: 'admin', createdAt: new Date('2025-08-12T10:25:00') }, - - // Ticket 3 history - { ticketId: 3, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-08T09:45:00') }, - { ticketId: 3, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-10T14:00:00') }, - { ticketId: 3, action: 'status_change', description: 'Statut changé de "en_cours" vers "attente_pieces"', createdBy: 'admin', createdAt: new Date('2025-08-13T16:30:00') }, - { ticketId: 3, action: 'comment', description: 'Écran de remplacement commandé. Délai annoncé de 5-7 jours ouvrables.', createdBy: 'admin', createdAt: new Date('2025-08-13T16:35:00') }, - - // Ticket 4 history - { ticketId: 4, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-05T11:20:00') }, - { ticketId: 4, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-06T09:00:00') }, - { ticketId: 4, action: 'comment', description: 'Diagnostic effectué : problème de connectivité Bluetooth. Réparation en cours.', createdBy: 'admin', createdAt: new Date('2025-08-07T15:30:00') }, - { ticketId: 4, action: 'status_change', description: 'Statut changé de "en_cours" vers "resolu"', createdBy: 'admin', createdAt: new Date('2025-08-14T08:00:00') }, - { ticketId: 4, action: 'comment', description: 'Casque réparé avec succès. Module Bluetooth remplacé. Tests complets effectués.', createdBy: 'admin', createdAt: new Date('2025-08-14T08:05:00') }, - - // Ticket 5 history - { ticketId: 5, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') }, - { ticketId: 5, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') }, - { ticketId: 5, action: 'comment', description: 'Priorité critique - Intervention d\'urgence programmée pour ce matin.', createdBy: 'admin', createdAt: new Date('2025-08-14T07:35:00') }, - ]; - - // Initialize history for each ticket - // histories.forEach((historyEntry) => { - // const id = this.idCounters.savTicketHistory++; - // const history: SavTicketHistory = { - // id, - // ...historyEntry, - // }; - - // const existingHistories = this.savTicketHistories.get(historyEntry.ticketId) || []; - // existingHistories.push(history); - // this.savTicketHistories.set(historyEntry.ticketId, existingHistories); - // }); - - this.idCounters.savTicket = 6; - console.log('✅ SAV test data initialized with 5 tickets'); - } - - // Weather operations - async getWeatherSettings(): Promise { - // In memory storage, return a default setting for development - return { - id: 1, - apiKey: "6PSHYLBJB9EEYNZ7NDQ5BPGBZ", // Token fourni par l'utilisateur - location: "Nancy, France", - isActive: true, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async createWeatherSettings(settings: InsertWeatherSettings): Promise { - // For development, we'll just return the created settings with an ID - return { - id: 1, - ...settings, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async updateWeatherSettings(id: number, settings: Partial): Promise { - const existing = await this.getWeatherSettings(); - return { - ...existing!, - ...settings, - updatedAt: new Date() - }; - } - - async getWeatherData(date: string, isCurrentYear: boolean): Promise { - // In development, return some mock data for testing - return undefined; // Will trigger API calls - } - - async getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise { - return undefined; - } - - async createWeatherData(data: InsertWeatherData): Promise { - return { - id: 1, - ...data, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async updateWeatherData(id: number, data: Partial): Promise { - const existing = await this.getWeatherData(data.date || "", data.isCurrentYear || true); - return { - ...existing!, - ...data, - updatedAt: new Date() - }; - } - - async deleteOldWeatherData(daysToKeep: number): Promise { - // In development, this is a no-op - console.log(`🧹 DEV: Would delete weather data older than ${daysToKeep} days`); - } - - async clearWeatherCache(): Promise { - // In development, this is a no-op - console.log('🧹 DEV: Weather cache cleared due to location change'); - } - - async markDeliveryControlValidated(id: number, userId: string): Promise { - const delivery = this.deliveries.get(id); - if (delivery) { - this.deliveries.set(id, { - ...delivery, - controlValidated: true, - controlValidatedBy: userId, - controlValidatedAt: new Date(), - updatedAt: new Date(), - }); - } - } - - // Historique des mails fournisseurs (en mémoire pour le développement) - private supplierMailLogsStore: SupplierMailLog[] = []; - private supplierMailLogIdCounter = 1; - - async createSupplierMailLog(logData: InsertSupplierMailLog): Promise { - const log: SupplierMailLog = { - id: this.supplierMailLogIdCounter++, - deliveryId: logData.deliveryId, - groupId: logData.groupId, - supplierId: logData.supplierId ?? null, - supplierName: logData.supplierName ?? null, - sentTo: logData.sentTo, - subject: logData.subject ?? null, - status: logData.status, - errorMessage: logData.errorMessage ?? null, - messageId: logData.messageId ?? null, - sentBy: logData.sentBy, - sentByName: logData.sentByName ?? null, - createdAt: new Date(), - }; - this.supplierMailLogsStore.unshift(log); - return log; - } - - async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise { - return this.supplierMailLogsStore - .filter(log => - (!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) && - (deliveryId === undefined || log.deliveryId === deliveryId) - ) - .slice(0, 500); - } - - // Webhook BAP Configuration - async getWebhookBapConfig(): Promise { - // En développement, retourner une config par défaut - return { - id: 1, - name: "Configuration BAP", - webhookUrl: "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d", - description: "Configuration par défaut pour développement", - isActive: true, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async createWebhookBapConfig(config: InsertWebhookBapConfig): Promise { - return { - id: 1, - ...config, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async updateWebhookBapConfig(id: number, config: Partial): Promise { - const existing = await this.getWebhookBapConfig(); - return { - ...existing!, - ...config, - updatedAt: new Date() - }; - } - - // Utilities operations - async getUtilities(): Promise { - // MemStorage ne stocke pas les utilities, retourne undefined - return undefined; - } - - async createUtilities(utilitiesData: InsertUtilities): Promise { - // MemStorage ne supporte pas la création de utilities - return { - id: 1, - salesAnalysisUrl: utilitiesData.salesAnalysisUrl ?? null, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - async updateUtilities(id: number, utilitiesData: Partial): Promise { - // MemStorage ne supporte pas la mise à jour de utilities - return { - id, - salesAnalysisUrl: utilitiesData.salesAnalysisUrl ?? null, - createdAt: new Date(), - updatedAt: new Date() - }; - } - - // Announcement operations - async createAnnouncement(announcementData: InsertAnnouncement): Promise { - const announcementStorage = getAnnouncementStorage( - async () => Array.from(this.users.values()), - async () => Array.from(this.groups.values()) - ); - return await announcementStorage.createAnnouncement(announcementData); - } - - async getAnnouncements(groupIds?: number[]): Promise { - const announcementStorage = getAnnouncementStorage( - async () => Array.from(this.users.values()), - async () => Array.from(this.groups.values()) - ); - return await announcementStorage.getAnnouncements(groupIds); - } - - async getAnnouncement(id: number): Promise { - const announcementStorage = getAnnouncementStorage( - async () => Array.from(this.users.values()), - async () => Array.from(this.groups.values()) - ); - return await announcementStorage.getAnnouncement(id); - } - - async updateAnnouncement(id: number, announcementData: Partial): Promise { - const announcementStorage = getAnnouncementStorage( - async () => Array.from(this.users.values()), - async () => Array.from(this.groups.values()) - ); - return await announcementStorage.updateAnnouncement(id, announcementData); - } - - async deleteAnnouncement(id: number): Promise { - const announcementStorage = getAnnouncementStorage( - async () => Array.from(this.users.values()), - async () => Array.from(this.groups.values()) - ); - return await announcementStorage.deleteAnnouncement(id); - } - - // Avoir operations - async getAvoirs(groupIds?: number[]): Promise { - let avoirs = Array.from(this.avoirs.values()); - - // Filter by groups if provided - if (groupIds && groupIds.length > 0) { - avoirs = avoirs.filter(avoir => groupIds.includes(avoir.groupId)); - } - - return avoirs - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .map(avoir => ({ - ...avoir, - supplier: this.suppliers.get(avoir.supplierId)!, - group: this.groups.get(avoir.groupId)!, - creator: this.users.get(avoir.createdBy)!, - })); - } - - async getAvoir(id: number): Promise { - const avoir = this.avoirs.get(id); - if (!avoir) return undefined; - - return { - ...avoir, - supplier: this.suppliers.get(avoir.supplierId)!, - group: this.groups.get(avoir.groupId)!, - creator: this.users.get(avoir.createdBy)!, - }; - } - - async createAvoir(avoirData: InsertAvoir): Promise { - const id = this.idCounters.avoir++; - const avoir: Avoir = { - id, - ...avoirData, - status: 'En attente de demande', - webhookSent: false, - nocodbVerified: false, - nocodbVerifiedAt: null, - processedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.avoirs.set(id, avoir); - return avoir; - } - - async updateAvoir(id: number, avoirData: Partial): Promise { - const existingAvoir = this.avoirs.get(id); - if (!existingAvoir) throw new Error('Avoir not found'); - - const updatedAvoir = { - ...existingAvoir, - ...avoirData, - updatedAt: new Date(), - }; - this.avoirs.set(id, updatedAvoir); - return updatedAvoir; - } - - async deleteAvoir(id: number): Promise { - this.avoirs.delete(id); - } - - async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise { - const avoir = this.avoirs.get(id); - if (avoir) { - avoir.webhookSent = webhookSent; - avoir.updatedAt = new Date(); - this.avoirs.set(id, avoir); - } - } - - async updateAvoirNocodbVerification(id: number, verified: boolean): Promise { - const avoir = this.avoirs.get(id); - if (avoir) { - avoir.nocodbVerified = verified; - avoir.nocodbVerifiedAt = verified ? new Date() : null; - avoir.updatedAt = new Date(); - this.avoirs.set(id, avoir); - } - } - - // Reconciliation Comments operations - async getReconciliationComments(deliveryId: number): Promise { - const comments = Array.from(this.reconciliationComments.values()) - .filter(comment => comment.deliveryId === deliveryId) - .sort((a, b) => new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime()); - - return comments.map(comment => { - const delivery = this.deliveries.get(comment.deliveryId); - const author = this.users.get(comment.authorId); - const group = this.groups.get(comment.groupId); - - if (!delivery || !author || !group) { - throw new Error('Missing related data for reconciliation comment'); - } - - const supplier = this.suppliers.get(delivery.supplierId); - const creator = this.users.get(delivery.createdBy); - - if (!supplier || !creator) { - throw new Error('Missing delivery related data for reconciliation comment'); - } - - return { - ...comment, - delivery: { - ...delivery, - supplier, - group, - creator, - }, - group, - author, - } as ReconciliationCommentWithRelations; - }); - } - - async getReconciliationCommentById(id: number): Promise { - const comment = this.reconciliationComments.get(id); - if (!comment) return undefined; - - const delivery = this.deliveries.get(comment.deliveryId); - const author = this.users.get(comment.authorId); - const group = this.groups.get(comment.groupId); - - if (!delivery || !author || !group) { - throw new Error('Missing related data for reconciliation comment'); - } - - const supplier = this.suppliers.get(delivery.supplierId); - const creator = this.users.get(delivery.createdBy); - - if (!supplier || !creator) { - throw new Error('Missing delivery related data for reconciliation comment'); - } - - return { - ...comment, - delivery: { - ...delivery, - supplier, - group, - creator, - }, - group, - author, - } as ReconciliationCommentWithRelations; - } - - async createReconciliationComment(commentData: InsertReconciliationComment): Promise { - const id = this.idCounters.reconciliationComment++; - const comment: ReconciliationComment = { - id, - ...commentData, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.reconciliationComments.set(id, comment); - return comment; - } - - async updateReconciliationComment(id: number, commentData: Partial): Promise { - const existingComment = this.reconciliationComments.get(id); - if (!existingComment) throw new Error('Reconciliation comment not found'); - - const updatedComment = { - ...existingComment, - ...commentData, - updatedAt: new Date(), - }; - this.reconciliationComments.set(id, updatedComment); - return updatedComment; - } - - async deleteReconciliationComment(id: number): Promise { - this.reconciliationComments.delete(id); - } - - // Analytics implementations for MemStorage - async getAnalyticsSummary(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - status?: string[]; - }): Promise<{ - totalOrders: number; - totalDeliveries: number; - reconciliationRate: number; - totalAmount: number; - avgDeliveryDelay: number; - topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; - topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; - }> { - // Simple implementation for development - const orders = Array.from(this.orders.values()); - const deliveries = Array.from(this.deliveries.values()); - - // Apply filters - const filteredOrders = orders.filter(order => { - if (filters.startDate) { - const orderDate = new Date(order.plannedDate); - if (orderDate < filters.startDate) return false; - } - if (filters.endDate) { - const orderDate = new Date(order.plannedDate); - if (orderDate > filters.endDate) return false; - } - if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return false; - if (filters.groupIds?.length && !filters.groupIds.includes(order.groupId)) return false; - if (filters.status?.length && !filters.status.includes(order.status)) return false; - return true; - }); - - const filteredDeliveries = deliveries.filter(delivery => { - if (filters.startDate) { - const deliveryDate = new Date(delivery.scheduledDate); - if (deliveryDate < filters.startDate) return false; - } - if (filters.endDate) { - const deliveryDate = new Date(delivery.scheduledDate); - if (deliveryDate > filters.endDate) return false; - } - if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return false; - if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return false; - if (filters.status?.length && !filters.status.includes(delivery.status)) return false; - return true; - }); - - // Calculate metrics - const totalOrders = filteredOrders.length; - const totalDeliveries = filteredDeliveries.length; - const reconciledDeliveries = filteredDeliveries.filter(d => - d.reconciled === true || d.reconciled === 1 - ).length; - const reconciliationRate = totalDeliveries > 0 ? (reconciledDeliveries / totalDeliveries) * 100 : 0; - - const totalAmount = filteredDeliveries.reduce((sum, d) => { - return sum + (parseFloat(d.blAmount as any) || 0) + (parseFloat(d.invoiceAmount as any) || 0); - }, 0); - - // Top suppliers - const supplierStats = new Map(); - filteredDeliveries.forEach(d => { - const supplier = this.suppliers.get(d.supplierId); - if (supplier) { - const stats = supplierStats.get(d.supplierId) || { name: supplier.name, count: 0, amount: 0 }; - stats.count++; - stats.amount += parseFloat(d.blAmount as any) || 0; - supplierStats.set(d.supplierId, stats); - } - }); - const topSuppliers = Array.from(supplierStats.entries()) - .map(([id, stats]) => ({ id, ...stats })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); - - // Top stores - const storeStats = new Map(); - filteredOrders.forEach(o => { - const group = this.groups.get(o.groupId); - if (group) { - const stats = storeStats.get(o.groupId) || { name: group.name, orders: 0, deliveries: 0 }; - stats.orders++; - storeStats.set(o.groupId, stats); - } - }); - filteredDeliveries.forEach(d => { - const group = this.groups.get(d.groupId); - if (group) { - const stats = storeStats.get(d.groupId) || { name: group.name, orders: 0, deliveries: 0 }; - stats.deliveries++; - storeStats.set(d.groupId, stats); - } - }); - const topStores = Array.from(storeStats.entries()) - .map(([id, stats]) => ({ id, ...stats })) - .sort((a, b) => (b.orders + b.deliveries) - (a.orders + a.deliveries)) - .slice(0, 5); - - return { - totalOrders, - totalDeliveries, - reconciliationRate, - totalAmount, - avgDeliveryDelay: 0, // Simplified for development - topSuppliers, - topStores - }; - } - - async getAnalyticsTimeseries(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - groupIds?: number[]; - granularity?: 'day' | 'week' | 'month'; - }): Promise> { - const dataMap = new Map(); - - // Process orders - Array.from(this.orders.values()).forEach(order => { - if (filters.startDate && order.plannedDate < filters.startDate) return; - if (filters.endDate && order.plannedDate > filters.endDate) return; - if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return; - if (filters.groupIds?.length && !filters.groupIds.includes(order.groupId)) return; - - const dateKey = order.plannedDate.toISOString().split('T')[0]; - const existing = dataMap.get(dateKey) || { orders: 0, deliveries: 0 }; - existing.orders++; - dataMap.set(dateKey, existing); - }); - - // Process deliveries - Array.from(this.deliveries.values()).forEach(delivery => { - if (filters.startDate && delivery.scheduledDate < filters.startDate) return; - if (filters.endDate && delivery.scheduledDate > filters.endDate) return; - if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return; - if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return; - - const dateKey = delivery.scheduledDate.toISOString().split('T')[0]; - const existing = dataMap.get(dateKey) || { orders: 0, deliveries: 0 }; - existing.deliveries++; - dataMap.set(dateKey, existing); - }); - - return Array.from(dataMap.entries()) - .map(([date, data]) => ({ date, ...data })) - .sort((a, b) => a.date.localeCompare(b.date)); - } - - async getAnalyticsBySupplier(filters: { - startDate?: Date; - endDate?: Date; - groupIds?: number[]; - }): Promise> { - const supplierStats = new Map(); - - Array.from(this.deliveries.values()).forEach(delivery => { - if (filters.startDate && delivery.scheduledDate < filters.startDate) return; - if (filters.endDate && delivery.scheduledDate > filters.endDate) return; - if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return; - - const supplier = this.suppliers.get(delivery.supplierId); - if (supplier) { - const stats = supplierStats.get(delivery.supplierId) || { name: supplier.name, deliveries: 0, amount: 0 }; - stats.deliveries++; - stats.amount += parseFloat(delivery.blAmount as any) || 0; - supplierStats.set(delivery.supplierId, stats); - } - }); - - return Array.from(supplierStats.entries()) - .map(([id, stats]) => ({ supplierId: id, supplierName: stats.name, ...stats })) - .sort((a, b) => b.deliveries - a.deliveries); - } - - async getAnalyticsByStore(filters: { - startDate?: Date; - endDate?: Date; - supplierIds?: number[]; - }): Promise> { - const storeStats = new Map(); - - // Count orders - Array.from(this.orders.values()).forEach(order => { - if (filters.startDate && order.plannedDate < filters.startDate) return; - if (filters.endDate && order.plannedDate > filters.endDate) return; - if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return; - - const group = this.groups.get(order.groupId); - if (group) { - const stats = storeStats.get(order.groupId) || { name: group.name, orders: 0, deliveries: 0 }; - stats.orders++; - storeStats.set(order.groupId, stats); - } - }); - - // Count deliveries - Array.from(this.deliveries.values()).forEach(delivery => { - if (filters.startDate && delivery.scheduledDate < filters.startDate) return; - if (filters.endDate && delivery.scheduledDate > filters.endDate) return; - if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return; - - const group = this.groups.get(delivery.groupId); - if (group) { - const stats = storeStats.get(delivery.groupId) || { name: group.name, orders: 0, deliveries: 0 }; - stats.deliveries++; - storeStats.set(delivery.groupId, stats); - } - }); - - return Array.from(storeStats.entries()) - .map(([id, stats]) => ({ groupId: id, storeName: stats.name, ...stats })) - .sort((a, b) => (b.orders + b.deliveries) - (a.orders + a.deliveries)); - } -} - -// Use DatabaseStorage when DATABASE_URL is available, MemStorage as fallback -const isProduction = process.env.NODE_ENV === 'production'; -const hasDatabase = !!process.env.DATABASE_URL; -console.log('🔗 Database initialization:', { - NODE_ENV: process.env.NODE_ENV, - isProduction: isProduction, - hasDbUrl: hasDatabase, - dbHost: process.env.DATABASE_URL?.split('@')[1]?.split('/')[0] || 'unknown' -}); - -export const storage: IStorage = hasDatabase ? new DatabaseStorage() : new MemStorage(); -if (hasDatabase) { - console.log('✅ Using DatabaseStorage (PostgreSQL database detected)'); -} else { - console.log('🔧 DEV: Using MemStorage (no database URL found)'); -} \ No newline at end of file diff --git a/server/vite.ts b/server/vite.ts deleted file mode 100644 index 29358c5..0000000 --- a/server/vite.ts +++ /dev/null @@ -1,99 +0,0 @@ -import express, { type Express } from "express"; -import fs from "fs"; -import path from "path"; -import { createServer as createViteServer, createLogger } from "vite"; -import { type Server } from "http"; -import viteConfig from "../vite.config"; -import { nanoid } from "nanoid"; - -const viteLogger = createLogger(); - -export function log(message: string, source = "express") { - const formattedTime = new Date().toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - - console.log(`${formattedTime} [${source}] ${message}`); -} - -export async function setupVite(app: Express, server: Server) { - const serverOptions = { - middlewareMode: true, - hmr: { server }, - allowedHosts: true as const, - }; - - const vite = await createViteServer({ - ...viteConfig, - configFile: false, - customLogger: { - ...viteLogger, - error: (msg, options) => { - viteLogger.error(msg, options); - process.exit(1); - }, - }, - server: serverOptions, - appType: "custom", - }); - - app.use(vite.middlewares); - app.use("*", async (req, res, next) => { - const url = req.originalUrl; - - try { - const clientTemplate = path.resolve( - import.meta.dirname, - "..", - "client", - "index.html", - ); - - // always reload the index.html file from disk incase it changes - let template = await fs.promises.readFile(clientTemplate, "utf-8"); - template = template.replace( - `src="/src/main.tsx"`, - `src="/src/main.tsx?v=${nanoid()}"`, - ); - const page = await vite.transformIndexHtml(url, template); - res.status(200).set({ "Content-Type": "text/html" }).end(page); - } catch (e) { - vite.ssrFixStacktrace(e as Error); - next(e); - } - }); -} - -export function serveStatic(app: Express) { - const distPath = path.resolve(import.meta.dirname, "public"); - - if (!fs.existsSync(distPath)) { - throw new Error( - `Could not find the build directory: ${distPath}, make sure to build the client first`, - ); - } - - // Fichiers de /assets hachés par Vite : cache navigateur d'un an - app.use("/assets", express.static(path.resolve(distPath, "assets"), { - maxAge: "1y", - immutable: true, - index: false, - })); - // index.html toujours revalidé pour prendre en compte un nouveau déploiement - app.use(express.static(distPath, { - setHeaders: (res, filePath) => { - if (filePath.endsWith(".html")) { - res.setHeader("Cache-Control", "no-cache"); - } - }, - })); - - // fall through to index.html if the file doesn't exist - app.use("*", (_req, res) => { - res.set("Cache-Control", "no-cache"); - res.sendFile(path.resolve(distPath, "index.html")); - }); -} diff --git a/server/weatherAutoConfig.ts b/server/weatherAutoConfig.ts deleted file mode 100644 index 837471e..0000000 --- a/server/weatherAutoConfig.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Auto-configuration du système météo pour la production - * Configure automatiquement les paramètres météo avec la clé API des variables d'environnement - */ - -import { storage } from "./storage.js"; -import { weatherService } from "./weatherService.js"; - -export async function initializeWeatherConfig() { - try { - console.log('🌤️ [AUTO-CONFIG] Initializing weather configuration...'); - - // Vérifier si la configuration météo existe déjà - const existingSettings = await storage.getWeatherSettings(); - - if (existingSettings && existingSettings.isActive) { - console.log('✅ [AUTO-CONFIG] Weather settings already configured and active'); - return; - } - - // Récupérer la clé API depuis les variables d'environnement - const apiKey = process.env.VISUAL_CROSSING_API_KEY; - - if (!apiKey) { - console.log('⚠️ [AUTO-CONFIG] VISUAL_CROSSING_API_KEY not found in environment variables'); - return; - } - - // Configuration par défaut - const defaultConfig = { - apiKey: apiKey, - location: "Nancy, France", - isActive: true - }; - - console.log('🌤️ [AUTO-CONFIG] Testing API connection...'); - - // Tester la connexion API avant de sauvegarder - const testResult = await weatherService.testApiConnection(defaultConfig.apiKey, defaultConfig.location); - - if (!testResult.success) { - console.error('❌ [AUTO-CONFIG] API test failed:', testResult.message); - return; - } - - console.log('✅ [AUTO-CONFIG] API test successful'); - - // Créer ou mettre à jour la configuration - if (existingSettings) { - // Mettre à jour la configuration existante - await storage.updateWeatherSettings(existingSettings.id, defaultConfig); - console.log('🔄 [AUTO-CONFIG] Weather settings updated'); - } else { - // Créer une nouvelle configuration - await storage.createWeatherSettings(defaultConfig); - console.log('🆕 [AUTO-CONFIG] Weather settings created'); - } - - console.log('✅ [AUTO-CONFIG] Weather system successfully configured'); - - } catch (error) { - console.error('❌ [AUTO-CONFIG] Failed to initialize weather config:', error); - } -} \ No newline at end of file diff --git a/server/weatherService.ts b/server/weatherService.ts deleted file mode 100644 index 8a681d4..0000000 --- a/server/weatherService.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { WeatherData, WeatherSettings, InsertWeatherData } from "../shared/schema.js"; - -export interface WeatherApiResponse { - days: Array<{ - datetime: string; - tempmax: number; - tempmin: number; - icon: string; - conditions: string; - }>; -} - -export interface WeatherDisplayData { - currentYear: WeatherData | null; - previousYear: WeatherData | null; -} - -export class WeatherService { - private baseUrl = "https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline"; - - /** - * Récupère les données météo actuelles depuis l'API Visual Crossing - */ - async fetchCurrentWeather(settings: WeatherSettings): Promise { - try { - const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json&lang=fr`; - - const response = await fetch(url); - if (!response.ok) { - const reason = (await response.text().catch(() => '')).slice(0, 300); - console.error(`Weather API error: ${response.status} ${response.statusText} ${reason}`); - return null; - } - - const data = await response.json(); - return data; - } catch (error) { - console.error("Erreur lors de la récupération de la météo actuelle:", error); - return null; - } - } - - /** - * Récupère les données météo de l'année précédente pour la même date - */ - async fetchPreviousYearWeather(settings: WeatherSettings, targetDate: string): Promise { - try { - // Pour les données historiques, Visual Crossing utilise la syntaxe: /timeline/location/date - const url = `${this.baseUrl}/${encodeURIComponent(settings.location)}/${targetDate}?unitGroup=metric&include=days&key=${settings.apiKey}&contentType=json&lang=fr`; - - console.log(`🌤️ [FETCH-HISTORY] Requesting: ${settings.location} for ${targetDate}`); - - const response = await fetch(url); - if (!response.ok) { - // Visual Crossing explique le refus dans le corps (quota, plan, clé...) - const reason = (await response.text().catch(() => '')).slice(0, 300); - console.error(`🌤️ [ERROR] Weather API error for previous year: ${response.status} ${response.statusText} ${reason}`); - - // Pour les données historiques, certains comptes peuvent ne pas avoir accès - if (response.status === 401) { - console.warn(`⚠️ [WARNING] Historical data access may not be available with current API key`); - } - return null; - } - - const data = await response.json(); - console.log(`✅ [FETCH-HISTORY] Successfully retrieved data for ${targetDate}`); - return data; - } catch (error) { - console.error("🌤️ [ERROR] Erreur lors de la récupération de la météo de l'année précédente:", error); - return null; - } - } - - /** - * Traduit les conditions météo en français - */ - private translateConditionToFrench(condition: string): string { - const conditionLower = condition.toLowerCase(); - - // Traductions anglais -> français - if (conditionLower.includes('clear')) return 'Dégagé'; - if (conditionLower.includes('sunny')) return 'Ensoleillé'; - if (conditionLower.includes('fair')) return 'Beau temps'; - if (conditionLower.includes('partly cloudy')) return 'Partiellement nuageux'; - if (conditionLower.includes('cloudy')) return 'Nuageux'; - if (conditionLower.includes('overcast')) return 'Couvert'; - if (conditionLower.includes('rain')) { - if (conditionLower.includes('heavy')) return 'Pluie forte'; - if (conditionLower.includes('light')) return 'Pluie légère'; - return 'Pluie'; - } - if (conditionLower.includes('shower')) return 'Averses'; - if (conditionLower.includes('drizzle')) return 'Bruine'; - if (conditionLower.includes('thunderstorm') || conditionLower.includes('thunder')) return 'Orage'; - if (conditionLower.includes('snow')) { - if (conditionLower.includes('heavy')) return 'Neige forte'; - if (conditionLower.includes('light')) return 'Neige légère'; - return 'Neige'; - } - if (conditionLower.includes('fog')) return 'Brouillard'; - if (conditionLower.includes('mist')) return 'Brume'; - if (conditionLower.includes('wind')) return 'Venteux'; - - // Si déjà en français, retourner tel quel - return condition; - } - - /** - * Convertit les données de l'API en format WeatherData avec traduction française forcée - */ - convertApiDataToWeatherData(apiData: WeatherApiResponse, location: string, isCurrentYear: boolean): InsertWeatherData | null { - if (!apiData.days || apiData.days.length === 0) { - return null; - } - - const todayData = apiData.days[0]; - - return { - // Pas d'ID - sera auto-généré par PostgreSQL serial - date: todayData.datetime, - location, - tempMax: todayData.tempmax.toString(), - tempMin: todayData.tempmin.toString(), - icon: todayData.icon, - conditions: this.translateConditionToFrench(todayData.conditions), // Traduction forcée - isCurrentYear - // Pas de createdAt/updatedAt - gérés par la base de données - }; - } - - /** - * Calcule la date de l'année précédente pour le même jour de la semaine - */ - getPreviousYearDate(date: Date = new Date()): string { - const currentYear = date.getFullYear(); - const currentMonth = date.getMonth(); - const currentDayOfWeek = date.getDay(); // 0 = dimanche, 1 = lundi, etc. - - // Créer une date pour l'année précédente, même mois - const previousYear = new Date(currentYear - 1, currentMonth, 1); - - // Trouver le premier jour de la semaine correspondant dans ce mois - const firstDayOfMonth = previousYear.getDay(); - const daysToAdd = (currentDayOfWeek - firstDayOfMonth + 7) % 7; - - // Calculer quelle semaine nous sommes dans le mois actuel - const currentDate = date.getDate(); - const currentWeekOfMonth = Math.ceil(currentDate / 7); - - // Aller à la même semaine du mois l'année précédente - const targetDate = new Date(currentYear - 1, currentMonth, 1 + daysToAdd + (currentWeekOfMonth - 1) * 7); - - // Si la date calculée dépasse le mois, prendre la dernière occurrence de ce jour dans le mois - if (targetDate.getMonth() !== currentMonth) { - // Revenir en arrière d'une semaine - targetDate.setDate(targetDate.getDate() - 7); - } - - console.log(`🗓️ [DATE-CALC] Aujourd'hui: ${date.toISOString().split('T')[0]} (${this.getDayName(currentDayOfWeek)})`); - console.log(`🗓️ [DATE-CALC] Année précédente: ${targetDate.toISOString().split('T')[0]} (${this.getDayName(targetDate.getDay())})`); - - return targetDate.toISOString().split('T')[0]; // Format YYYY-MM-DD - } - - /** - * Retourne le nom du jour de la semaine - */ - private getDayName(dayOfWeek: number): string { - const days = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; - return days[dayOfWeek]; - } - - /** - * Convertit l'icône météo en format d'affichage - */ - getWeatherIcon(iconCode: string): string { - const iconMap: { [key: string]: string } = { - 'clear-day': '☀️', - 'clear-night': '🌙', - 'partly-cloudy-day': '⛅', - 'partly-cloudy-night': '☁️', - 'cloudy': '☁️', - 'rain': '🌧️', - 'snow': '❄️', - 'wind': '💨', - 'fog': '🌫️', - 'thunder': '⛈️', - 'hail': '🌨️' - }; - - return iconMap[iconCode] || '🌤️'; - } - - /** - * Valide les paramètres de configuration météo - */ - validateWeatherSettings(settings: Partial): { isValid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!settings.apiKey || settings.apiKey.trim().length === 0) { - errors.push("La clé API Visual Crossing est requise"); - } - - if (!settings.location || settings.location.trim().length === 0) { - errors.push("La localisation est requise"); - } - - // Vérification basique du format de la clé API Visual Crossing - if (settings.apiKey && settings.apiKey.length < 10) { - errors.push("La clé API semble invalide (trop courte)"); - } - - return { - isValid: errors.length === 0, - errors - }; - } - - /** - * Test de connexion à l'API météo - */ - async testApiConnection(apiKey: string, location: string): Promise<{ success: boolean; message: string }> { - try { - const testUrl = `${this.baseUrl}/${encodeURIComponent(location)}?unitGroup=metric&include=days&key=${apiKey}&contentType=json`; - - const response = await fetch(testUrl); - - if (response.ok) { - const data = await response.json(); - if (data.days && data.days.length > 0) { - return { success: true, message: "Connexion réussie à l'API météo" }; - } else { - return { success: false, message: "Aucune donnée météo trouvée pour cette localisation" }; - } - } else if (response.status === 401) { - return { success: false, message: "Clé API invalide" }; - } else if (response.status === 404) { - return { success: false, message: "Localisation introuvable" }; - } else { - return { success: false, message: `Erreur API: ${response.status} ${response.statusText}` }; - } - } catch (error) { - console.error("Erreur test API météo:", error); - return { success: false, message: "Erreur de connexion à l'API météo" }; - } - } - - /** - * Convertit les coordonnées GPS en nom de ville via l'API de géocodage inverse Nominatim (OpenStreetMap) - */ - async getCityFromCoordinates(latitude: number, longitude: number, apiKey: string): Promise<{ city: string; country: string; fullLocation: string } | null> { - try { - // Utiliser l'API Nominatim (OpenStreetMap) pour la géolocalisation inverse - const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&accept-language=fr`; - - console.log('🌍 Géolocalisation via Nominatim:', { latitude, longitude }); - - const response = await fetch(nominatimUrl, { - headers: { - 'User-Agent': 'LogiFlow-Weather-System/1.0 (Enterprise Weather Management Platform)' - } - }); - - if (!response.ok) { - console.error(`Nominatim API error: ${response.status} ${response.statusText}`); - return null; - } - - const data = await response.json(); - - console.log('🌍 Nominatim Response:', JSON.stringify(data, null, 2)); - - if (data && data.address) { - const address = data.address; - - // Extraire la ville (peut être dans city, town, village, municipality, etc.) - const city = address.city || address.town || address.village || address.municipality || address.hamlet || 'Ville inconnue'; - const country = address.country || 'Pays inconnu'; - - // Créer une adresse lisible pour Visual Crossing - const fullLocation = `${city}, ${country}`; - - console.log('🌍 Parsed location:', { city, country, fullLocation }); - - return { - city, - country, - fullLocation - }; - } - - console.log('🌍 No address found in Nominatim response'); - return null; - } catch (error) { - console.error("Erreur lors de la géolocalisation:", error); - return null; - } - } -} - -export const weatherService = new WeatherService(); \ No newline at end of file diff --git a/shared/permissions.json b/shared/permissions.json new file mode 100644 index 0000000..3d242a2 --- /dev/null +++ b/shared/permissions.json @@ -0,0 +1,207 @@ +{ + "dashboard": { + "admin": [ + "view" + ], + "directeur": [ + "view" + ], + "manager": [ + "view" + ], + "employee": [ + "view" + ] + }, + "calendar": { + "admin": [ + "view", + "create", + "edit", + "delete" + ], + "directeur": [ + "view", + "create", + "edit", + "delete" + ], + "manager": [ + "view", + "create", + "edit" + ], + "employee": [ + "view" + ] + }, + "orders": { + "admin": [ + "view", + "create", + "edit", + "delete" + ], + "directeur": [ + "view", + "create", + "edit", + "delete" + ], + "manager": [ + "view", + "create", + "edit" + ], + "employee": [] + }, + "deliveries": { + "admin": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "directeur": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "manager": [ + "view", + "create", + "edit", + "validate" + ], + "employee": [] + }, + "reconciliation": { + "admin": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "directeur": [ + "view", + "create", + "edit", + "validate" + ], + "manager": [], + "employee": [] + }, + "publicity": { + "admin": [ + "view", + "create", + "edit", + "delete" + ], + "directeur": [ + "view" + ], + "manager": [ + "view" + ], + "employee": [ + "view" + ] + }, + "customer-orders": { + "admin": [ + "view", + "create", + "edit", + "delete" + ], + "directeur": [ + "view", + "create", + "edit", + "delete" + ], + "manager": [ + "view", + "create", + "edit" + ], + "employee": [ + "view", + "create" + ] + }, + "dlc": { + "admin": [ + "view", + "create", + "edit", + "delete" + ], + "directeur": [ + "view", + "create", + "edit", + "delete" + ], + "manager": [ + "view", + "create", + "edit" + ], + "employee": [ + "view", + "create" + ] + }, + "tasks": { + "admin": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "directeur": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "manager": [ + "view", + "create", + "edit", + "validate" + ], + "employee": [ + "view" + ] + }, + "avoir": { + "admin": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "directeur": [ + "view", + "create", + "edit", + "delete", + "validate" + ], + "manager": [ + "view", + "create" + ], + "employee": [] + } +} diff --git a/shared/permissions.ts b/shared/permissions.ts index 6d96070..bd3e344 100644 --- a/shared/permissions.ts +++ b/shared/permissions.ts @@ -1,5 +1,6 @@ // Système de permissions hardcodé par module // Remplace l'ancien système flexible basé sur la base de données +import PERMISSIONS_DATA from "./permissions.json"; export type Permission = 'view' | 'create' | 'edit' | 'delete' | 'validate'; export type Role = 'admin' | 'directeur' | 'manager' | 'employee'; @@ -15,88 +16,19 @@ export type Module = | 'tasks' | 'avoir'; -// Définition des permissions par module et par rôle -const PERMISSIONS: Record> = { - // Tableau de bord - tous les rôles peuvent le voir - dashboard: { - admin: ['view'], - directeur: ['view'], - manager: ['view'], - employee: ['view'] - }, - - // Calendrier - Admin/Directeur tout, Manager tout sauf delete, Employé view - calendar: { - admin: ['view', 'create', 'edit', 'delete'], - directeur: ['view', 'create', 'edit', 'delete'], - manager: ['view', 'create', 'edit'], - employee: ['view'] - }, - - // Commandes - Admin/Directeur tout, Manager tout sauf delete, Employé aucun accès - orders: { - admin: ['view', 'create', 'edit', 'delete'], - directeur: ['view', 'create', 'edit', 'delete'], - manager: ['view', 'create', 'edit'], - employee: [] - }, - - // Livraisons - Admin/Directeur tout + validate, Manager tout sauf delete + validate, Employé aucun accès - deliveries: { - admin: ['view', 'create', 'edit', 'delete', 'validate'], - directeur: ['view', 'create', 'edit', 'delete', 'validate'], - manager: ['view', 'create', 'edit', 'validate'], - employee: [] - }, - - // Rapprochement - Admin tout y compris validate, Directeur tout sauf delete, Manager/Employé rien - reconciliation: { - admin: ['view', 'create', 'edit', 'delete', 'validate'], - directeur: ['view', 'create', 'edit', 'validate'], - manager: [], - employee: [] - }, - - // Publicité - Admin tout, autres view seulement - publicity: { - admin: ['view', 'create', 'edit', 'delete'], - directeur: ['view'], - manager: ['view'], - employee: ['view'] - }, - - // Commandes client - Admin/Directeur tout, Manager tout sauf delete, Employé view + create - 'customer-orders': { - admin: ['view', 'create', 'edit', 'delete'], - directeur: ['view', 'create', 'edit', 'delete'], - manager: ['view', 'create', 'edit'], - employee: ['view', 'create'] - }, - - // DLC - Admin/Directeur tout, Manager tout sauf delete, Employé create + view - dlc: { - admin: ['view', 'create', 'edit', 'delete'], - directeur: ['view', 'create', 'edit', 'delete'], - manager: ['view', 'create', 'edit'], - employee: ['view', 'create'] - }, - - // Tâches - Admin/Directeur tout, Manager view + create + edit + validate, Employé view seulement - tasks: { - admin: ['view', 'create', 'edit', 'delete', 'validate'], - directeur: ['view', 'create', 'edit', 'delete', 'validate'], - manager: ['view', 'create', 'edit', 'validate'], - employee: ['view'] - }, - - // Avoirs - Admin/Directeur tout, Manager create + view seulement, Employé pas d'accès - avoir: { - admin: ['view', 'create', 'edit', 'delete', 'validate'], - directeur: ['view', 'create', 'edit', 'delete', 'validate'], - manager: ['view', 'create'], - employee: [] - } -}; +// Matrice rôle × module dans permissions.json, lue aussi par le serveur Rust +// (server-rs/src/access.rs). Résumé : +// - dashboard : tous les rôles voient +// - calendar : admin/directeur tout, manager tout sauf supprimer, employé voit +// - orders : admin/directeur tout, manager tout sauf supprimer, employé aucun accès +// - deliveries : admin/directeur tout + valider, manager tout sauf supprimer + valider, employé aucun accès +// - reconciliation : admin tout, directeur tout sauf supprimer, manager/employé rien +// - publicity : admin tout, autres voient +// - customer-orders : admin/directeur tout, manager tout sauf supprimer, employé voit + crée +// - dlc : admin/directeur tout, manager tout sauf supprimer, employé voit + crée +// - tasks : admin/directeur tout, manager voit + crée + modifie + valide, employé voit +// - avoir : admin/directeur tout, manager crée + voit, employé aucun accès +const PERMISSIONS = PERMISSIONS_DATA as Record>; // Hook pour vérifier les permissions export function usePermissions(userRole?: string) { diff --git a/tsconfig.json b/tsconfig.json index 3537c42..a06f5c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,7 @@ { "include": [ "client/src/**/*", - "shared/**/*", - "server/**/*" + "shared/**/*" ], "exclude": [ "node_modules", @@ -27,12 +26,12 @@ "skipLibCheck": true, "allowImportingTsExtensions": true, "moduleResolution": "bundler", + "resolveJsonModule": true, "downlevelIteration": true, "baseUrl": ".", "types": [ "node", "vite/client", - "express", "react", "react-dom" ], diff --git a/tsconfig.server.json b/tsconfig.server.json deleted file mode 100644 index 0126094..0000000 --- a/tsconfig.server.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "bundler", - "target": "ES2022", - "outDir": "./dist/server", - "rootDir": "./server", - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": false, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true - }, - "include": ["server/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vite.config.ts b/vite.config.ts index 859ffb0..7d6b6be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,6 +52,10 @@ export default defineConfig({ }, }, server: { + // En développement, l'API est servie par le serveur Rust (npm run dev:server) + proxy: { + "/api": "http://localhost:3000", + }, fs: { strict: true, deny: ["**/.*"],