Author SHA1 Message Date
Claude 1a64a73d5a feat(api): gérer les clés de l'API externe depuis Paramètres
Les clés de l'API de rapprochement n'étaient configurables que par la
variable d'environnement EXTERNAL_API_KEYS. Nouvel onglet Paramètres >
API externe (admin) :

- état de l'API, adresse à copier, rappel des principales routes
- création d'une clé nommée par outil, affichée une seule fois
- liste des clés avec préfixe, date de création et de dernière
  utilisation ; révocation immédiate, sans redémarrage

Côté serveur :
- table external_api_keys (empreinte SHA-256 uniquement), créée par
  migrations.production.ts et init.sql
- server/externalApiKeys.ts : création, liste, révocation, vérification
  (repli en mémoire sans base)
- l'API accepte les clés de Paramètres et toujours celles de
  EXTERNAL_API_KEYS ; 503 seulement si aucune clé active
- routes /api/external-api/keys (session + admin)
- documentation et .env.example mis à jour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
2026-10-10 07:52:43 +00:00
146 changed files with 20332 additions and 31549 deletions

No files matched your search

+1 -8
View File
@@ -93,11 +93,4 @@ ssl/
# Backup files
*.bak
*.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/
*.backup
-10
View File
@@ -1,10 +0,0 @@
# 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
-90
View File
@@ -1,90 +0,0 @@
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
- name: Fichier docker-compose valide
run: docker compose -f docker-compose.yml config -q
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
+1 -3
View File
@@ -1,6 +1,7 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
@@ -12,6 +13,3 @@ cookie*.txt
# Artefacts de debug locaux
*.log
scratch/
# Serveur Node de référence du banc (scripts/bench/reference-node.sh)
.bench-reference/
+107 -34
View File
@@ -1,49 +1,122 @@
# 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.
# Multi-stage build for optimized production image
FROM node:20-alpine AS build
# Interface (dist/public)
FROM node:20-alpine AS client-build
# Set working directory
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 tsconfig.json vite.config.ts tailwind.config.ts postcss.config.js components.json ./
# 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 client/ client/
COPY server/ server/
COPY shared/ shared/
RUN NODE_ENV=production npx vite build && test -f dist/public/index.html
# 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
# Vérifier la structure
RUN ls -la server/
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
# 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
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
ENV PORT=3000 \
STATIC_DIR=/app/dist/public \
BACKUP_DIR=/app/backups \
NODE_ENV=production
# 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
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
CMD ["/app/bin/logiflow"]
# 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"]
+17 -28
View File
@@ -44,8 +44,8 @@ Une plateforme d'entreprise moderne pour la gestion logistique, le suivi des liv
## 🚀 Installation et Configuration
### Prérequis
- Rust stable (serveur) et Node.js 20+ (construction de l'interface uniquement)
- PostgreSQL 13+ et `pg_dump` (sauvegardes)
- Node.js 18+
- PostgreSQL 13+
- Clé API Visual Crossing (gratuite sur [visualcrossing.com](https://www.visualcrossing.com/))
### Installation
@@ -61,18 +61,13 @@ npm install
cp .env.example .env
# Éditer .env avec vos paramètres de base de données
# Démarrer le serveur (API, migrations au démarrage) sur le port 3000
npm run dev:server
# Initialiser la base de données
npm run db:push
# Dans un autre terminal : l'interface avec rechargement à chaud (Vite),
# qui relaie /api vers le serveur
# Démarrer l'application
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
@@ -127,9 +122,11 @@ En production : `npm run build` (interface dans `dist/public`, binaire dans
- **Wouter** : Routage léger et performant
### Backend
- **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
- **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
### Base de Données
- **PostgreSQL** : Base de données relationnelle performante
@@ -149,16 +146,13 @@ En production : `npm run build` (interface dans `dist/public`, binaire dans
│ │ ├── pages/ # Pages de l'application
│ │ ├── lib/ # Utilitaires et configuration
│ │ └── hooks/ # Hooks React personnalisés
├── 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
├── 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
├── shared/ # Code partagé
│ ├── 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
│ └── schema.ts # Schémas de base de données
├── migrations/ # Migrations de base de données
└── backups/ # Dossier des sauvegardes
```
@@ -174,13 +168,8 @@ 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
+2 -2
View File
@@ -35,7 +35,7 @@ export interface NavItem {
}
export interface NavSection {
// Pas de titre pour les entrées Accueil et Calendrier, affichées en tête
// Pas de titre pour l'entrée Accueil, affichée en tête
title?: string;
items: NavItem[];
}
@@ -44,7 +44,6 @@ export const NAV_SECTIONS: NavSection[] = [
{
items: [
{ path: "/", label: "Accueil", icon: House, roles: ALL_ROLES },
{ path: "/calendar", label: "Calendrier", icon: CalendarDays, roles: ALL_ROLES },
],
},
{
@@ -69,6 +68,7 @@ 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"] },
+15
View File
@@ -0,0 +1,15 @@
# 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
+13 -21
View File
@@ -1,22 +1,5 @@
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:
@@ -25,9 +8,17 @@ services:
target: production
restart: unless-stopped
environment:
<<: *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
PORT: 3000
RUST_LOG: info
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:-}
ports:
- "3000:3000"
networks:
@@ -36,13 +27,14 @@ 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
start_period: 40s
logiflow-db:
image: postgres:15-alpine
@@ -74,4 +66,4 @@ volumes:
networks:
nginx_default:
external: true
external: true
-383
View File
@@ -1,383 +0,0 @@
# 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. |
+12 -13
View File
@@ -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 |
| | 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 |
| **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 |
| | 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,7 +400,6 @@ 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).
@@ -415,7 +414,6 @@ 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) |
@@ -425,6 +423,7 @@ 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) |
+98
View File
@@ -0,0 +1,98 @@
#!/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 };
+103
View File
@@ -0,0 +1,103 @@
// 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 };
+2163 -87
View File
File diff suppressed because it is too large. Load diff
+53 -8
View File
@@ -1,20 +1,19 @@
{
"name": "logiflow",
"name": "rest-express",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"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",
"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",
"check": "tsc",
"db:push": "drizzle-kit push",
"gen:rust-schema": "tsx scripts/gen-rust-schema.ts"
"db:push": "drizzle-kit push"
},
"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",
@@ -43,20 +42,49 @@
"@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",
@@ -68,25 +96,42 @@
"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",
"zod": "^3.24.2"
"ws": "^8.18.0",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
},
"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"
}
}
+1 -1
View File
@@ -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:** 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.
- **Backend:** Node.js Express (ESM) provides a robust API layer. Data validation is performed using Zod schemas.
- **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.
-50
View File
@@ -1,50 +0,0 @@
# 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 `<maintenant>` ; `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.
-1
View File
@@ -1 +0,0 @@
8f4d799016e6dcd75cbef17ad27193f47b79300a
-299
View File
@@ -1,299 +0,0 @@
// 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_<millisecondes>_<aléatoire>
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"],
},
];
@@ -1,365 +0,0 @@
// 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"],
},
];
@@ -1,410 +0,0 @@
// 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"],
},
];
@@ -1,313 +0,0 @@
// 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"],
},
];
-24
View File
@@ -1,24 +0,0 @@
// 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);
-251
View File
@@ -1,251 +0,0 @@
// 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];
-359
View File
@@ -1,359 +0,0 @@
// 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"],
},
];
-49
View File
@@ -1,49 +0,0 @@
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"],
},
];
-66
View File
@@ -1,66 +0,0 @@
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: [],
},
];
-438
View File
@@ -1,438 +0,0 @@
// 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<any>((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<any>((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<string, string>): 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<string, string> {
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<Target> {
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<string, string>;
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<Record<string, string>> {
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<string, unknown>;
if (step.bodyType === "form") {
return { body: new URLSearchParams(data as Record<string, string>).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<string, string>, results: unknown[]): Promise<Recorded> {
const { body, contentType } = buildBody(step, results);
const headers: Record<string, string> = { ...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<string, string> = {};
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<unknown[]> {
const res = await fetch(`${STUB_URL}/__calls`, { method: reset ? "DELETE" : "GET" });
return res.json() as Promise<unknown[]>;
}
async function snapshotTables(database: string, tables: string[]) {
const client = new pg.Client({ connectionString: dbUrl(database) });
await client.connect();
const out: Record<string, unknown[]> = {};
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] ? "<maintenant>" : 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) ? "<ignoré>" : 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<Entry[]> {
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<AccountKey, Record<string, string>>();
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<string, { total: number; ko: number }>();
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); });
-49
View File
@@ -1,49 +0,0 @@
// 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",
};
-34
View File
@@ -1,34 +0,0 @@
// 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];
}
-23
View File
@@ -1,23 +0,0 @@
#!/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=<dossier>
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"
-47
View File
@@ -1,47 +0,0 @@
// 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<string, Map<string, string>>();
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<string, Map<string, string>>();
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); });
-20
View File
@@ -1,20 +0,0 @@
#!/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
-211
View File
@@ -1,211 +0,0 @@
// 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, '<banc-1@banc.test>', '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); });
-8
View File
@@ -1,8 +0,0 @@
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.
-84
View File
@@ -1,84 +0,0 @@
// 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<unknown>;
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<string>();
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]);
}
}
-52
View File
@@ -1,52 +0,0 @@
// 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<unknown>;
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`]);
}
-25
View File
@@ -1,25 +0,0 @@
// 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<unknown>;
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')`);
}
-63
View File
@@ -1,63 +0,0 @@
// 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<unknown>;
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')`);
}
-23
View File
@@ -1,23 +0,0 @@
// 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<unknown>;
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')`);
}
-18
View File
@@ -1,18 +0,0 @@
// 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<unknown>;
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]);
}
}
@@ -1,2 +0,0 @@
-- Sauvegarde du banc (banc-sauvegarde-1)
SELECT 1;
@@ -1,2 +0,0 @@
-- Sauvegarde manuelle du banc (banc-sauvegarde-500)
SELECT 500;
-44
View File
@@ -1,44 +0,0 @@
#!/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)."
-287
View File
@@ -1,287 +0,0 @@
// 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<string, unknown>)[column] ?? "") === value);
}
function readBody(req: http.IncomingMessage): Promise<Buffer> {
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<string, string> {
const headers: Record<string, string> = {};
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 <adresse> » 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<string, string> } {
const [main, ...rest] = (value ?? "").split(";");
const params: Record<string, string> = {};
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<string, string>, 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<string, unknown> = { 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<string, unknown> {
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<void>> {
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<void>((done) => server.close(() => smtp.close(() => done()))));
});
});
});
}
-49
View File
@@ -1,49 +0,0 @@
// 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<string, string>;
}
// É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;
}
+23
View File
@@ -0,0 +1,23 @@
#!/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);
}
+163
View File
@@ -0,0 +1,163 @@
#!/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 "$@"
-224
View File
@@ -1,224 +0,0 @@
// 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<string>();
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<string, ColumnInfo[]>();
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);
});
-1
View File
@@ -1 +0,0 @@
/target
-3188
View File
File diff suppressed because it is too large. Load diff
-57
View File
@@ -1,57 +0,0 @@
[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
-71
View File
@@ -1,71 +0,0 @@
# 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<String>` 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::<XRow>`, `db::update_by_id::<XRow>(…, 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/<lot>.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.
-50
View File
@@ -1,50 +0,0 @@
# 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`.
-54
View File
@@ -1,54 +0,0 @@
// 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<String> = 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<String>) {
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());
}
}
}
-1
View File
@@ -1 +0,0 @@
max_width = 110
-35
View File
@@ -1,35 +0,0 @@
// 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<String, HashMap<String, Vec<String>>>;
static PERMISSIONS: LazyLock<Matrix> = 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"));
}
}
-257
View File
@@ -1,257 +0,0 @@
// 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<String>,
#[serde(rename = "createdAt", serialize_with = "crate::json::pg_text_opt")]
pub created_at: Option<NaiveDateTime>,
#[serde(rename = "updatedAt", serialize_with = "crate::json::pg_text_opt")]
pub updated_at: Option<NaiveDateTime>,
}
#[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<UserGroup>,
}
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<i32> {
self.user_groups
.iter()
.map(|membership| membership.group_id)
.collect()
}
}
/// Utilisateur connecté obligatoire : sinon 401 « Authentification requise »
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
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<CurrentUser>);
impl FromRequestParts<AppState> for MaybeUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
Ok(Self(load_current_user(&parts.headers, state).await?))
}
}
async fn load_current_user(headers: &HeaderMap, state: &AppState) -> Result<Option<CurrentUser>, 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<Option<CurrentUser>, 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<String>, Option<NaiveDateTime>, Option<NaiveDateTime>)>(
"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<String> {
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<String> {
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<String> {
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:<sid>.<signature> », signature = HMAC-SHA256(secret, sid) en base64 sans « = »
/// (paquet cookie-signature)
fn unsign(value: &str, secret: &str) -> Option<String> {
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::<Sha256>::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);
}
}
-69
View File
@@ -1,69 +0,0 @@
// 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<Tz> {
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"));
}
}
-60
View File
@@ -1,60 +0,0 @@
// 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<String>,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
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(),
})
}
}
-92
View File
@@ -1,92 +0,0 @@
// 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:<iv>:<tag>:<données>
// 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<String, &'static str> {
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"), "");
}
}
-313
View File
@@ -1,313 +0,0 @@
// 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<String> {
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<String> {
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<i32> {
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<i32> {
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<String> = 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<T: Table>(db: &PgPool, id: i32) -> Result<Option<T>, 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<T: Table>(db: &PgPool, ids: &[i32]) -> Result<Vec<T>, 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<T: Table>(alias: &str) -> String {
T::COLUMNS
.split(", ")
.map(|column| format!("{alias}.{column}"))
.collect::<Vec<_>>()
.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<T: Table>(data: &serde_json::Map<String, Value>) -> Result<Parsed, String> {
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<T: Table>(db: &PgPool, data: &Parsed) -> Result<T, sqlx::Error> {
insert_with::<T>(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<T: Table>(db: &PgPool, data: &Parsed) -> Result<T, sqlx::Error> {
insert_with::<T>(db, data, true).await
}
async fn insert_with<T: Table>(db: &PgPool, data: &Parsed, values_first: bool) -> Result<T, sqlx::Error> {
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<String> = 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<T: Table>(
db: &PgPool,
id: i32,
data: &Parsed,
touch_updated_at: bool,
) -> Result<Option<T>, 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<T: Table>(db: &PgPool, id: i32) -> Result<u64, sqlx::Error> {
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<String, Value> {
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::<CustomerOrdersRow>(&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::<CustomerOrdersRow>(&object(json!({ "createdAt": "2026-10-15" }))).unwrap_err();
assert_eq!(error, "value.toISOString is not a function");
}
#[test]
fn colonnes_prefixees() {
assert!(columns_of::<CustomerOrdersRow>("co").starts_with("co.\"id\", co.\"order_taker\""));
}
}
-87
View File
@@ -1,87 +0,0 @@
// 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<T = Response> = Result<T, AppError>;
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<Value>) -> 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<T> {
fn or_500(self, message: &str) -> Result<T, AppError>;
}
impl<T, E: std::fmt::Display> OrInternal<T> for Result<T, E> {
fn or_500(self, message: &str) -> Result<T, AppError> {
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<T: serde::Serialize>(value: Option<T>) -> Response {
match value {
Some(value) => Json(value).into_response(),
None => empty_json(),
}
}
-139
View File
@@ -1,139 +0,0 @@
// 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<matchit::Router<&'static str>> = 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<Router> = 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::<Vec<_>>()
.join("/")
}
/// Adresse qu'Express servirait pour `path`, si elle diffère de `path`
pub fn canonical_path(path: &str) -> Option<String> {
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::<Vec<_>>()
.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<Response, Request> {
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::<Uri>() 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());
}
}
-116
View File
@@ -1,116 +0,0 @@
// 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/"<taille en hexadécimal>-<SHA-1 base64, 27 caractères>", 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}")
);
}
}
-197
View File
@@ -1,197 +0,0 @@
// 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<Option<(String, Option<String>)>, 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<String> = 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<String>) -> anyhow::Result<()> {
let enabled: Option<(Option<bool>,)> =
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<NaiveDateTime>,)> = 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"),
}
}
}
-542
View File
@@ -1,542 +0,0 @@
// 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::<f64>().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<f64> {
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::<f64>().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::<f64>()
.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<u8> {
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::<i32>().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::<Vec<_>>()
.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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<Utc>) -> 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::<String>()
};
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);
}
}
-158
View File
@@ -1,158 +0,0 @@
// 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<S: Serializer>(value: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&format_ts(value))
}
pub fn ts_opt<S: Serializer>(value: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
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<S: Serializer>(value: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&format_pg_text(value))
}
pub fn pg_text_opt<S: Serializer>(value: &Option<NaiveDateTime>, serializer: S) -> Result<S::Ok, S::Error> {
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<const SCALE: u32, S: Serializer>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error> {
let mut value = *value;
if value.scale() < SCALE {
value.rescale(SCALE);
}
serializer.serialize_str(&value.to_string())
}
pub fn numeric_opt<const SCALE: u32, S: Serializer>(
value: &Option<Decimal>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match value {
Some(value) => numeric::<SCALE, S>(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<T: serde::Serialize>(row: &T) -> serde_json::Map<String, serde_json::Value> {
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<Decimal>,
}
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");
}
}
-97
View File
@@ -1,97 +0,0 @@
// 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<Config>,
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::<SocketAddr>())
.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é");
}
-329
View File
@@ -1,329 +0,0 @@
// 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<AppState> {
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<i32>,
is_nullable: String,
column_default: Option<String>,
}
struct Table {
name: String,
columns: Vec<Column>,
/// Nombre de lignes, ou message de l'erreur de comptage
count: Result<i64, String>,
}
#[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<Table>, Vec<ForeignKey>), 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<Column> = 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<ForeignKey> = 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<i64, String>) -> 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<AppState>, 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<AppState>, 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)"
);
}
}
-89
View File
@@ -1,89 +0,0 @@
// 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<AppState>, 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<AppState>,
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<UsersRow> = 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())
}
-564
View File
@@ -1,564 +0,0 @@
// 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<u64>,
rows: Vec<Map<String, Value>>,
}
async fn run(db: &sqlx::PgPool, sql: &str) -> Result<Outcome, sqlx::Error> {
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<String, Value> {
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::<String>()
.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<String> = 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<AppState>,
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<Value> = 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<u8> {
if let Some(hex) = text.strip_prefix("\\x") {
return js_hex(hex);
}
let chars: Vec<char> = 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<u8> {
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<char> = 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::<i32>(), month.parse::<u32>(), day.parse::<u32>())
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::<u32>().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::<i64>().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::<i64>().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<char> = 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<Value>,
recorded: Vec<char>,
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<char>, 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<Value> {
self.parse_with_position(nested).0
}
fn parse_with_position(&mut self, nested: bool) -> (Vec<Value>, 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));
}
}
-713
View File
@@ -1,713 +0,0 @@
// 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<AppState> {
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<DateTime<Utc>>;
/// Filtres de l'adresse, tels que Node les lit
#[derive(Default)]
struct Filters {
start_date: Option<FilterDate>,
end_date: Option<FilterDate>,
/// split(',').map(Number)
supplier_ids: Option<Vec<f64>>,
group_ids: Option<Vec<f64>>,
status: Option<Vec<String>>,
}
/// Échec d'une requête : Node le transforme en 500 (ou en liste vide pour by-store)
#[derive(Debug)]
struct Failure;
impl From<sqlx::Error> 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<f64> = 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<FilterDate>) -> Result<Option<String>, Failure> {
match date {
None => Ok(None),
Some(None) => Err(Failure),
Some(Some(date)) => Ok(Some(iso_day(date))),
}
}
fn days(&self) -> Result<(Option<String>, Option<String>), 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<Utc>) -> 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<T>(list: &Option<Vec<T>>) -> Option<&[T]> {
list.as_deref().filter(|list| !list.is_empty())
}
/// Conditions WHERE paramétrées
#[derive(Default)]
struct Conditions {
clauses: Vec<String>,
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<i32> = 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::<Map<_, _>>(),
)
}
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<String> = rows
.iter()
.map(|row| {
row.iter()
.map(|(_, cell)| cell.csv())
.collect::<Vec<_>>()
.join(",")
})
.collect();
format!("{}\n{}", headers.join(","), lines.join("\n"))
}
// ---------- Requêtes ----------
/// getAnalyticsSummary
async fn summary_row(db: &PgPool, filters: &Filters) -> Result<Row, Failure> {
let (start, end) = filters.days()?;
let conditions = |date_column: &str, prefix: &str| -> Result<Conditions, Failure> {
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<String, Failure> {
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::<Postgres, (i64,), _>(&orders_sql, orders.arguments).fetch_one(db),
sqlx::query_as_with::<Postgres, (i64, String, i64, Option<String>), _>(
&deliveries_sql,
deliveries.arguments
)
.fetch_one(db),
sqlx::query_as_with::<Postgres, (i32, String, i64, String), _>(&suppliers_sql, suppliers.arguments)
.persistent(false)
.fetch_all(db),
sqlx::query_as_with::<Postgres, (i32, String, i64, i64), _>(&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<Value> = 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<Value> = 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<Vec<Row>, 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::<Postgres, (String, i64), _>(&orders_sql, orders.arguments).fetch_all(db),
sqlx::query_as_with::<Postgres, (String, i64), _>(&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<String, (i64, i64)> = 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<Vec<Row>, 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<Row> {
store_rows_or_fail(db, filters).await.unwrap_or_default()
}
async fn store_rows_or_fail(db: &PgPool, filters: &Filters) -> Result<Vec<Row>, 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<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, _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<Vec<u8>> {
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<AppState>, 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);
}
}
-386
View File
@@ -1,386 +0,0 @@
// 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<AppState> {
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<i32>,
created_by: String,
created_at: Option<NaiveDateTime>,
author_id: Option<String>,
author_username: Option<String>,
author_first_name: Option<String>,
author_last_name: Option<String>,
author_name: Option<String>,
group_id: Option<i32>,
group_name: Option<String>,
}
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<String, Value> {
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<String>| 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::<Vec<_>>().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<Vec<MessageRow>> {
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<AppState>, 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<Value> = match latest(&state.db, store_filter).await {
Some(rows) => rows.iter().map(MessageRow::listed).collect(),
None => {
let rows: Vec<MessageRow> = 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<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<DashboardMessagesRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<DashboardMessagesRow>(&state.db, id).await {
Ok(count) => count,
Err(error) => {
tracing::warn!(%error, "information : nouvel essai de suppression");
db::delete_by_id::<DashboardMessagesRow>(&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" }));
}
}
-570
View File
@@ -1,570 +0,0 @@
// 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<AppState> {
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<Option<AvoirsRow>, 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<AvoirsRow, AppError> {
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<i32>),
/// 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<Scope, AppError> {
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<i32, SuppliersRow>,
groups: HashMap<i32, GroupReconciliation>,
creators: HashMap<String, UserSummary>,
}
impl Relations {
async fn load(db: &PgPool, avoirs: &[AvoirsRow]) -> Result<Self, sqlx::Error> {
let supplier_ids: Vec<i32> = avoirs.iter().map(|avoir| avoir.supplier_id).collect();
let suppliers = db::find_by_ids::<SuppliersRow>(db, &supplier_ids)
.await?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = avoirs.iter().map(|avoir| avoir.group_id).collect();
let groups: Vec<GroupReconciliation> = 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<String> = 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<AppState>, 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<AvoirsRow> = 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<Value> = avoirs.iter().map(|avoir| relations.attach(avoir)).collect();
Ok(Json(out).into_response())
}
async fn show(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<SuppliersRow>(&state.db, avoir.supplier_id)
.await
.or_500(FAILED)?;
// getAvoir joint la ligne complète du magasin
let group = db::find_by_id::<GroupsRow>(&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::<AvoirsRow>(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::<GroupsRow>(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<String, Value> {
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<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<AvoirsRow>(&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::<AvoirsRow>))
};
};
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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<AvoirsRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<SuppliersRow>(&state.db, avoir.supplier_id)
.await
.map_err(|error| failed(database_message(&error)))?;
let group = db::find_by_id::<GroupsRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<AvoirsRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<AvoirsRow>(&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::<AvoirsRow>(&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" }));
}
}
-563
View File
@@ -1,563 +0,0 @@
// 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<AppState> {
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_<type>_<new Date().toISOString() avec « : » et « . » remplacés>.sql
fn backup_filename(kind: &str, now: &DateTime<Utc>) -> 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<DatabaseConfig> {
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<AppState>, user: CurrentUser) -> ApiResult {
admin_only(&user)?;
let backups: Vec<DatabaseBackupsRow> =
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<AppState>, 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<DatabaseBackupsRow> {
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::<DatabaseBackupsRow>(&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<Vec<(String,)>, 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<AppState>, user: CurrentUser, Path(filename): Path<String>) -> 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<AppState>,
user: CurrentUser,
method: Method,
headers: HeaderMap,
Path(filename): Path<String>,
) -> 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::<Utc>::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::<Utc>::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<u8> {
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<u8> = 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()
);
}
}
-389
View File
@@ -1,389 +0,0 @@
// 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<AppState> {
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<reqwest::Client> = 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<Option<WebhookBapConfigRow>, sqlx::Error> {
sqlx::query_as(&WebhookBapConfigRow::select("LIMIT 1"))
.fetch_optional(db)
.await
}
async fn get_config(State(state): State<AppState>, 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<AppState>,
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::<WebhookBapConfigRow>(&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::<WebhookBapConfigRow>(&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::<std::io::Error>() {
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<Duration>,
) -> 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<u16> = text.encode_utf16().take(200).collect();
String::from_utf16_lossy(&units)
}
async fn test_webhook(
State(_state): State<AppState>,
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<usize> {
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<AppState>,
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);
}
}
-99
View File
@@ -1,99 +0,0 @@
// 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<AppState> {
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<AppState>, 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<i32> = 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<ContactsRow> = 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<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<ContactsRow>(&state.db, id, &data, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(contact))
}
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
const FAILED: &str = "Failed to delete contact";
can_write(&user)?;
let id = int_id(&id, FAILED)?;
db::delete_by_id::<ContactsRow>(&state.db, id)
.await
.or_500(FAILED)?;
Ok(Json(json!({ "message": "Contact deleted successfully" })).into_response())
}
-242
View File
@@ -1,242 +0,0 @@
// 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<AppState> {
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<Vec<Value>, sqlx::Error> {
let orders: Vec<CustomerOrdersRow> = sql.build_query_as().fetch_all(&state.db).await?;
let supplier_ids: Vec<i32> = orders.iter().map(|order| order.supplier_id).collect();
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(&state.db, &supplier_ids)
.await?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = 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::<CustomerOrdersRow>("customer_orders")
))
}
async fn list(State(state): State<AppState>, 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<i32> = 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<AppState>, 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::<CustomerOrdersRow>(&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<CustomerOrdersRow, AppError> {
let id = int_id(raw_id, failed)?;
let order = db::find_by_id::<CustomerOrdersRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<CustomerOrdersRow>(&data).map_err(|_| AppError::internal(FAILED))?;
let updated = db::update_by_id::<CustomerOrdersRow>(&state.db, order.id, &values, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(updated))
}
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
const FAILED: &str = "Failed to delete customer order";
let order = find_for(&state, &user, &id, FAILED, "Access denied").await?;
db::delete_by_id::<CustomerOrdersRow>(&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<AppState>, 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<i32> = 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<CustomerOrdersRow>(&state.db, order.id, &values, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(updated))
}
-918
View File
@@ -1,918 +0,0 @@
// 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<AppState> {
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<String, Value>, keys: &[&str]) -> Map<String, Value> {
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<HashMap<i32, GroupReconciliation>, sqlx::Error> {
let rows: Vec<GroupReconciliation> = 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::<DeliveriesRow>("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<SuppliersRow>,
pub group: Option<GroupReconciliation>,
pub creator: Option<UserSummary>,
}
impl DeliveryItem {
pub fn to_json(&self) -> Map<String, Value> {
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<Vec<DeliveryItem>, sqlx::Error> {
let rows: Vec<DeliveriesRow> = sql.build_query_as().fetch_all(db).await?;
let supplier_ids: Vec<i32> = rows.iter().map(|row| row.supplier_id).collect();
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(db, &supplier_ids)
.await?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = rows.iter().map(|row| row.group_id).collect();
let groups = group_reconciliations(db, &group_ids).await?;
let creator_ids: Vec<String> = 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<HashMap<i32, Vec<DeliveryItem>>, sqlx::Error> {
let mut by_order: HashMap<i32, Vec<DeliveryItem>> = 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<Vec<DeliveryItem>, 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<OrderItem>,
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<HashMap<i32, i64>, 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<DeliveryItem>,
) -> Result<Vec<ListedDelivery>, sqlx::Error> {
let mut order_ids: Vec<i32> = items.iter().filter_map(|item| item.row.order_id).collect();
order_ids.sort_unstable();
order_ids.dedup();
let delivery_ids: Vec<i32> = 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<Vec<ListedDelivery>, 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<Vec<ListedDelivery>, 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<SuppliersRow>,
pub group: Option<GroupsRow>,
creator: Option<UserSummary>,
order: Option<orders::OrderDetail>,
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<Option<DeliveryDetail>, 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::<DeliveriesRow>("deliveries")
))
.bind(id)
.fetch_optional(db)
.await?;
let Some(row) = row else {
return Ok(None);
};
let supplier = db::find_by_id::<SuppliersRow>(db, row.supplier_id).await?;
let group = db::find_by_id::<GroupsRow>(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<Option<DeliveriesRow>, sqlx::Error> {
let updated = db::update_by_id::<DeliveriesRow>(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::<OrdersRow>(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<AppState>, 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<Value> = 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<DeliveryDetail, AppError> {
get_delivery(db, id)
.await
.or_500(failed)?
.ok_or_else(|| AppError::not_found("Delivery not found"))
}
async fn show(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<Value, ()> {
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<Parsed, ()> {
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::<OrdersRow>(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<Value, ()> {
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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<SuppliersRow>(&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<AppState>, 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::<DeliveriesRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<DeliveriesRow>(&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<String>) -> Option<&str> {
text.as_deref().filter(|text| !js::trim(text).is_empty())
}
async fn validate(
State(state): State<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<Postgres>::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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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"));
}
}
-423
View File
@@ -1,423 +0,0 @@
// 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<AppState> {
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<Vec<i32>, ()> {
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<AppState>, 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::<Postgres>::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::<DlcProductsRow>("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<DlcProductsRow> = sql
.build_query_as()
.fetch_all(&state.db)
.await
.map_err(|_| failed())?;
let supplier_ids: Vec<i32> = products.iter().map(|p| p.supplier_id).collect();
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(&state.db, &supplier_ids)
.await
.map_err(|_| failed())?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = products.iter().map(|p| p.group_id).collect();
let groups = GroupSummary::by_ids(&state.db, &group_ids)
.await
.map_err(|_| failed())?;
let out: Vec<Value> = 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<AppState>, 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::<Postgres>::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<DlcProductsRow, AppError> {
let id = int_id(raw_id, failed)?;
let product = db::find_by_id::<DlcProductsRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<SuppliersRow>(&state.db, product.supplier_id)
.await
.or_500(FAILED)?;
let group = db::find_by_id::<GroupsRow>(&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<AppState>, 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::<DlcProductsRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<DlcProductsRow>(&data).map_err(|_| AppError::internal(FAILED))?;
let updated = db::update_by_id::<DlcProductsRow>(&state.db, product.id, &values, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(updated))
}
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
const FAILED: &str = "Failed to delete DLC product";
let product = find_for(&state, &user, &id, FAILED, "Access denied").await?;
db::delete_by_id::<DlcProductsRow>(&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::<DlcProductsRow>(&state.db, id, &values, true)
.await
.or_500(failed)?;
Ok(json_or_empty(updated))
}
async fn validate(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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::<DlcProductsRow>(&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::<DlcProductsRow>(&state.db, id, &Parsed::new(), true)
.await
.or_500(failed)?
}
};
Ok(json_or_empty(updated))
}
async fn mark_processed(
State(state): State<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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
}
File diff suppressed because it is too large. Load diff
-226
View File
@@ -1,226 +0,0 @@
// 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<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.no_proxy()
.user_agent("node-fetch")
.timeout(TIMEOUT)
.build()
.expect("client HTTP ffnancy")
});
pub fn routes() -> Router<AppState> {
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::<HashMap<_, _>>(),
)
}
#[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}}"#
);
}
}
-264
View File
@@ -1,264 +0,0 @@
// 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<AppState> {
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<T: serde::Serialize>(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<AppState>, user: CurrentUser) -> ApiResult {
if user.is_admin() {
let groups: Vec<GroupsRow> = 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<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<GroupsRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<GroupsRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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"]}]"#
);
}
}
-16
View File
@@ -1,16 +0,0 @@
// 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<AppState>) -> Json<Value> {
Json(json!({
"status": "healthy",
"timestamp": crate::modules::now_iso(),
"environment": state.config.environment,
"database": "connected",
}))
}
-33
View File
@@ -1,33 +0,0 @@
// 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()
}
-136
View File
@@ -1,136 +0,0 @@
// 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<AppState> {
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<AppState>, user: CurrentUser) -> ApiResult {
admin_only(&user)?;
let configs: Vec<NocodbConfigRow> = 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<NocodbConfigRow> = configs
.into_iter()
.map(|config| nocodb::decrypt_config(config, key))
.collect();
Ok(Json(configs).into_response())
}
async fn create(State(state): State<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<NocodbConfigRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<NocodbConfigRow>(&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<AppState>, _user: CurrentUser) -> ApiResult {
let config = nocodb::active_config(&state).await;
Ok(Json(config).into_response())
}
-467
View File
@@ -1,467 +0,0 @@
// 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<AppState> {
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::<OrdersRow>("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<SuppliersRow>,
pub group: Option<GroupReconciliation>,
pub creator: Option<UserSummary>,
}
impl OrderItem {
pub fn to_json(&self) -> Map<String, 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
}
}
/// 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<Vec<OrderItem>, sqlx::Error> {
let rows: Vec<OrdersRow> = sql.build_query_as().fetch_all(db).await?;
let supplier_ids: Vec<i32> = rows.iter().map(|row| row.supplier_id).collect();
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(db, &supplier_ids)
.await?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = rows.iter().map(|row| row.group_id).collect();
let groups = group_reconciliations(db, &group_ids).await?;
let creator_ids: Vec<String> = 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<HashMap<i32, OrderItem>, 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<Vec<(OrderItem, Vec<DeliveryItem>)>, 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<i32> = 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<SuppliersRow>,
group: Option<GroupsRow>,
creator: Option<UserSummary>,
deliveries: Vec<DeliveryItem>,
}
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<Option<OrderDetail>, 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::<SuppliersRow>(db, row.supplier_id).await?;
let group = db::find_by_id::<GroupsRow>(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<Option<Vec<i32>>, ()> {
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<AppState>, 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<Value> = 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<OrderDetail, AppError> {
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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<String, Value> {
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<AppState>, 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::<OrdersRow>(&state.db, &data)
.await
.or_500(FAILED)?;
Ok(Json(order).into_response())
}
async fn update(
State(state): State<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<OrdersRow>(&state.db, order.row.id, &data, true)
.await
.or_500(FAILED)?;
Ok(crate::error::json_or_empty(updated))
}
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<OrdersRow>(&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<Option<OrdersRow>, sqlx::Error> {
let mut data = Map::new();
data.insert("status".into(), Value::String(status.to_string()));
db::update_by_id::<OrdersRow>(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<AppState>, 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")]
);
}
}
-656
View File
@@ -1,656 +0,0 @@
// É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<AppState> {
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<f64, Vec<Value>> {
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<String, Vec<Value>> {
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<String>, Vec<Value>> {
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<Utc>),
Invalid,
}
impl JsDate {
fn of(date: Option<DateTime<Utc>>) -> 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<String>,
/// Montants en texte, tels que node-pg les lit ("1250.50")
invoice_amount: Option<String>,
invoice_amount_ttc: Option<String>,
due_date: Option<NaiveDateTime>,
reconciled: Option<bool>,
}
/// 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<String>,
invoice_amount_ttc: Option<String>,
due_date: Option<JsDate>,
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<Vec<Line>, sqlx::Error> {
let rows: Vec<DeliveryRow> = 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<HashMap<i32, (String, Option<String>)>, sqlx::Error> {
let rows: Vec<(i32, String, Option<String>)> =
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<String>) -> Option<&str> {
value.as_deref().filter(|text| !text.is_empty())
}
/// `montant ? parseFloat(montant) : 0`
fn amount(value: &Option<String>) -> 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<f64> {
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::<DeliveriesRow>(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<AppState>, 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<Line>, Vec<Line>) =
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<Value> = 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<Option<GroupsRow>, 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<Utc>, DateTime<Utc>)> {
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<Utc>,
supplier_name: String,
invoice_reference: String,
payment_method: String,
amount_ht: f64,
amount_ttc: f64,
}
async fn export(State(state): State<AppState>, 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<Value>| 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<ExportLine> = 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());
}
}
-472
View File
@@ -1,472 +0,0 @@
// 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<AppState> {
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<Participation>,
}
#[derive(Serialize)]
struct Participation {
#[serde(rename = "publicityId")]
publicity_id: i32,
#[serde(rename = "groupId")]
group_id: i32,
group: Option<GroupSummary>,
}
type ParticipationRow = (i32, i32, Option<i32>, Option<String>, Option<String>);
impl From<ParticipationRow> 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<Vec<Publicity>, sqlx::Error> {
let mut rows: Vec<PublicitiesRow> = 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<ParticipationRow> = if rows.is_empty() {
Vec::new()
} else {
// inArray de Drizzle : un paramètre par identifiant, sans tri (ordre de la base)
let placeholders: Vec<String> = (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::<Postgres, ParticipationRow>(&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<i32, Vec<Participation>> = 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<Option<Publicity>, 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<ParticipationRow> = 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<String> {
let year = js::parse_int(query.truthy("year")?)?;
(year != 0.0).then(|| js::number_string(year))
}
async fn list(State(state): State<AppState>, _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<AppState>, _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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<sqlx::Error> 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<Option<Value>, 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<bool, WriteError> {
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<AppState>, 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<String, Value>) -> 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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());
}
}
-795
View File
@@ -1,795 +0,0 @@
// 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<AppState> {
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<bool>,
bl_number: Option<String>,
bl_amount: Option<Decimal>,
invoice_reference: Option<String>,
scheduled_date: NaiveDate,
delivered_date: Option<NaiveDateTime>,
supplier_row: Option<i32>,
supplier_name: Option<String>,
supplier_email: Option<String>,
group_row: Option<i32>,
group_name: Option<String>,
}
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<Option<DeliveryAccess>, 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<NaiveDateTime>,
updated_at: Option<NaiveDateTime>,
a_id: Option<String>,
a_first_name: Option<String>,
a_last_name: Option<String>,
a_username: Option<String>,
a_email: Option<String>,
a_role: Option<String>,
g_id: Option<i32>,
g_name: Option<String>,
g_color: Option<String>,
d_id: Option<i32>,
d_order_id: Option<i32>,
d_supplier_id: Option<i32>,
d_group_id: Option<i32>,
d_scheduled_date: Option<NaiveDate>,
d_delivered_date: Option<NaiveDateTime>,
d_quantity: Option<i32>,
d_unit: Option<String>,
d_status: Option<String>,
d_notes: Option<String>,
d_bl_number: Option<String>,
d_bl_amount: Option<Decimal>,
d_invoice_reference: Option<String>,
d_invoice_amount: Option<Decimal>,
d_reconciled: Option<bool>,
d_validated_at: Option<NaiveDateTime>,
d_created_by: Option<String>,
d_created_at: Option<NaiveDateTime>,
d_updated_at: Option<NaiveDateTime>,
}
fn ts(value: &Option<NaiveDateTime>) -> 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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<CommentWithRelations> = 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<Value> = comments.iter().map(CommentWithRelations::to_json).collect();
Ok(Json(comments).into_response())
}
async fn create_comment(
State(state): State<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<ReconciliationCommentsRow>(&state.db, id, &data, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(comment))
}
async fn delete_comment(
State(state): State<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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::<ReconciliationCommentsRow>(&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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
) -> 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::<Vec<_>>()
.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<AppState>, 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<Vec<i32>> = 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<SupplierMailLogsRow> = 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<bool, String> {
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::<crate::schema::DeliveriesRow>(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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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<AppState>,
_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<AppState>,
_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<AppState>, 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<String>, Option<bool>)> = 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<AppState>, 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<AppState>,
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())
}
@@ -1,407 +0,0 @@
// 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<u8>,
},
}
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<char>) -> Option<String> {
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<String> {
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<usize> {
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<String, Part> {
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(&section[..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<u8>, 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<bool, sqlx::Error> {
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<AppState>,
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}--"
)
);
}
}
-407
View File
@@ -1,407 +0,0 @@
// 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<AppState> {
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<i32, AppError> {
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<Vec<i32>, 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<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
const FAILED: &str = "Failed to fetch tickets";
let group_ids = scope(&user, &query, FAILED)?;
let mut sql = QueryBuilder::<Postgres>::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::<SavTicketsRow>("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<SavTicketsRow> = sql.build_query_as().fetch_all(&state.db).await.or_500(FAILED)?;
let supplier_ids: Vec<i32> = tickets.iter().map(|ticket| ticket.supplier_id).collect();
let suppliers: HashMap<i32, SuppliersRow> = db::find_by_ids::<SuppliersRow>(&state.db, &supplier_ids)
.await
.or_500(FAILED)?
.into_iter()
.map(|supplier| (supplier.id, supplier))
.collect();
let group_ids: Vec<i32> = 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<String> = 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<Value> = 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<SavTicketsRow, AppError> {
let id = int_id(raw_id, failed)?;
let ticket = db::find_by_id::<SavTicketsRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<SuppliersRow>(&state.db, ticket.supplier_id)
.await
.or_500(FAILED)?;
let group = db::find_by_id::<GroupsRow>(&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<AppState>, 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<i32> = 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<SavTicketsRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<SavTicketsRow>(&state.db, ticket.id)
.await
.or_500(FAILED)?;
Ok(StatusCode::NO_CONTENT.into_response())
}
async fn add_history(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
const FAILED: &str = "Failed to fetch stats";
let group_ids = scope(&user, &query, FAILED)?;
let mut sql = QueryBuilder::<Postgres>::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"
]
);
}
}
-647
View File
@@ -1,647 +0,0 @@
// 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<AppState> {
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(), &params, &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<u8> {
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<Utc>) -> 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<Utc>) -> 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<Utc>) -> 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:<sid>.<signature>, mêmes attributs
fn session_cookie(sid: &str, secret: &str, expires: &DateTime<Utc>) -> 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<Value> {
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<String> {
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<AppState>,
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<UsersRow> = 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<AcceptSpec> {
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<String> {
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"),
"<p>Found. Redirecting to /auth</p>",
),
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<AppState>, 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 = &current.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<AppState>) -> Response {
let admin: Result<Option<(Option<bool>,)>, 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);
}
}
-380
View File
@@ -1,380 +0,0 @@
// 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<AppState> {
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<f64>;
/// resolveStatsGroupIds : None = refus (liste vide), Some(None) = tous les magasins
fn resolve_stats_group_ids(user: &CurrentUser, store_id: Option<&str>) -> Option<Option<GroupIds>> {
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<GroupIds> {
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<String> {
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<Vec<ActivityStats>, 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::<Postgres, (Option<i32>, i64), _>(&orders_sql, orders_arguments)
.persistent(false)
.fetch_all(db),
sqlx::query_as_with::<Postgres, (Option<i32>, 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<ActivityStats> = Vec::new();
fn entry(stats: &mut Vec<ActivityStats>, 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<AppState>, 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<AppState>, 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<S: serde::Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
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<PeriodStats, sqlx::Error> {
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::<Postgres, (i64,), _>(&orders_sql, orders_arguments).fetch_one(db),
sqlx::query_as_with::<Postgres, (i64,), _>(&deliveries_sql, deliveries_arguments).fetch_one(db),
sqlx::query_as_with::<Postgres, (i64,), _>(&pending_sql, pending_arguments).fetch_one(db),
sqlx::query_as_with::<Postgres, (i64, i64, String), _>(&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<AppState>, 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<AppState>, 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}"#
);
}
}
-95
View File
@@ -1,95 +0,0 @@
// 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<AppState> {
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<AppState>, user: CurrentUser, query: JsQuery) -> ApiResult {
if !["admin", "manager", "directeur", "employee"].contains(&user.role()) {
return Err(forbidden());
}
let suppliers: Vec<SuppliersRow> = 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<SuppliersRow> = 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<AppState>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<SuppliersRow>(&state.db, id, &data, true)
.await
.or_500(FAILED)?;
Ok(json_or_empty(supplier))
}
async fn remove(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<SuppliersRow>(&state.db, id)
.await
.or_500(FAILED)?;
Ok(Json(json!({ "message": "Supplier deleted successfully" })).into_response())
}
-303
View File
@@ -1,303 +0,0 @@
// 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<AppState> {
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<AppState>, 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<Vec<i32>> = 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::<Postgres>::new(format!(
"SELECT {} FROM tasks LEFT JOIN groups ON tasks.group_id = groups.id",
columns_of::<TasksRow>("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<TasksRow> = sql
.build_query_as()
.fetch_all(&state.db)
.await
.map_err(|_| failed())?;
let ids: Vec<i32> = 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<Value> = 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<AppState>, 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::<TasksRow>(&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<Utc>>) -> 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<Option<TasksRow>, 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<TasksRow, AppError> {
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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<TasksRow>(&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<AppState>, user: CurrentUser, Path(id): Path<String>) -> ApiResult {
const FAILED: &str = "Failed to delete task";
let task = check_access(&state, &user, &id, FAILED).await?;
db::delete_by_id::<TasksRow>(&state.db, task.id)
.await
.or_500(FAILED)?;
Ok(Json(json!({ "message": "Task deleted successfully" })).into_response())
}
async fn complete(State(state): State<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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::<TasksRow>(&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");
}
}
-463
View File
@@ -1,463 +0,0 @@
// 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<AppState> {
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<String> {
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<AppState>, user: CurrentUser) -> ApiResult {
if !["admin", "directeur", "manager"].contains(&user.role()) {
return Err(access_denied());
}
let users: Vec<PublicUser> =
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<String, Vec<UserGroup>> = HashMap::new();
for membership in memberships {
by_user
.entry(membership.user_id.clone())
.or_default()
.push(membership);
}
let users: Vec<Value> = 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<Vec<UserGroup>, sqlx::Error> {
type Row = (
String,
i32,
i32,
String,
Option<String>,
Option<NaiveDateTime>,
Option<NaiveDateTime>,
);
let rows: Vec<Row> = 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_<millisecondes>_<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<AppState>, 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<Option<UsersRow>, 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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<AppState>, user: CurrentUser, Path(id): Path<String>) -> 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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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<AppState>,
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);
}
}
-113
View File
@@ -1,113 +0,0 @@
// 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<AppState> {
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<Option<UtilitiesRow>, AppError> {
sqlx::query_as(&UtilitiesRow::select("LIMIT 1"))
.fetch_optional(&state.db)
.await
.map_err(database_error)
}
async fn read(State(state): State<AppState>, 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<AppState>, 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::<UtilitiesRow>(&state.db, existing.id, &data, true)
.await
.map_err(database_error)?,
None => Some(
db::insert::<UtilitiesRow>(&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]"
);
}
}
-873
View File
@@ -1,873 +0,0 @@
// 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<reqwest::Client> = 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<reqwest::Certificate> {
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<AppState> {
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<Option<WeatherSettingsRow>, sqlx::Error> {
sqlx::query_as(&WeatherSettingsRow::select("LIMIT 1"))
.persistent(false)
.fetch_optional(db)
.await
}
async fn get_settings(State(state): State<AppState>, 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<AppState>,
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<AppState>,
user: CurrentUser,
Path(id): Path<String>,
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::<WeatherSettingsRow>(&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::<hyper::ext::ReasonPhrase>()
.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<Value> {
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::<Value>()
.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::<Value>().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::<Value>().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<Value> {
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<Value>)>;
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<Option<WeatherRecord>, 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<Option<WeatherRecord>, sqlx::Error> {
let row: Option<WeatherDataRow> = 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<Option<WeatherRecord>, sqlx::Error> {
let row: Option<WeatherDataRow> = 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<WeatherRecord, sqlx::Error> {
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<Option<WeatherRecord>, 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<AppState>, _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<AppState>,
_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::<WeatherSettingsRow>(&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<WeatherRecord>) -> 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)));
}
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more