mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-19 13:08:23 +02:00
feat(rust) : serveur réécrit en Rust, Node retiré ; Calendrier en tête du menu (#571)
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API, l'interface, les migrations de démarrage et les tâches de fond ; même comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés USERS-02, RAPPRO-21, R4, R5). - Node retiré : server/, dépendances serveur, image Docker sans Node (51 Mo), docker-compose à un seul service applicatif. - R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin). - R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris. - R6 : mainteneur dans .github/CODEOWNERS. - Menu latéral : Accueil puis Calendrier en tête. - Plan et état : docs/PLAN-MIGRATION-RUST.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
This commit is contained in:
1 parent
6760f43e87
commit
600627c632
146 files changed
+31521
-20331
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+3188
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "logiflow"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Serveur LogiFlow en Rust, placé devant le serveur Node (docs/PLAN-MIGRATION-RUST.md)"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
|
||||
form_urlencoded = "1"
|
||||
futures-util = "0.3"
|
||||
hmac = "0.12"
|
||||
matchit = "0.8"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] }
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
|
||||
http-body-util = "0.1"
|
||||
# Raison HTTP d'origine (« Not Found »…) lue sur les réponses reqwest (météo)
|
||||
hyper = "1"
|
||||
rust_decimal = { version = "1", features = ["serde"] }
|
||||
scrypt = { version = "0.11", default-features = false, features = ["std"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
subtle = "2"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "macros", "chrono", "rust_decimal", "json"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.6", features = ["compression-gzip", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
chrono-tz = { version = "0.10", default-features = false, features = ["std"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 4
|
||||
|
||||
# Connexion (scrypt, N = 16384) : sans optimisation, une vérification de mot de
|
||||
# passe prend plusieurs secondes en mode debug (banc, développement)
|
||||
[profile.dev.package.scrypt]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.salsa20]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.pbkdf2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,71 @@
|
||||
# Modifier le serveur Rust sans changer son comportement
|
||||
|
||||
Guide écrit pour le portage des adresses de l'ancien serveur Node (`server/routes.ts`) vers `server-rs/` (`docs/PLAN-MIGRATION-RUST.md`). Le portage est terminé et `server/` a quitté le dépôt ; son code reste lisible dans la copie de référence du banc (`.bench-reference/server/`, construite par `scripts/bench/reference-node.sh`). Les règles valent toujours : une adresse est fidèle quand le banc (`scripts/bench/`) ne montre **aucun écart** avec le Node de référence, pour les 5 comptes, en lecture comme en écriture ; tout changement voulu y est déclaré comme écart.
|
||||
|
||||
## La règle : le même comportement que Node, bug compris
|
||||
|
||||
- Même code HTTP, même corps JSON (valeurs, champs `null`, ordre des listes), mêmes messages d'erreur, même ordre des contrôles (droits, puis validation, puis base).
|
||||
- Les défauts connus de Node (cloisonnement par magasin incomplet, contrôles de rôle manquants…) ont été **reproduits** et signalés par un commentaire qui cite l'identifiant de l'audit s'il existe (ex. `CONT-02`). Les corriger, c'est déclarer l'écart au banc (`deviation`) dans le même commit.
|
||||
- Ne rien « améliorer » en route : une différence voulue doit être décidée et inscrite au banc.
|
||||
|
||||
## Lire le code Node de référence
|
||||
|
||||
Dans `.bench-reference/`, pour chaque adresse :
|
||||
|
||||
1. le gestionnaire dans `server/routes.ts` (ou `server/externalApi.ts`, `server/localAuth.ts`) : contrôles, paramètres lus (`req.query`, `req.params`, `req.body`), schéma zod, messages, codes ;
|
||||
2. la méthode de `server/storage.ts` (`DatabaseStorage`, lignes 387 à 3509 ; ignorer `MemStorage`) : requête, jointures, tri, projections (`groupSummaryColumns`…) ;
|
||||
3. les schémas de `shared/schema.ts` (`insertXSchema`, `.omit`, `.extend`, `.partial`) ;
|
||||
4. les services appelés (`server/invoiceVerification.ts`, `server/emailService.ts`…).
|
||||
|
||||
## Outils du socle
|
||||
|
||||
| Besoin | Rust | Équivalent Node |
|
||||
|---|---|---|
|
||||
| Utilisateur connecté (401 « Authentification requise » sinon) | extracteur `auth::CurrentUser` : `user.user` (ligne `UsersRow`), `role()`, `is_admin()`, `group_ids()` | `isAuthenticated`, `req.user`, `getCurrentUser(req)`, `storage.getUser(req.user.id)` |
|
||||
| Utilisateur facultatif | `auth::MaybeUser` | `req.isAuthenticated()` |
|
||||
| Corps de la requête | `request::JsonBody(body)` : `serde_json::Value`, `{}` si le type n'est pas JSON | `req.body` (`express.json()`) |
|
||||
| Paramètres d'adresse | `request::JsQuery` : `get("x")`, `truthy("x")` (présent et non vide) | `req.query.x`, `if (req.query.x)` |
|
||||
| Identifiant d'adresse | `Path<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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Serveur LogiFlow (Rust)
|
||||
|
||||
Serveur unique de l'application : il sert l'API (`src/router.rs`) et l'interface construite par Vite (`dist/public`), applique les migrations au démarrage (`src/startup.rs`) et lance les tâches de fond (`src/jobs.rs` : sauvegarde automatique quotidienne, purge des caches, compte `admin` par défaut sur une base vide). Il a remplacé le serveur Node/Express, dont il reprend le comportement à l'identique (`docs/PLAN-MIGRATION-RUST.md`) : mêmes réponses, même cookie de session (`connect.sid`, table `sessions`), mêmes secrets chiffrés.
|
||||
|
||||
## Lancer en local
|
||||
|
||||
```bash
|
||||
npx vite build # interface dans dist/public
|
||||
DATABASE_URL=postgres://… SESSION_SECRET=… cargo run --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
Puis ouvrir http://localhost:3000. Pour travailler sur l'interface : `npm run dev:server` et, à côté, `npm run dev` (Vite sur http://localhost:5173, qui relaie `/api` vers le port 3000).
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
|---|---|---|
|
||||
| `PORT` | port d'écoute | `3000` |
|
||||
| `DATABASE_URL` | base PostgreSQL | obligatoire |
|
||||
| `STATIC_DIR` | interface construite par Vite | `dist/public` |
|
||||
| `SESSION_SECRET` | signature du cookie de session | `fallback-secret-key` |
|
||||
| `ENCRYPTION_KEY` | clé des secrets chiffrés en base (à défaut `SESSION_SECRET`) | — |
|
||||
| `BACKUP_DIR` | dossier des sauvegardes (`pg_dump` doit être installé) | `backups` |
|
||||
| `NODE_ENV` | renvoyé par `/api/health` | `production` |
|
||||
| `ALLOWED_ORIGINS` | origines acceptées en plus de l'hôte du serveur pour les requêtes d'écriture (`https://a.fr,https://b.fr`), voir `src/origin.rs` | — |
|
||||
| `RUST_LOG` | niveau des journaux (`info`, `debug`…) | `info` |
|
||||
|
||||
Ne pas changer `SESSION_SECRET` ni `ENCRYPTION_KEY` d'une installation existante : les sessions ouvertes et les secrets déjà chiffrés (mot de passe SMTP, jeton NocoDB) deviendraient illisibles.
|
||||
|
||||
## Vérifier
|
||||
|
||||
```bash
|
||||
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||
```
|
||||
|
||||
Puis le banc de comparaison avec le serveur Node de référence (`scripts/bench/README.md`) : aucun écart attendu, hors écarts déclarés.
|
||||
|
||||
## Organisation
|
||||
|
||||
- `src/router.rs` : table des adresses de l'API. `src/express_routes.rs` retrouve l'adresse qu'Express aurait servie (casse, « / » final) ; `src/static_files.rs` sert l'interface et la page 404 d'Express.
|
||||
- `src/modules/` : un fichier par module de l'application ; `src/services/` : services partagés (NocoDB, vérification des factures, courriels, dates).
|
||||
- Socle : session et utilisateur (`auth.rs`), droits (`access.rs`, matrice de `shared/permissions.json`), validation au format zod (`zod.rs`), accès aux tables (`db.rs`), formats JSON de Node (`json.rs`, `js.rs`), secrets chiffrés (`crypto.rs`), erreurs (`error.rs`).
|
||||
- `src/schema.rs` est **généré** depuis `shared/schema.ts` : après un changement de schéma, `DATABASE_URL=… npm run gen:rust-schema`, et la migration correspondante dans `src/startup.rs`.
|
||||
- Ajouter ou modifier une adresse : `PORTAGE.md` (règles et outils du socle).
|
||||
|
||||
## Maintenance (décision R6)
|
||||
|
||||
- Mainteneur et relecteur : @R0m1k3 (`.github/CODEOWNERS`). Toute modification passe par une PR dont la CI est verte : `cargo fmt`, `clippy -D warnings`, tests, contrôle de type de l'interface et banc.
|
||||
- Dans les réglages GitHub du dépôt, protéger `main` : relecture des CODEOWNERS et vérifications « Serveur Rust » obligatoires.
|
||||
- Dépendances : `cargo update` puis banc complet, une fois par mois ; Rust stable, image `rust:1.97-alpine` montée en même temps.
|
||||
- Heure : « aujourd'hui » se calcule à Paris (`src/clock.rs`, décision R5) ; ne jamais utiliser `Utc::now().date_naive()` pour une date du jour.
|
||||
- Écritures d'un autre site refusées (`src/origin.rs`, décision R4) : un nouveau service qui appelle l'API depuis un navigateur sur une autre adresse se déclare dans `ALLOWED_ORIGINS`.
|
||||
@@ -0,0 +1,54 @@
|
||||
// Liste des motifs d'adresse déclarés dans src/modules/*.rs et src/router.rs
|
||||
// (`.route("…")`), pour retrouver la route qu'Express aurait servie
|
||||
// (src/express_routes.rs).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/modules");
|
||||
println!("cargo:rerun-if-changed=src/router.rs");
|
||||
let mut patterns = BTreeSet::new();
|
||||
collect(
|
||||
&fs::read_to_string("src/router.rs").expect("src/router.rs"),
|
||||
&mut patterns,
|
||||
);
|
||||
for entry in fs::read_dir("src/modules").expect("src/modules") {
|
||||
let path = entry.expect("fichier de module").path();
|
||||
if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
collect(&fs::read_to_string(&path).expect("module lisible"), &mut patterns);
|
||||
}
|
||||
}
|
||||
let list: Vec<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 110
|
||||
@@ -0,0 +1,35 @@
|
||||
// Droits par rôle et par module : même matrice que l'interface et Node
|
||||
// (shared/permissions.json, lue par shared/permissions.ts).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
type Matrix = HashMap<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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Session partagée avec Node : cookie connect.sid signé par express-session,
|
||||
// session stockée par connect-pg-simple dans la table sessions
|
||||
// (docs/PLAN-MIGRATION-RUST.md, « Sessions partagées entre Node et Rust »).
|
||||
// L'utilisateur est relu à chaque requête, avec ses magasins, comme
|
||||
// deserializeUser → storage.getUserWithGroups (server/storage.ts).
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::request::Parts;
|
||||
use base64::Engine;
|
||||
use chrono::NaiveDateTime;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::db::Table;
|
||||
use crate::error::AppError;
|
||||
use crate::schema::UsersRow;
|
||||
|
||||
pub const SESSION_COOKIE: &str = "connect.sid";
|
||||
|
||||
/// Magasin joint aux affectations (userGroupColumns : dates en valeur brute du pilote)
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct GroupBrief {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub color: Option<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Date et heure du jour à Paris (décision R5) : les magasins sont en France, et
|
||||
// l'ancien serveur Node, qui comptait en UTC, rendait la date de la veille entre
|
||||
// minuit et 1 h (2 h l'été). Les instants enregistrés en base restent en UTC ;
|
||||
// seul « aujourd'hui » (et ce qui s'en déduit : année, mois, heure de la
|
||||
// sauvegarde automatique) se calcule ici.
|
||||
|
||||
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||
use chrono_tz::{Europe::Paris, Tz};
|
||||
|
||||
/// Instant présent, à l'heure de Paris
|
||||
pub fn now() -> DateTime<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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Configuration lue dans l'environnement, mêmes variables que le serveur Node.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
/// Interface construite par Vite (dist/public)
|
||||
pub static_dir: PathBuf,
|
||||
pub session_secret: String,
|
||||
/// Clé de chiffrement des secrets (à défaut SESSION_SECRET), comme server/crypto.ts
|
||||
pub encryption_key: String,
|
||||
pub environment: String,
|
||||
/// Dossier des sauvegardes pg_dump, comme server/backupService.ts : BACKUP_DIR
|
||||
/// (à défaut /app/backups) en production, ./backups sinon
|
||||
pub backup_dir: PathBuf,
|
||||
/// Origines autorisées en plus de l'hôte du serveur pour les requêtes
|
||||
/// d'écriture (ALLOWED_ORIGINS, séparées par des virgules), voir origin.rs
|
||||
pub allowed_origins: Vec<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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Secrets chiffrés au repos (mot de passe SMTP des magasins, jeton NocoDB),
|
||||
// compatibles avec server/crypto.ts : AES-256-GCM, clé = SHA-256 de
|
||||
// ENCRYPTION_KEY (à défaut SESSION_SECRET), format enc:v1:<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"), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Accès générique aux tables décrites dans schema.rs (généré), au comportement de
|
||||
// Drizzle : insert().values(), update().set().returning(), select().from().
|
||||
//
|
||||
// Comme node-pg, chaque valeur part en texte et PostgreSQL la convertit vers le
|
||||
// type de la colonne ($1::date, $2::numeric…) : mêmes conversions, mêmes erreurs.
|
||||
|
||||
use serde_json::Value;
|
||||
use sqlx::postgres::{PgArguments, PgRow};
|
||||
use sqlx::{Arguments, FromRow, PgPool};
|
||||
|
||||
use crate::zod::Parsed;
|
||||
|
||||
/// Colonne : clé JSON (Drizzle), nom SQL, type SQL
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Col {
|
||||
pub key: &'static str,
|
||||
pub column: &'static str,
|
||||
pub sql_type: &'static str,
|
||||
}
|
||||
|
||||
pub trait Table: for<'r> FromRow<'r, PgRow> + Send + Unpin {
|
||||
const TABLE: &'static str;
|
||||
/// Colonnes dans l'ordre de shared/schema.ts, entre guillemets
|
||||
const COLUMNS: &'static str;
|
||||
const FIELDS: &'static [Col];
|
||||
|
||||
fn field(key: &str) -> Option<&'static Col> {
|
||||
Self::FIELDS.iter().find(|col| col.key == key)
|
||||
}
|
||||
|
||||
/// SELECT des colonnes de la table, suivi de `rest` (WHERE, ORDER BY…)
|
||||
fn select(rest: &str) -> String {
|
||||
format!("SELECT {} FROM {} {rest}", Self::COLUMNS, Self::TABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Valeur JavaScript telle que node-pg l'envoie (prepareValue) : texte, ou NULL
|
||||
pub fn pg_text(value: &Value) -> Option<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\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Erreurs de l'API : { "message": … } avec le code HTTP, comme les gestionnaires Node.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppError {
|
||||
pub status: StatusCode,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
pub type ApiResult<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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Correspondance des adresses à la manière d'Express : insensible à la casse, et une
|
||||
// barre oblique finale tolérée (« /api/Orders/ » sert « /api/orders »), alors
|
||||
// qu'axum compare exactement. Quand aucune route ne correspond, on cherche celle
|
||||
// qu'Express aurait choisie et on rejoue la requête sur l'adresse canonique ; les
|
||||
// paramètres gardent leur casse d'origine, comme req.params.
|
||||
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::Request;
|
||||
use axum::http::Uri;
|
||||
use axum::response::Response;
|
||||
use tower::ServiceExt;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/route_patterns.rs"));
|
||||
|
||||
/// Motif en minuscules → motif déclaré
|
||||
static MATCHER: LazyLock<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// En-têtes des réponses de l'API servies par Rust, identiques à celles d'Express :
|
||||
// - Cache-Control: private, no-cache (server/routes.ts, middleware /api), sauf
|
||||
// sous /api/ext/v1 (API externe, enregistrée avant ce middleware) ;
|
||||
// - JSON en « application/json; charset=utf-8 » ;
|
||||
// - ETag faible calculé sur le corps (paquet etag d'Express) et 304 si le
|
||||
// navigateur a déjà la même réponse.
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::extract::Request;
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::Engine;
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
// Au-delà, le corps n'est pas mis en mémoire pour l'ETag (exports volumineux)
|
||||
const ETAG_MAX_BODY: usize = 50 * 1024 * 1024;
|
||||
|
||||
pub async fn api_response_headers(request: Request, next: Next) -> Response {
|
||||
let method = request.method().clone();
|
||||
let request_headers = request.headers().clone();
|
||||
let external_api = is_external_api(request.uri().path());
|
||||
let response = next.run(request).await;
|
||||
let (mut parts, body) = response.into_parts();
|
||||
|
||||
if !external_api {
|
||||
parts
|
||||
.headers
|
||||
.entry(header::CACHE_CONTROL)
|
||||
.or_insert(HeaderValue::from_static("private, no-cache"));
|
||||
}
|
||||
if parts
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v == "application/json")
|
||||
{
|
||||
parts.headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
);
|
||||
}
|
||||
if parts.headers.contains_key(header::ETAG) || parts.headers.contains_key(header::CONTENT_DISPOSITION) {
|
||||
return Response::from_parts(parts, body);
|
||||
}
|
||||
|
||||
let bytes = match to_bytes(body, ETAG_MAX_BODY).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Réponse trop volumineuse").into_response(),
|
||||
};
|
||||
let etag = express_etag(&bytes);
|
||||
if let Ok(value) = HeaderValue::from_str(&etag) {
|
||||
parts.headers.insert(header::ETAG, value);
|
||||
}
|
||||
if matches!(method, Method::GET | Method::HEAD)
|
||||
&& (parts.status.is_success() || parts.status == StatusCode::NOT_MODIFIED)
|
||||
&& is_fresh(&request_headers, &etag)
|
||||
{
|
||||
parts.status = StatusCode::NOT_MODIFIED;
|
||||
for name in [
|
||||
header::CONTENT_TYPE,
|
||||
header::CONTENT_LENGTH,
|
||||
header::TRANSFER_ENCODING,
|
||||
] {
|
||||
parts.headers.remove(name);
|
||||
}
|
||||
return Response::from_parts(parts, Body::empty());
|
||||
}
|
||||
Response::from_parts(parts, Body::from(bytes))
|
||||
}
|
||||
|
||||
/// Adresse de l'API externe (/api/ext/v1…) : Node l'enregistre avant son
|
||||
/// middleware /api, ses réponses n'ont donc pas de Cache-Control
|
||||
fn is_external_api(path: &str) -> bool {
|
||||
let prefix = crate::modules::external_api::EXTERNAL_API_PREFIX;
|
||||
path.strip_prefix(prefix)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// W/"<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}")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Tâches de démarrage et de fond, reprises du serveur Node :
|
||||
// - compte administrateur par défaut (createDefaultAdminUser, server/localAuth.ts) ;
|
||||
// - configuration météo depuis VISUAL_CROSSING_API_KEY (server/weatherAutoConfig.ts) ;
|
||||
// - sauvegarde automatique quotidienne, vérifiée toutes les heures (server/backupService.ts) ;
|
||||
// - purge du cache de vérification des factures toutes les 6 h (server/maintenance.ts).
|
||||
// Comme dans Node, une tâche en échec est journalisée sans arrêter le serveur. Le
|
||||
// serveur tourne en UTC (image Docker sans TZ) : « 2 h » et « aujourd'hui » sont en UTC.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{NaiveDateTime, Timelike, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::modules::{backups, session_routes, weather};
|
||||
|
||||
const BACKUP_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
const CACHE_PURGE_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
const CACHE_PURGE_FIRST_DELAY: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Lance les tâches de fond (sauvegarde, purge, configuration météo)
|
||||
pub fn spawn(state: AppState) {
|
||||
tokio::spawn(initialize_weather_config(state.db.clone()));
|
||||
tokio::spawn(backup_scheduler(state.clone()));
|
||||
tokio::spawn(cache_purge(state.db.clone()));
|
||||
}
|
||||
|
||||
/// Compte « admin » créé s'il n'existe pas (mot de passe « admin », à changer à la
|
||||
/// première connexion), ou mot de passe remis au bon format s'il est illisible.
|
||||
/// La valeur « admin.salt », acceptée par l'accès de développement de Node pour le
|
||||
/// mot de passe « admin », est remplacée par une vraie empreinte du même mot de passe.
|
||||
pub async fn ensure_default_admin(db: &PgPool) {
|
||||
let existing: Result<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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// Conversions au comportement de JavaScript, sur lesquelles le code Node s'appuie
|
||||
// (Number(), parseInt(), new Date(), longueur d'une chaîne, toISOString()).
|
||||
// Le serveur Node tourne en UTC (image Docker sans TZ) : l'heure locale est l'UTC.
|
||||
|
||||
use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
const MAX_DATE_MS: f64 = 8.64e15;
|
||||
|
||||
/// Number(valeur)
|
||||
pub fn to_number(value: &Value) -> f64 {
|
||||
match value {
|
||||
Value::Null => 0.0,
|
||||
Value::Bool(true) => 1.0,
|
||||
Value::Bool(false) => 0.0,
|
||||
Value::Number(number) => number.as_f64().unwrap_or(f64::NAN),
|
||||
Value::String(text) => string_to_number(text),
|
||||
Value::Array(items) => match items.as_slice() {
|
||||
[] => 0.0,
|
||||
[single] if !single.is_array() && !single.is_object() => match single {
|
||||
Value::Null => 0.0,
|
||||
other => to_number(&Value::String(display(other))),
|
||||
},
|
||||
_ => f64::NAN,
|
||||
},
|
||||
Value::Object(_) => f64::NAN,
|
||||
}
|
||||
}
|
||||
|
||||
fn display(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(text) => text.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn string_to_number(text: &str) -> f64 {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (sign, unsigned) = match trimmed.as_bytes()[0] {
|
||||
b'-' => (-1.0, &trimmed[1..]),
|
||||
b'+' => (1.0, &trimmed[1..]),
|
||||
_ => (1.0, trimmed),
|
||||
};
|
||||
if unsigned == "Infinity" {
|
||||
return sign * f64::INFINITY;
|
||||
}
|
||||
// Préfixes 0x / 0o / 0b : sans signe en JavaScript
|
||||
if sign == 1.0 && trimmed.len() > 2 && !trimmed.starts_with(['+', '-']) {
|
||||
let radix = match &trimmed[..2] {
|
||||
"0x" | "0X" => Some(16),
|
||||
"0o" | "0O" => Some(8),
|
||||
"0b" | "0B" => Some(2),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(radix) = radix {
|
||||
return u64::from_str_radix(&trimmed[2..], radix)
|
||||
.map(|n| n as f64)
|
||||
.unwrap_or(f64::NAN);
|
||||
}
|
||||
}
|
||||
let valid = {
|
||||
let bytes = unsigned.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut digits = 0;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
digits += 1;
|
||||
}
|
||||
if i < bytes.len() && bytes[i] == b'.' {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
digits += 1;
|
||||
}
|
||||
}
|
||||
if digits > 0 && i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
|
||||
i += 1;
|
||||
if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
|
||||
i += 1;
|
||||
}
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if start == i {
|
||||
return f64::NAN;
|
||||
}
|
||||
}
|
||||
digits > 0 && i == bytes.len()
|
||||
};
|
||||
if !valid {
|
||||
return f64::NAN;
|
||||
}
|
||||
unsigned.parse::<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Formats JSON de l'API Node, à reproduire à l'identique.
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serializer;
|
||||
|
||||
/// Colonne timestamp lue par Drizzle : Date JavaScript, « 2026-10-15T08:30:00.000Z »
|
||||
pub fn ts<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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Serveur LogiFlow : l'API (router.rs), l'interface construite par Vite
|
||||
// (static_files.rs), les migrations de démarrage et les tâches de fond.
|
||||
// Il a remplacé le serveur Node (docs/PLAN-MIGRATION-RUST.md).
|
||||
|
||||
mod access;
|
||||
mod auth;
|
||||
mod clock;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod error;
|
||||
mod express_routes;
|
||||
mod http;
|
||||
mod jobs;
|
||||
mod js;
|
||||
mod json;
|
||||
mod modules;
|
||||
mod origin;
|
||||
mod projections;
|
||||
mod request;
|
||||
mod router;
|
||||
mod schema;
|
||||
mod services;
|
||||
mod startup;
|
||||
mod static_files;
|
||||
mod zod;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<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é");
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Administration : diagnostic du schéma (GET /api/debug/log-schema et
|
||||
// /api/debug/download-schema, réservés à l'administrateur et à la production),
|
||||
// exécution de SQL (sql.rs) et adresses de secours (emergency.rs : migration
|
||||
// d'urgence, réinitialisation du compte admin).
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::post};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::http::express_etag;
|
||||
use crate::js;
|
||||
|
||||
mod emergency;
|
||||
mod sql;
|
||||
|
||||
pub fn routes() -> Router<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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Adresses de secours :
|
||||
// - POST /api/admin/emergency-migration (server/routes.ts, admin) : relance les
|
||||
// migrations de démarrage (startup.rs), puis la création des index en
|
||||
// arrière-plan ; les erreurs sont journalisées, la réponse est toujours un succès ;
|
||||
// - POST /api/emergency-admin-reset (server/index.production.ts, sans session) :
|
||||
// remet le mot de passe « admin » au compte admin si le corps porte le secret
|
||||
// EMERGENCY_SECRET de l'environnement ; l'adresse n'existe pas (404) sans lui.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::Table;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::modules::now_iso;
|
||||
use crate::modules::session_routes::hash_password;
|
||||
use crate::schema::UsersRow;
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub async fn emergency_migration(State(state): State<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())
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// POST /api/admin/execute-sql (server/routes.ts) : SQL saisi par l'administrateur
|
||||
// dans la page SQL de l'interface (SQLExecutor.tsx), exécuté tel quel, comme
|
||||
// db.execute(sql.raw(…)) de Drizzle sur node-pg : protocole simple (plusieurs
|
||||
// instructions possibles, résultats en texte), lignes converties par les
|
||||
// analyseurs de node-pg (pg-types), sauf dates, horodatages et intervalles que
|
||||
// Drizzle laisse en texte.
|
||||
//
|
||||
// Faille connue, reprise telle quelle (audit SQL-01) : aucune confirmation ni
|
||||
// restriction des instructions, l'administrateur exécute ce qu'il veut.
|
||||
//
|
||||
// Approximations :
|
||||
// - rowCount : node-pg lit le nombre de l'étiquette de fin de commande
|
||||
// (« UPDATE 3 »), null quand elle n'en porte pas (« CREATE TABLE ») ; sqlx ne
|
||||
// rend que ce nombre (0 sans nombre), la commande est donc reconnue à son
|
||||
// premier mot quand il vaut 0 ;
|
||||
// - tableaux d'intervalles : laissés en texte (objets PostgresInterval dans Node).
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{NaiveDate, TimeZone, Utc};
|
||||
use futures_util::TryStreamExt;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{Column, Either, Row, ValueRef};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::js;
|
||||
use crate::request::JsonBody;
|
||||
|
||||
fn error_body(status: StatusCode, body: Value) -> AppError {
|
||||
AppError::with_body(status, body)
|
||||
}
|
||||
|
||||
// ---------- Exécution ----------
|
||||
|
||||
/// Ce que node-pg rend : un résultat par instruction terminée (tableau de
|
||||
/// résultats au-delà d'une), les lignes de l'unique instruction
|
||||
struct Outcome {
|
||||
/// rows_affected de chaque fin de commande, dans l'ordre
|
||||
completions: Vec<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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
// Statistiques détaillées : /api/analytics/summary, /timeseries, /by-supplier,
|
||||
// /by-store, /export (server/routes.ts, « Analytics routes » ; storage
|
||||
// getAnalyticsSummary, getAnalyticsTimeseries, getAnalyticsBySupplier,
|
||||
// getAnalyticsByStore).
|
||||
//
|
||||
// Node assemble une partie de ces requêtes en texte (timeseries, by-store) :
|
||||
// elles sont ici paramétrées, avec les mêmes résultats. Dans ces requêtes en
|
||||
// texte, un identifiant décimal ou hors limites est comparé sans erreur (il ne
|
||||
// correspond à rien) et seul un NaN ou un Infinity fait échouer la requête ;
|
||||
// dans les requêtes de Drizzle, tout identifiant que PostgreSQL refuse comme
|
||||
// integer la fait échouer.
|
||||
//
|
||||
// Comme Node aujourd'hui : « Montant total » additionne BL et factures (ANA-04),
|
||||
// /by-store ignore les magasins de l'utilisateur (ANA-05), une liste de
|
||||
// magasins vidée par le filtre des droits n'en filtre plus aucun, et un compte
|
||||
// sans magasin voit tout (ANA-06) ; le directeur voit tous les magasins ; le
|
||||
// taux de rapprochement vaut null (NaN) quand il n'y a aucune livraison.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Json, Router, routing::get};
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::postgres::PgArguments;
|
||||
use sqlx::{Arguments, PgPool, Postgres};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::js_int4;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::js;
|
||||
use crate::request::JsQuery;
|
||||
|
||||
pub fn routes() -> Router<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Informations du tableau de bord : /api/announcements (server/routes.ts,
|
||||
// « Announcement routes », branche production ; table dashboard_messages).
|
||||
// Les replis de Node sur erreur SQL passent par server/announcementStorage.ts
|
||||
// (AnnouncementDatabaseStorage) : ils sont reproduits.
|
||||
// Comme Node aujourd'hui : un non-admin voit les informations de tous les
|
||||
// magasins (DASH-13), et un magasin illisible (?storeId=abc) fait retomber sur
|
||||
// toutes les informations, sans limite, avec l'auteur sous une autre forme.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::put};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, parse_int4};
|
||||
use crate::error::{ApiResult, AppError, OrInternal};
|
||||
use crate::js::truthy;
|
||||
use crate::request::{JsQuery, JsonBody};
|
||||
use crate::schema::DashboardMessagesRow;
|
||||
use crate::zod::Object;
|
||||
|
||||
pub fn routes() -> Router<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" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// Avoirs : /api/avoirs (server/routes.ts, « Avoir routes » ; storage.ts getAvoirs à
|
||||
// updateAvoirNocodbVerification). La création et le passage au statut « Reçu »
|
||||
// envoient l'avoir au webhook du magasin (node-fetch, sans délai maximal) ; s'il
|
||||
// répond 2xx, l'avoir est marqué « webhook envoyé ».
|
||||
//
|
||||
// Défauts de Node reproduits :
|
||||
// - GET /api/avoirs : un directeur voit les avoirs de tous les magasins, et un
|
||||
// compte sans magasin, sans storeId, voit tous les avoirs (liste de magasins vide
|
||||
// = aucun filtre) (AVOIRS-API-08) ;
|
||||
// - PUT : montant, référence et commentaire absents du corps sont effacés
|
||||
// (AVOIRS-API-06) ; magasin, fournisseur et créateur modifiables sans contrôle,
|
||||
// ni règle de rôle pour « Reçu » ni protection des avoirs validés (AVOIRS-API-07) ;
|
||||
// - webhook-status et nocodb-verification ne contrôlent pas le magasin (directeur
|
||||
// compris) et acceptent un identifiant inexistant (AVOIRS-API-08) ;
|
||||
// - le webhook porte un nom de fournisseur factice (AVOIRS-API-05) ;
|
||||
// - GET /:id joint la ligne complète du magasin (mot de passe SMTP remplacé par
|
||||
// smtpPasswordSet), la liste sa projection de rapprochement.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access::has_permission;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::json::{spread, strip_smtp_password};
|
||||
use crate::modules::bap::node_fetch_post;
|
||||
use crate::modules::now_iso;
|
||||
use crate::projections::{GroupReconciliation, UserSummary};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{AvoirsRow, GroupsRow, SuppliersRow};
|
||||
use crate::services::invoice_verification as verification;
|
||||
use crate::services::nocodb::{database_message, not_a_string_error, truthy};
|
||||
use crate::zod::{Field, Object, Parsed};
|
||||
|
||||
pub fn routes() -> Router<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" }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// Sauvegardes de la base : /api/backups (server/routes.ts, « Backup management
|
||||
// routes », et server/backupService.ts), réservées à l'administrateur.
|
||||
//
|
||||
// pg_dump est lancé sans shell, avec des arguments séparés ; mêmes options, même
|
||||
// nom de fichier, mêmes lignes dans database_backups, dix sauvegardes gardées.
|
||||
//
|
||||
// Écart voulu (audit BACKUP-09) : Node joint le nom reçu dans l'adresse au
|
||||
// dossier des sauvegardes sans le contrôler (« ../ » sort du dossier). Rust
|
||||
// n'accepte qu'un simple nom de fichier (ni « / », ni « \ », ni « . » ou « .. ») ;
|
||||
// sinon la réponse est celle d'une sauvegarde introuvable.
|
||||
//
|
||||
// Non reportées ici : la sauvegarde automatique (vérification horaire, et après
|
||||
// chaque connexion) reste faite par Node jusqu'à la bascule.
|
||||
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::delete, routing::get};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::RngCore;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal};
|
||||
use crate::schema::DatabaseBackupsRow;
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Bon à payer (BAP) : configuration du webhook n8n (/api/webhook-bap-config, test
|
||||
// compris) et envoi d'un PDF au webhook (/api/bap/send-webhook), réservés à l'admin
|
||||
// (server/routes.ts). Les appels sortants reprennent les messages d'erreur de
|
||||
// node-fetch, que le serveur Node utilise pour ces adresses.
|
||||
//
|
||||
// Non repris : la configuration par défaut renvoyée par Node quand la table
|
||||
// webhook_bap_config n'existe pas (code 42P01) ; la table existe partout.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::js;
|
||||
use crate::request::JsonBody;
|
||||
use crate::schema::WebhookBapConfigRow;
|
||||
use crate::services::nocodb::{database_message, truthy};
|
||||
use crate::zod::Object;
|
||||
|
||||
pub fn routes() -> Router<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Contacts des magasins : /api/contacts (server/routes.ts, « Contacts routes »).
|
||||
// Comme Node aujourd'hui : un compte sans magasin voit tous les contacts (CONT-02)
|
||||
// et un manager peut modifier ceux d'un autre magasin (CONT-03).
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::put};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::ContactsRow;
|
||||
use crate::zod::Object;
|
||||
|
||||
pub fn routes() -> Router<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())
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Commandes clients : /api/customer-orders (server/routes.ts, « Customer Orders
|
||||
// routes » et « Client call tracking routes » ; storage.ts getCustomerOrders à
|
||||
// markClientCalled). Comme Node aujourd'hui :
|
||||
// - un compte sans magasin voit les commandes de tous les magasins ;
|
||||
// - création et modification sans schéma zod (insertCustomerOrderSchema n'est pas
|
||||
// appliqué) : les champs connus de la table sont écrits tels quels, les erreurs de
|
||||
// la base deviennent des 500 ;
|
||||
// - la modification ne contrôle que le magasin d'origine de la commande : elle peut
|
||||
// la déplacer vers un autre magasin ;
|
||||
// - les appels à passer ignorent les commandes dont customer_notified est NULL.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::patch, routing::put};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, columns_of, drizzle_values, node_pg_value};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::json::spread;
|
||||
use crate::modules::now_iso;
|
||||
use crate::projections::GroupSummary;
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{CustomerOrdersRow, SuppliersRow};
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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))
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
// Livraisons : /api/deliveries (server/routes.ts, « Deliveries routes »,
|
||||
// validation et contrôle ; storage.ts getDeliveries à markDeliveryControlValidated).
|
||||
//
|
||||
// Formes de Node reproduites :
|
||||
// - liste : champs dans l'ordre du select de Drizzle, fournisseur complet, magasin
|
||||
// réduit (groupReconciliationColumns), créateur réduit, commande associée (sans
|
||||
// ses livraisons, absente si elle est d'un autre magasin) et nombre de
|
||||
// commentaires de rapprochement ;
|
||||
// - GET /:id : sans les champs du contrôle, magasin complet (mot de passe SMTP
|
||||
// remplacé par smtpPasswordSet), commande complète (getOrder).
|
||||
//
|
||||
// Effets de bord de Node reproduits, dans le même ordre (mêmes requêtes) :
|
||||
// - lier une commande « pending » la fait passer à « planned » (création et
|
||||
// modification) ; valider une livraison passe sa commande à « delivered » ;
|
||||
// - fournisseur en rapprochement automatique : livraison livrée avec un BL
|
||||
// rapprochée d'office (seconde mise à jour, la réponse reste la première ligne) ;
|
||||
// - changer la référence de facture relit l'échéance dans NocoDB (sans cache) ;
|
||||
// - valider marque « rapprochées » les entrées du cache de l'ancienne référence et
|
||||
// de l'ancien numéro de BL.
|
||||
//
|
||||
// Défauts de Node reproduits :
|
||||
// - un directeur ou un manager sans ?storeId ne voit que son premier magasin ;
|
||||
// - la validation ne contrôle le magasin que pour un manager (un directeur valide
|
||||
// les livraisons de tout magasin) ; le contrôle (PUT /:id/control) ne contrôle
|
||||
// jamais le magasin ;
|
||||
// - PUT accepte groupId, createdBy, reconciled… (schéma d'insertion partiel) : une
|
||||
// livraison peut changer de magasin ; un corps invalide donne un 500 ;
|
||||
// - POST : erreurs de validation rendues en 500 (pas de 400 zod).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access::has_permission;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, columns_of};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::json::{spread, strip_smtp_password};
|
||||
use crate::modules::orders::{self, OrderItem, date_range, listed_groups, spread_body};
|
||||
use crate::projections::{GroupReconciliation, UserSummary};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{DeliveriesRow, GroupsRow, OrdersRow, SuppliersRow};
|
||||
use crate::services::dates;
|
||||
use crate::services::invoice_verification as verification;
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Produits DLC : /api/dlc-products (server/routes.ts, « DLC Products routes » ;
|
||||
// storage.ts getDlcProducts à getDlcStats). Comme Node aujourd'hui :
|
||||
// - un compte sans magasin voit les produits et les compteurs de tous les magasins ;
|
||||
// - création et modification sans schéma zod : les champs connus de la table sont
|
||||
// écrits tels quels, les erreurs de la base deviennent des 500 ;
|
||||
// - la modification ne contrôle que le magasin d'origine du produit : elle peut le
|
||||
// déplacer vers un magasin de l'utilisateur ou non, et changer son créateur ;
|
||||
// - GET /:id joint la ligne complète du magasin (adresse, logo, configuration
|
||||
// NocoDB et SMTP ; le mot de passe SMTP remplacé par smtpPasswordSet) ;
|
||||
// - les compteurs sont rendus en texte ("3"), tels que node-pg lit un COUNT.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||
use chrono::{Days, NaiveDate};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, columns_of, drizzle_values};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::json::{spread, strip_smtp_password};
|
||||
use crate::modules::now_iso;
|
||||
use crate::projections::GroupSummary;
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{DlcProductsRow, GroupsRow, SuppliersRow};
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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
@@ -0,0 +1,226 @@
|
||||
// Relais ffnancy : /api/ffnancy/* (server/routes.ts, « Proxy API ffnancy »).
|
||||
// Le navigateur ne peut pas appeler https://api.ffnancy.fr (CORS) : le serveur
|
||||
// relaie la recherche d'articles et les mouvements d'entrée, avec les seuls
|
||||
// paramètres autorisés (renseignés, dans l'ordre de la liste).
|
||||
//
|
||||
// Comme Node (node-fetch) : en-têtes Accept « */* » et User-Agent « node-fetch »,
|
||||
// sans mandataire ; réponse en erreur : son code et { error: 'API ffnancy error' } ;
|
||||
// réponse illisible ou injoignable : 500. Le JSON reçu est relu puis réécrit
|
||||
// comme JSON.parse / JSON.stringify (nombres au format de JavaScript, clés
|
||||
// numériques en tête), mot de passe SMTP masqué comme toute réponse /api.
|
||||
//
|
||||
// Écarts sans effet sur la réponse : pas d'Accept-Encoding (le client HTTP ne
|
||||
// décompresse pas), et un délai maximal de 30 s (node-fetch n'en a pas).
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::js;
|
||||
use crate::json::strip_smtp_password;
|
||||
use crate::request::JsQuery;
|
||||
use crate::services::nocodb::array_index;
|
||||
|
||||
const FFNANCY_API: &str = "https://api.ffnancy.fr/api";
|
||||
const ARTICLE_PARAMS: [&str; 7] = ["search", "codein", "ean", "codefou", "actif", "page", "limit"];
|
||||
const ENTRY_PARAMS: [&str; 6] = ["artNoId", "dateDebut", "dateFin", "site", "page", "limit"];
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
static CLIENT: LazyLock<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}}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Magasins : /api/groups (server/routes.ts, « Groups routes »).
|
||||
//
|
||||
// Le mot de passe SMTP ne sort jamais : comme stripSmtpPassword
|
||||
// (server/sanitize.ts, appliqué par Node à toute réponse JSON de /api), il est
|
||||
// remplacé par smtpPasswordSet, à la même place. Il est chiffré à l'écriture
|
||||
// (crate::crypto, comme storage.createGroup / updateGroup).
|
||||
//
|
||||
// POST /api/groups/:id/test-smtp vérifie la configuration SMTP du magasin
|
||||
// (connexion et authentification, sans envoi) : services::mail.
|
||||
//
|
||||
// Comme Node aujourd'hui : un manager peut créer, modifier et supprimer
|
||||
// n'importe quel magasin, pas seulement le sien.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::crypto;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, empty_json};
|
||||
use crate::request::{JsonBody, int_id};
|
||||
use crate::schema::GroupsRow;
|
||||
use crate::zod::{Field, Object, Parsed};
|
||||
|
||||
pub fn routes() -> Router<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"]}]"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// GET /api/health (server/routes.ts:118), utilisé par le contrôle de santé Docker.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub async fn health(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "healthy",
|
||||
"timestamp": crate::modules::now_iso(),
|
||||
"environment": state.config.environment,
|
||||
"database": "connected",
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Un module par domaine de l'application (lots de docs/PLAN-MIGRATION-RUST.md).
|
||||
pub mod admin;
|
||||
pub mod analytics;
|
||||
pub mod announcements;
|
||||
pub mod avoirs;
|
||||
pub mod backups;
|
||||
pub mod bap;
|
||||
pub mod contacts;
|
||||
pub mod customer_orders;
|
||||
pub mod deliveries;
|
||||
pub mod dlc;
|
||||
pub mod external_api;
|
||||
pub mod ffnancy;
|
||||
pub mod groups;
|
||||
pub mod health;
|
||||
pub mod nocodb_config;
|
||||
pub mod orders;
|
||||
pub mod payment_schedule;
|
||||
pub mod publicities;
|
||||
pub mod reconciliation;
|
||||
pub mod sav;
|
||||
pub mod session_routes;
|
||||
pub mod stats;
|
||||
pub mod suppliers;
|
||||
pub mod tasks;
|
||||
pub mod users;
|
||||
pub mod utilities;
|
||||
pub mod weather;
|
||||
|
||||
/// Instant présent au format de JavaScript : 2026-10-15T08:30:00.000Z
|
||||
pub fn now_iso() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Configuration NocoDB : /api/nocodb-config (server/routes.ts, « NocoDB Configuration
|
||||
// routes »). Le jeton API est chiffré en base et rendu en clair dans les réponses,
|
||||
// comme Node (storage.decryptNocodbConfig).
|
||||
//
|
||||
// Défaut de Node reproduit : GET /api/nocodb-config/active rend la configuration
|
||||
// active, jeton API en clair compris, à tout compte connecté (les autres adresses
|
||||
// sont réservées à l'admin).
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, put};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, json_or_empty};
|
||||
use crate::request::{JsonBody, int_id};
|
||||
use crate::schema::NocodbConfigRow;
|
||||
use crate::services::nocodb;
|
||||
use crate::zod::{Object, Parsed};
|
||||
|
||||
pub fn routes() -> Router<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())
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Commandes fournisseurs : /api/orders, /api/sync-order-delivery-status
|
||||
// (server/routes.ts, « Orders routes » ; storage.ts getOrders à deleteOrder).
|
||||
//
|
||||
// Formes de Node reproduites :
|
||||
// - liste : champs dans l'ordre du select de Drizzle (statut avant quantité),
|
||||
// fournisseur complet, magasin réduit (groupReconciliationColumns), créateur
|
||||
// réduit, puis les livraisons de chaque commande ;
|
||||
// - GET /:id : magasin complet (adresse, logo, configuration NocoDB et SMTP, le
|
||||
// mot de passe SMTP remplacé par smtpPasswordSet).
|
||||
//
|
||||
// Défauts de Node reproduits :
|
||||
// - un directeur ou un manager sans ?storeId ne voit que son premier magasin ;
|
||||
// un employé sans ?storeId ne voit rien ;
|
||||
// - PUT accepte groupId et createdBy (schéma d'insertion partiel) : une commande
|
||||
// peut être déplacée vers un magasin de l'utilisateur ou non, et changer de
|
||||
// créateur ; un corps invalide donne un 500 (l'erreur zod n'est pas renvoyée) ;
|
||||
// - POST /api/sync-order-delivery-status lit les commandes de tous les magasins.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access::has_permission;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, columns_of};
|
||||
use crate::error::{ApiResult, AppError, OrInternal};
|
||||
use crate::js;
|
||||
use crate::json::{spread, strip_smtp_password};
|
||||
use crate::modules::deliveries::{self, DeliveryItem, fields_in_order, group_reconciliations};
|
||||
use crate::projections::{GroupReconciliation, UserSummary};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{GroupsRow, OrdersRow, SuppliersRow};
|
||||
|
||||
pub fn routes() -> Router<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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// Échéancier des paiements fournisseurs : GET /api/payment-schedule et son export
|
||||
// CSV, POST /api/payment-schedule/export (server/routes.ts), réservés à l'admin et
|
||||
// au directeur (ses magasins seulement).
|
||||
//
|
||||
// La lecture complète les livraisons dans NocoDB (vérification des factures, avec
|
||||
// son cache) : échéance absente → échéance et montant HT trouvés écrits en base ;
|
||||
// montant TTC absent ou nul → montant HT (et échéance brute) réécrits.
|
||||
//
|
||||
// Défauts de Node reproduits :
|
||||
// - une lecture (GET) écrit en base, une livraison à la fois (ECHE-01) ;
|
||||
// - le repli « TTC » n'écrit jamais le TTC : il réécrit le HT et l'échéance de
|
||||
// NocoDB, et recommence à chaque lecture ; la réponse garde l'échéance d'avant ;
|
||||
// - l'export n'échappe pas les champs du CSV, et une erreur de validation des
|
||||
// paramètres donne un 500 (message de ZodError).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::http::express_etag;
|
||||
use crate::js;
|
||||
use crate::request::{JsQuery, JsonBody};
|
||||
use crate::schema::{DeliveriesRow, GroupsRow};
|
||||
use crate::services::invoice_verification::{self as verification, JsField, Verification, js_number_text};
|
||||
use crate::services::nocodb::{self, truthy};
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
// Publicités : /api/ad-campaigns (server/routes.ts, « Publicity routes » ;
|
||||
// storage.getPublicities, getPublicity, createPublicity, updatePublicity,
|
||||
// deletePublicity, setPublicityParticipations).
|
||||
// Comme Node aujourd'hui :
|
||||
// - la liste ignore le magasin et les magasins de l'utilisateur (PUB-01) ;
|
||||
// - PUT écrit le corps tel quel, sans validation, et aucune écriture n'est
|
||||
// transactionnelle : une participation refusée laisse la publicité créée ou
|
||||
// ses anciennes participations effacées (PUB-17) ;
|
||||
// - /api/ad-campaigns/debug reste exposée (PUB-06).
|
||||
// Les identifiants et l'année passent à PostgreSQL sous la forme texte que
|
||||
// node-pg leur donne (String(parseInt(…))), pour les mêmes refus et les mêmes
|
||||
// messages d'erreur, renvoyés par la suppression et par /debug.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Json, Router, routing::get, routing::post};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgArguments;
|
||||
use sqlx::{Arguments, Postgres};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access::has_permission;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table, node_pg_text, parse_int4};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::projections::GroupSummary;
|
||||
use crate::request::{JsQuery, JsonBody};
|
||||
use crate::schema::PublicitiesRow;
|
||||
use crate::zod::Object;
|
||||
|
||||
pub fn routes() -> Router<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
// Rapprochement BL / factures (server/routes.ts) : commentaires de rapprochement,
|
||||
// courriel de demande de documents au fournisseur et son historique, vérification
|
||||
// d'une facture dans NocoDB, diagnostic et marquage du cache des factures, envoi
|
||||
// d'une facture au webhook du magasin (send_invoice.rs, écart voulu RAPPRO-21).
|
||||
//
|
||||
// Défauts de Node reproduits :
|
||||
// - un commentaire modifié peut changer de livraison, de magasin ou d'auteur
|
||||
// (deliveryId, groupId, authorId acceptés par le PUT) ;
|
||||
// - POST /api/verify-invoice, /api/verify-invoices et /api/cache/mark-reconciled
|
||||
// ne contrôlent pas le magasin : tout compte connecté (directeur ou admin pour le
|
||||
// marquage) agit sur n'importe quel magasin ;
|
||||
// - /api/cache/diagnosis cherche des clés de cache au mauvais format
|
||||
// (`référence_magasin` au lieu de `magasin_référence`) : il ne trouve rien, mais
|
||||
// supprime au passage une entrée expirée qui aurait cette clé ;
|
||||
// - /api/supplier-mail-logs ignore storeId pour un directeur.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access::has_permission;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{GroupsRow, ReconciliationCommentsRow, SupplierMailLogsRow};
|
||||
use crate::services::invoice_verification::{self as verification, JsField, Verification};
|
||||
use crate::services::mail::{self, MailDelivery, MailLog};
|
||||
use crate::services::nocodb::{self, truthy};
|
||||
use crate::zod::{Field, Object};
|
||||
|
||||
mod send_invoice;
|
||||
|
||||
pub fn routes() -> Router<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())
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// POST /api/reconciliation/send-invoice (server/routes.ts) : le fichier d'une
|
||||
// facture, reçu en multipart, est renvoyé en multipart (file, supplier, blNumber,
|
||||
// type) au webhook dont l'adresse accompagne le formulaire (admin et directeur).
|
||||
//
|
||||
// Écart voulu (audit RAPPRO-21, déclaré au banc) : Node appelle toute adresse
|
||||
// fournie par le navigateur. Ici, l'adresse doit être celle d'un magasin
|
||||
// (groups.webhook_url, ce que l'interface envoie) ou du webhook BAP actif ; toute
|
||||
// autre adresse est refusée (400) avant l'appel.
|
||||
//
|
||||
// Repris de Node : l'analyse multipart écrite à la main (parseMultipart), la
|
||||
// lecture du corps sans limite de taille, l'encodage multipart de node-fetch
|
||||
// (formdata-polyfill) et ses messages d'erreur, sans délai maximal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::Json;
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use rand::Rng;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::modules::bap::node_fetch_post;
|
||||
use crate::services::nocodb::database_message;
|
||||
|
||||
fn error_body(status: StatusCode, error: &str) -> AppError {
|
||||
AppError::with_body(status, json!({ "error": error }))
|
||||
}
|
||||
|
||||
// ---------- Analyse multipart (parseMultipart de Node) ----------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Part {
|
||||
Text(String),
|
||||
File {
|
||||
filename: String,
|
||||
content_type: String,
|
||||
data: Vec<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(§ion[..header_end]);
|
||||
let content_start = header_end + 4;
|
||||
let content_end = section.len().saturating_sub(2).max(content_start);
|
||||
let content = section.get(content_start..content_end).unwrap_or_default();
|
||||
|
||||
let Some(name) = capture(&headers, "name=\"", &['"'], Some('"')) else {
|
||||
continue;
|
||||
};
|
||||
let part = match capture(&headers, "filename=\"", &['"'], Some('"')) {
|
||||
Some(filename) => Part::File {
|
||||
filename,
|
||||
content_type: capture(&headers, "Content-Type: ", &['\r', '\n'], None)
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
data: content.to_vec(),
|
||||
},
|
||||
None => Part::Text(String::from_utf8_lossy(content).into_owned()),
|
||||
};
|
||||
parts.insert(name, part);
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
// ---------- Renvoi en multipart (formDataToBlob de node-fetch) ----------
|
||||
|
||||
/// Type d'un Blob : vide s'il sort de U+0020–U+007E, sinon en minuscules
|
||||
fn blob_type(content_type: &str) -> String {
|
||||
if content_type.chars().all(|c| (' '..='~').contains(&c)) {
|
||||
content_type.to_ascii_lowercase()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fin de ligne isolée (\r ou \n) remplacée par \r\n
|
||||
fn crlf(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\r' => {
|
||||
out.push_str("\r\n");
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
'\n' => out.push_str("\r\n"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Échappement des noms de champ et de fichier dans Content-Disposition
|
||||
fn escape_name(name: &str, keep_newlines: bool) -> String {
|
||||
let name = if keep_newlines {
|
||||
name.to_string()
|
||||
} else {
|
||||
crlf(name)
|
||||
};
|
||||
name.replace('\n', "%0A").replace('\r', "%0D").replace('"', "%22")
|
||||
}
|
||||
|
||||
/// Corps multipart et son type (boundary aléatoire, 32 caractères)
|
||||
fn encode_form(file: (&str, &str, &[u8]), fields: &[(&str, String)]) -> (Vec<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}--"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// SAV : /api/sav/tickets et /api/sav/stats (server/routes.ts, « SAV routes » ;
|
||||
// storage.ts getSavTickets à getSavTicketStats). Comme Node aujourd'hui :
|
||||
// - un compte sans magasin voit les tickets et les compteurs de tous les magasins ;
|
||||
// - la suppression ne contrôle pas le magasin : un directeur supprime les tickets
|
||||
// de n'importe quel magasin ;
|
||||
// - la modification (schéma zod partiel) peut déplacer le ticket vers un autre
|
||||
// magasin et changer son créateur ;
|
||||
// - l'historique est désactivé dans storage.ts : POST …/history n'enregistre rien et
|
||||
// renvoie null, history vaut toujours [] ;
|
||||
// - numéro de ticket « nombre de tickets de l'année + 1 », mais le COUNT est lu en
|
||||
// texte par node-pg, donc concaténé : 5 tickets donnent « 51 », SAV-2026-0051.
|
||||
// Reproduit tel quel (doublons possibles, docs/PLAN-MIGRATION-RUST.md) ;
|
||||
// - GET /:id joint la ligne complète du magasin (mot de passe SMTP remplacé par
|
||||
// smtpPasswordSet).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::post};
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, columns_of};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js;
|
||||
use crate::json::{spread, strip_smtp_password};
|
||||
use crate::projections::{GroupSummary, UserSummary};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::{GroupsRow, SavTicketsRow, SuppliersRow};
|
||||
use crate::zod::{Field, Object};
|
||||
|
||||
pub fn routes() -> Router<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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// Connexion et session : POST /api/login, GET et POST /api/logout, GET /api/user,
|
||||
// GET /api/default-credentials-check (server/localAuth.ts : c'est lui que la
|
||||
// production exécute ; localAuth.production.ts n'est jamais chargé).
|
||||
//
|
||||
// Les sessions restent celles d'express-session + connect-pg-simple, dans les
|
||||
// deux sens tant que les deux serveurs tournent : même ligne dans la table
|
||||
// sessions, même cookie connect.sid signé (docs/PLAN-MIGRATION-RUST.md,
|
||||
// « Sessions partagées entre Node et Rust »). La lecture de la session est dans
|
||||
// crate::auth.
|
||||
//
|
||||
// Non reporté (décision R3, audit AUTH-04) : le mot de passe de développement
|
||||
// « admin.salt » accepté pour « admin », et la création du compte admin/admin au
|
||||
// démarrage. Non reporté non plus : la sauvegarde quotidienne lancée après
|
||||
// chaque connexion (backupService.checkAndPerformDailyBackup) ; la vérification
|
||||
// horaire de Node continue de la faire.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use serde_json::{Value, json};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{self, MaybeUser, SESSION_COOKIE};
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::js;
|
||||
use crate::request::JsonBody;
|
||||
use crate::schema::UsersRow;
|
||||
|
||||
pub fn routes() -> Router<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(), ¶ms, &mut key).expect("longueur de clé valide");
|
||||
key
|
||||
}
|
||||
|
||||
fn to_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Buffer.from(texte, "hex") : paires décodées jusqu'au premier caractère non
|
||||
/// hexadécimal, demi-octet final ignoré
|
||||
fn node_hex(text: &str) -> Vec<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 = ¤t.user;
|
||||
Ok(Json(json!({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"firstName": user.first_name,
|
||||
"lastName": user.last_name,
|
||||
"role": user.role,
|
||||
"passwordChanged": user.password_changed,
|
||||
"userGroups": current.user_groups,
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Identifiants par défaut à afficher : compte « admin » dont le mot de passe n'a
|
||||
/// pas été changé (true aussi en cas d'erreur)
|
||||
async fn default_credentials_check(State(state): State<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// Statistiques : /api/stats/by-group, /by-supplier (comptages des pages Magasins
|
||||
// et Fournisseurs), /monthly, /yearly (server/routes.ts ; storage
|
||||
// getOrderDeliveryStatsByGroup, getOrderDeliveryStatsBySupplier,
|
||||
// getMonthlyStats, getYearlyStats).
|
||||
// Comme Node aujourd'hui : pour /monthly et /yearly, un compte sans magasin
|
||||
// voit les chiffres de tous les magasins (liste vide = aucun filtre), et toute
|
||||
// erreur SQL (année ou mois illisibles, magasin hors limites) rend des zéros
|
||||
// au lieu d'une erreur.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get};
|
||||
use chrono::Datelike;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::PgArguments;
|
||||
use sqlx::{Arguments, Postgres};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::js_int4;
|
||||
use crate::error::{ApiResult, OrInternal};
|
||||
use crate::js;
|
||||
use crate::request::JsQuery;
|
||||
|
||||
pub fn routes() -> Router<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}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Fiches fournisseurs : /api/suppliers (server/routes.ts, « Suppliers routes »).
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::put};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::SuppliersRow;
|
||||
use crate::zod::Object;
|
||||
|
||||
pub fn routes() -> Router<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())
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Tâches : /api/tasks (server/routes.ts, « Tasks routes » ; storage.ts getTasks à
|
||||
// completeTask). Comme Node aujourd'hui :
|
||||
// - les corps ne passent par aucun schéma zod : les champs connus de la table sont
|
||||
// écrits tels quels, les erreurs de la base deviennent des 500 ;
|
||||
// - seul le magasin est contrôlé pour modifier, terminer ou supprimer une tâche :
|
||||
// un employé peut modifier ou supprimer celles de son magasin ;
|
||||
// - un compte sans magasin qui aurait le droit de créer passerait le contrôle du
|
||||
// magasin (« userGroupIds.length > 0 && … ») : inatteignable avec la matrice actuelle.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime, Timelike, Utc};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Postgres, QueryBuilder};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::access;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table, columns_of, drizzle_values, node_pg_value};
|
||||
use crate::error::{ApiResult, AppError, OrInternal};
|
||||
use crate::js;
|
||||
use crate::json::spread;
|
||||
use crate::projections::GroupSummary;
|
||||
use crate::request::{JsQuery, JsonBody, int_id};
|
||||
use crate::schema::TasksRow;
|
||||
use crate::zod::Parsed;
|
||||
|
||||
pub fn routes() -> Router<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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Utilisateurs : /api/users (server/routes.ts, « Users management routes » et
|
||||
// « User-Group management routes »).
|
||||
//
|
||||
// Les adresses POST /api/users/:id/groups et DELETE /api/users/:id/groups/:groupId
|
||||
// déclarées une seconde fois plus bas dans routes.ts (« Access denied ») ne sont
|
||||
// jamais atteintes : seules les premières sont reprises (décision R3).
|
||||
//
|
||||
// Écart voulu (audit USERS-02) : l'empreinte du mot de passe n'est jamais
|
||||
// renvoyée. Node la renvoie encore après une création ou une modification ;
|
||||
// Rust répond avec la projection publique (projections::PublicUser).
|
||||
//
|
||||
// Comme Node aujourd'hui : managers et directeurs voient tous les comptes ; une
|
||||
// adresse électronique vide dans une modification ne l'efface pas ; un mot de
|
||||
// passe vide dans une modification est enregistré tel quel (le compte ne peut
|
||||
// plus se connecter) ; affectations et suppression sans transaction.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::delete, routing::get, routing::post, routing::put};
|
||||
use chrono::NaiveDateTime;
|
||||
use rand::Rng;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::postgres::PgArguments;
|
||||
use sqlx::{Arguments, PgPool};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{CurrentUser, GroupBrief, UserGroup};
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, empty_json};
|
||||
use crate::modules::session_routes::hash_password;
|
||||
use crate::projections::PublicUser;
|
||||
use crate::request::{JsonBody, int_id};
|
||||
use crate::schema::{UserGroupsRow, UsersRow};
|
||||
use crate::zod::{Field, Object, Parsed};
|
||||
|
||||
pub fn routes() -> Router<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Paramètres généraux : /api/utilities (server/routes.ts, « Routes pour
|
||||
// configuration utilities ») : adresse de l'analyse des ventes et sauvegardes
|
||||
// automatiques. Une seule ligne, lue ou mise à jour (créée si la table est vide).
|
||||
//
|
||||
// Comme Node : les erreurs sont au format { error, details } ; une erreur de
|
||||
// validation n'est pas un 400 mais un 500 dont le détail est le message de
|
||||
// ZodError (ses erreurs en JSON indenté).
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table};
|
||||
use crate::error::{ApiResult, AppError};
|
||||
use crate::request::JsonBody;
|
||||
use crate::schema::UtilitiesRow;
|
||||
|
||||
pub fn routes() -> Router<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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// Météo : /api/weather/* (server/routes.ts, « Weather routes » et route de
|
||||
// géolocalisation ; server/weatherService.ts ; storage.getWeatherSettings,
|
||||
// getWeatherData, getNearestWeatherData, createWeatherData, clearWeatherCache).
|
||||
//
|
||||
// Les adresses de Visual Crossing et de Nominatim sont écrites dans le code,
|
||||
// comme dans Node. Écart voulu (critère du lot 2, DASH-35) : chaque appel a un
|
||||
// délai maximal (connexion 10 s, comme fetch dans Node, et 30 s en tout), là où
|
||||
// Node attend jusqu'à 300 s une réponse lente.
|
||||
// Comme Node aujourd'hui : tout utilisateur connecté peut changer la ville par
|
||||
// géolocalisation (décision R3 du plan, « Ce qu'on ne reporte pas »).
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
||||
use chrono::{Datelike, Duration as Days, NaiveDate};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::postgres::PgArguments;
|
||||
use sqlx::{Arguments, PgPool};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::db::{self, Table, node_pg_text};
|
||||
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
||||
use crate::js::{self, string_of, truthy};
|
||||
use crate::request::{JsonBody, int_id};
|
||||
use crate::schema::{WeatherDataRow, WeatherSettingsRow};
|
||||
use crate::zod::Object;
|
||||
|
||||
const VISUAL_CROSSING_URL: &str =
|
||||
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline";
|
||||
const NOMINATIM_URL: &str = "https://nominatim.openstreetmap.org/reverse";
|
||||
const NOMINATIM_USER_AGENT: &str = "LogiFlow-Weather-System/1.0 (Enterprise Weather Management Platform)";
|
||||
|
||||
/// Client des services météo : sans mandataire (fetch de Node n'en utilise pas),
|
||||
/// avec les autorités de NODE_EXTRA_CA_CERTS que Node ajoute aux siennes
|
||||
static CLIENT: LazyLock<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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Protection des requêtes d'écriture contre les sites tiers (décision R4).
|
||||
//
|
||||
// Le cookie de session est SameSite=Lax : un autre site ne peut pas l'envoyer
|
||||
// dans un POST de formulaire ou un fetch. Ce contrôle ajoute une seconde
|
||||
// barrière sans rien changer à l'interface : toute requête /api qui n'est pas
|
||||
// une lecture est refusée (403) si le navigateur indique qu'elle vient d'un
|
||||
// autre site.
|
||||
//
|
||||
// - Sec-Fetch-Site (navigateurs récents) : « same-origin » ou « none » acceptés ;
|
||||
// ne dépend pas de l'en-tête Host, que nginx peut réécrire.
|
||||
// - Sinon, en-tête Origin : accepté s'il est absent (outils, n8n, curl), ou si
|
||||
// son hôte est celui de la requête (Host ou X-Forwarded-Host).
|
||||
// - Dans les deux cas, les origines de ALLOWED_ORIGINS sont acceptées.
|
||||
// - /api/ext/ (API externe, clé d'API sans cookie) n'est pas concerné.
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub async fn check(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
let guarded = !matches!(*request.method(), Method::GET | Method::HEAD | Method::OPTIONS)
|
||||
&& is_api(path)
|
||||
&& !is_external_api(path);
|
||||
if guarded && !allowed(request.headers(), &state.config.allowed_origins) {
|
||||
tracing::warn!(
|
||||
method = %request.method(),
|
||||
path,
|
||||
origin = ?request.headers().get(header::ORIGIN),
|
||||
"requête d'écriture d'une autre origine refusée"
|
||||
);
|
||||
return refused();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn is_api(path: &str) -> bool {
|
||||
let lowered = path.to_ascii_lowercase();
|
||||
lowered == "/api" || lowered.starts_with("/api/")
|
||||
}
|
||||
|
||||
fn is_external_api(path: &str) -> bool {
|
||||
path.to_ascii_lowercase().starts_with("/api/ext/")
|
||||
}
|
||||
|
||||
fn allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool {
|
||||
let origin =
|
||||
header_text(headers, header::ORIGIN.as_str()).map(|o| o.trim_end_matches('/').to_ascii_lowercase());
|
||||
if origin.as_ref().is_some_and(|o| allowed_origins.contains(o)) {
|
||||
return true;
|
||||
}
|
||||
if let Some(site) = header_text(headers, "sec-fetch-site") {
|
||||
return matches!(site.to_ascii_lowercase().as_str(), "same-origin" | "none");
|
||||
}
|
||||
let Some(origin) = origin else {
|
||||
return true;
|
||||
};
|
||||
let Some(origin_host) = origin.split_once("://").map(|(_, host)| host) else {
|
||||
// « null » (iframe isolée, fichier local) ou valeur illisible
|
||||
return false;
|
||||
};
|
||||
["x-forwarded-host", header::HOST.as_str()]
|
||||
.into_iter()
|
||||
.filter_map(|name| header_text(headers, name))
|
||||
.flat_map(|value| value.split(','))
|
||||
.any(|host| host.trim().eq_ignore_ascii_case(origin_host))
|
||||
}
|
||||
|
||||
fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers.get(name).and_then(|value| value.to_str().ok())
|
||||
}
|
||||
|
||||
fn refused() -> Response {
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
[
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-cache"),
|
||||
),
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
),
|
||||
],
|
||||
r#"{"message":"Origine de la requête non autorisée"}"#,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
|
||||
let mut map = HeaderMap::new();
|
||||
for (name, value) in pairs {
|
||||
map.insert(*name, HeaderValue::from_static(value));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sec_fetch_site() {
|
||||
assert!(allowed(&headers(&[("sec-fetch-site", "same-origin")]), &[]));
|
||||
assert!(allowed(&headers(&[("sec-fetch-site", "none")]), &[]));
|
||||
assert!(!allowed(
|
||||
&headers(&[
|
||||
("sec-fetch-site", "cross-site"),
|
||||
("origin", "https://evil.example")
|
||||
]),
|
||||
&[]
|
||||
));
|
||||
assert!(!allowed(
|
||||
&headers(&[
|
||||
("sec-fetch-site", "same-site"),
|
||||
("origin", "https://autre.logiflow.fr")
|
||||
]),
|
||||
&[]
|
||||
));
|
||||
// Origine déclarée dans ALLOWED_ORIGINS
|
||||
assert!(allowed(
|
||||
&headers(&[
|
||||
("sec-fetch-site", "same-site"),
|
||||
("origin", "https://autre.logiflow.fr/")
|
||||
]),
|
||||
&["https://autre.logiflow.fr".to_string()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn en_tete_origin() {
|
||||
// Sans Origin : outils et services (n8n, curl)
|
||||
assert!(allowed(&headers(&[]), &[]));
|
||||
assert!(allowed(
|
||||
&headers(&[("origin", "https://logiflow.fr"), ("host", "logiflow.fr")]),
|
||||
&[]
|
||||
));
|
||||
assert!(allowed(
|
||||
&headers(&[("origin", "http://localhost:3000"), ("host", "localhost:3000")]),
|
||||
&[]
|
||||
));
|
||||
// nginx qui réécrit Host mais transmet X-Forwarded-Host
|
||||
assert!(allowed(
|
||||
&headers(&[
|
||||
("origin", "https://LogiFlow.fr"),
|
||||
("host", "logiflow:3000"),
|
||||
("x-forwarded-host", "logiflow.fr")
|
||||
]),
|
||||
&[]
|
||||
));
|
||||
assert!(!allowed(
|
||||
&headers(&[("origin", "https://evil.example"), ("host", "logiflow.fr")]),
|
||||
&[]
|
||||
));
|
||||
assert!(!allowed(
|
||||
&headers(&[("origin", "null"), ("host", "logiflow.fr")]),
|
||||
&[]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adresses_concernees() {
|
||||
assert!(is_api("/api/orders"));
|
||||
assert!(is_api("/API/Orders"));
|
||||
assert!(!is_api("/apiculture"));
|
||||
assert!(is_external_api("/api/ext/v1/deliveries"));
|
||||
assert!(is_external_api("/API/Ext/v1/deliveries"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Formes réduites jointes aux réponses, comme les projections de server/storage.ts :
|
||||
// jamais le logo ni la configuration SMTP d'un magasin, jamais l'empreinte d'un
|
||||
// mot de passe.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, PgPool};
|
||||
|
||||
/// groupSummaryColumns : { id, name, color }
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct GroupSummary {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
impl GroupSummary {
|
||||
/// Colonnes à sélectionner, préfixées par l'alias de la table groups
|
||||
pub fn columns(alias: &str) -> String {
|
||||
format!("{alias}.id, {alias}.name, {alias}.color")
|
||||
}
|
||||
|
||||
/// Résumés des magasins `ids`, par identifiant (seconde moitié d'un leftJoin(groups))
|
||||
pub async fn by_ids(db: &PgPool, ids: &[i32]) -> Result<HashMap<i32, Self>, sqlx::Error> {
|
||||
let rows: Vec<Self> = sqlx::query_as(&format!(
|
||||
"SELECT {} FROM groups g WHERE g.id = ANY($1)",
|
||||
Self::columns("g")
|
||||
))
|
||||
.bind(ids)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|group| (group.id, group)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// groupReconciliationColumns : résumé + configuration NocoDB et webhook
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct GroupReconciliation {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
#[serde(rename = "nocodbConfigId")]
|
||||
pub nocodb_config_id: Option<i32>,
|
||||
#[serde(rename = "nocodbTableName")]
|
||||
pub nocodb_table_name: Option<String>,
|
||||
#[serde(rename = "webhookUrl")]
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
impl GroupReconciliation {
|
||||
pub fn columns(alias: &str) -> String {
|
||||
format!(
|
||||
"{alias}.id, {alias}.name, {alias}.color, {alias}.nocodb_config_id, {alias}.nocodb_table_name, {alias}.webhook_url"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// userSummaryColumns : créateur ou auteur joint à une ligne
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct UserSummary {
|
||||
pub id: String,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: Option<String>,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: Option<String>,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl UserSummary {
|
||||
pub fn columns(alias: &str) -> String {
|
||||
format!("{alias}.id, {alias}.first_name, {alias}.last_name, {alias}.username, {alias}.email")
|
||||
}
|
||||
|
||||
/// Résumés des utilisateurs `ids`, par identifiant (seconde moitié d'un leftJoin(users))
|
||||
pub async fn by_ids(db: &PgPool, ids: &[String]) -> Result<HashMap<String, Self>, sqlx::Error> {
|
||||
let rows: Vec<Self> = sqlx::query_as(&format!(
|
||||
"SELECT {} FROM users u WHERE u.id = ANY($1)",
|
||||
Self::columns("u")
|
||||
))
|
||||
.bind(ids)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|user| (user.id.clone(), user)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// publicUserColumns : toutes les colonnes de users sauf password
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PublicUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub name: Option<String>,
|
||||
#[serde(rename = "firstName")]
|
||||
pub first_name: Option<String>,
|
||||
#[serde(rename = "lastName")]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(rename = "profileImageUrl")]
|
||||
pub profile_image_url: Option<String>,
|
||||
pub role: String,
|
||||
#[serde(rename = "passwordChanged")]
|
||||
pub password_changed: Option<bool>,
|
||||
#[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")]
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
#[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")]
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
impl PublicUser {
|
||||
pub fn columns(alias: &str) -> String {
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"name",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"profile_image_url",
|
||||
"role",
|
||||
"password_changed",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
.iter()
|
||||
.map(|column| format!("{alias}.{column}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Lecture des requêtes au comportement d'Express :
|
||||
// - corps : express.json() / express.urlencoded() ; autre type de contenu → {} ;
|
||||
// - paramètres de l'adresse : req.query (valeurs répétées jointes par « , »,
|
||||
// comme String(tableau)) ;
|
||||
// - identifiants : parseInt(req.params.id).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{FromRequest, FromRequestParts, Request};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::request::Parts;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// req.body
|
||||
pub struct JsonBody(pub Value);
|
||||
|
||||
impl<S: Send + Sync> FromRequest<S> for JsonBody {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.split(';').next().unwrap_or("").trim().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let bytes = Bytes::from_request(request, state)
|
||||
.await
|
||||
.map_err(|_| AppError::new(StatusCode::PAYLOAD_TOO_LARGE, "request entity too large"))?;
|
||||
let body = match content_type.as_str() {
|
||||
"application/json" => parse_json(&bytes)?,
|
||||
"application/x-www-form-urlencoded" => Value::Object(
|
||||
form_urlencoded::parse(&bytes)
|
||||
.map(|(key, value)| (key.into_owned(), Value::String(value.into_owned())))
|
||||
.collect(),
|
||||
),
|
||||
_ => Value::Object(Map::new()),
|
||||
};
|
||||
Ok(Self(body))
|
||||
}
|
||||
}
|
||||
|
||||
/// body-parser en mode strict : objet ou tableau, corps vide → {}
|
||||
fn parse_json(bytes: &[u8]) -> Result<Value, AppError> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| AppError::bad_request("Invalid UTF-8"))?;
|
||||
let trimmed = text.trim_start();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Value::Object(Map::new()));
|
||||
}
|
||||
if !trimmed.starts_with(['{', '[']) {
|
||||
let token = trimmed.chars().next().unwrap_or(' ');
|
||||
return Err(AppError::bad_request(&format!(
|
||||
"Unexpected token '{token}', \"{}\" is not valid JSON",
|
||||
trimmed.chars().take(10).collect::<String>()
|
||||
)));
|
||||
}
|
||||
serde_json::from_str(trimmed).map_err(|error| AppError::bad_request(&error.to_string()))
|
||||
}
|
||||
|
||||
/// req.query, aplati
|
||||
pub struct JsQuery(pub HashMap<String, String>);
|
||||
|
||||
impl JsQuery {
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
/// `if (req.query.x)` : présent et non vide
|
||||
pub fn truthy(&self, key: &str) -> Option<&str> {
|
||||
self.get(key).filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for JsQuery {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let mut values: HashMap<String, String> = HashMap::new();
|
||||
if let Some(query) = parts.uri.query() {
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
values
|
||||
.entry(key.into_owned())
|
||||
.and_modify(|existing| {
|
||||
existing.push(',');
|
||||
existing.push_str(&value);
|
||||
})
|
||||
.or_insert_with(|| value.into_owned());
|
||||
}
|
||||
}
|
||||
Ok(Self(values))
|
||||
}
|
||||
}
|
||||
|
||||
/// parseInt(texte) utilisé comme identifiant integer : NaN ou hors limites fait
|
||||
/// échouer la requête SQL côté Node, d'où son message 500
|
||||
pub fn int_id(raw: &str, message: &str) -> Result<i32, AppError> {
|
||||
crate::js::parse_int(raw)
|
||||
.filter(|n| *n >= f64::from(i32::MIN) && *n <= f64::from(i32::MAX))
|
||||
.map(|n| n as i32)
|
||||
.ok_or_else(|| AppError::internal(message))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Table des adresses de l'API. Une adresse absente d'ici : interface
|
||||
// (static_files::fallback), variante qu'Express aurait servie (casse, « / » final,
|
||||
// express_routes.rs) ou page 404 d'Express.
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::middleware;
|
||||
use axum::routing::get;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::compression::predicate::{DefaultPredicate, Predicate, SizeAbove};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::modules::*;
|
||||
use crate::{AppState, express_routes, http, origin, static_files};
|
||||
|
||||
// Même limite qu'express.json({ limit: '10mb' })
|
||||
const BODY_LIMIT: usize = 10 * 1024 * 1024;
|
||||
|
||||
pub fn build(state: AppState) -> Router {
|
||||
let api = Router::new()
|
||||
.route("/api/health", get(health::health))
|
||||
// Lot 1
|
||||
.merge(contacts::routes())
|
||||
.merge(suppliers::routes())
|
||||
// Lot 2
|
||||
.merge(announcements::routes())
|
||||
.merge(publicities::routes())
|
||||
.merge(weather::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(analytics::routes())
|
||||
// Lot 3
|
||||
.merge(tasks::routes())
|
||||
.merge(dlc::routes())
|
||||
.merge(customer_orders::routes())
|
||||
.merge(sav::routes())
|
||||
// Lot 4
|
||||
.merge(orders::routes())
|
||||
.merge(deliveries::routes())
|
||||
.merge(reconciliation::routes())
|
||||
.merge(avoirs::routes())
|
||||
.merge(payment_schedule::routes())
|
||||
.merge(nocodb_config::routes())
|
||||
.merge(bap::routes())
|
||||
.merge(external_api::routes())
|
||||
.merge(ffnancy::routes())
|
||||
// Lot 5
|
||||
.merge(session_routes::routes())
|
||||
.merge(users::routes())
|
||||
.merge(groups::routes())
|
||||
.merge(backups::routes())
|
||||
.merge(utilities::routes())
|
||||
.merge(admin::routes())
|
||||
// Méthode sans route sur une adresse connue : 404 d'Express
|
||||
.method_not_allowed_fallback(static_files::not_found)
|
||||
.route_layer(middleware::from_fn(http::api_response_headers));
|
||||
|
||||
// Pour rejouer une adresse qu'Express aurait servie (casse, « / » final)
|
||||
express_routes::install(api.clone().with_state(state.clone()));
|
||||
|
||||
Router::new()
|
||||
.merge(api)
|
||||
.fallback(static_files::fallback)
|
||||
// Requêtes d'écriture venues d'un autre site refusées (décision R4)
|
||||
.layer(middleware::from_fn_with_state(state.clone(), origin::check))
|
||||
.layer(DefaultBodyLimit::max(BODY_LIMIT))
|
||||
// Comme compression() d'Express : gzip au-delà de 1 Ko
|
||||
.layer(
|
||||
CompressionLayer::new()
|
||||
.gzip(true)
|
||||
.no_br()
|
||||
.no_deflate()
|
||||
.no_zstd()
|
||||
.compress_when(DefaultPredicate::new().and(SizeAbove::new(1024))),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,762 @@
|
||||
// Dates reçues de NocoDB, au comportement du serveur Node :
|
||||
// - normalize_date_string : server/dateUtils.ts (normalizeDateString), échéance
|
||||
// ramenée à AAAA-MM-JJ, jour avant mois quand l'ordre est ambigu ;
|
||||
// - parse_js_date / js_date : new Date(valeur) de V8 (Node 22), formats ISO et
|
||||
// « anciens » (10/25/2026, 2026/10/25, Oct 25 2026, …), utilisé pour les
|
||||
// échéances écrites en base. Le serveur Node tourne en UTC : l'heure locale
|
||||
// de V8 est ici l'UTC.
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
/// normalizeDateString(valeur) : None pour tout ce qui n'est pas une chaîne
|
||||
pub fn normalize_date_value(value: &Value) -> Option<String> {
|
||||
value.as_str().and_then(normalize_date_string)
|
||||
}
|
||||
|
||||
/// normalizeDateString(texte) : AAAA-MM-JJ, ou None si la date n'est pas reconnue.
|
||||
/// Une chaîne déjà au format AAAA-MM-JJ est rendue telle quelle, même invalide
|
||||
/// (« 2026-13-45 »), comme Node.
|
||||
pub fn normalize_date_string(text: &str) -> Option<String> {
|
||||
let trimmed = crate::js::trim(text);
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if is_iso_day(trimmed) {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
if let Some((first, second, year)) = split_numeric_date(trimmed) {
|
||||
let first_num: u32 = first.parse().ok()?;
|
||||
let second_num: u32 = second.parse().ok()?;
|
||||
if first_num > 31 || second_num > 31 || first_num == 0 || second_num == 0 {
|
||||
return None;
|
||||
}
|
||||
// Premier nombre > 12 : JJ/MM ; second > 12 : MM/JJ ; sinon JJ/MM par défaut
|
||||
let (day, month) = if first_num <= 12 && second_num > 12 {
|
||||
(second, first)
|
||||
} else {
|
||||
(first, second)
|
||||
};
|
||||
let (day, month) = (format!("{day:0>2}"), format!("{month:0>2}"));
|
||||
let month_num: u32 = month.parse().ok()?;
|
||||
if !(1..=12).contains(&month_num) {
|
||||
return None;
|
||||
}
|
||||
return Some(format!("{year}-{month}-{day}"));
|
||||
}
|
||||
let date = parse_js_date(trimmed)?;
|
||||
Some(format!("{}-{:02}-{:02}", date.year(), date.month(), date.day()))
|
||||
}
|
||||
|
||||
/// /^\d{4}-\d{2}-\d{2}$/
|
||||
fn is_iso_day(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
bytes.len() == 10
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/
|
||||
fn split_numeric_date(text: &str) -> Option<(&str, &str, &str)> {
|
||||
let mut parts = text.split(['/', '-']);
|
||||
let (first, second, year) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
let digits = |part: &str, min: usize, max: usize| {
|
||||
(min..=max).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit())
|
||||
};
|
||||
(parts.next().is_none() && digits(first, 1, 2) && digits(second, 1, 2) && digits(year, 4, 4))
|
||||
.then_some((first, second, year))
|
||||
}
|
||||
|
||||
/// new Date(valeur) : None pour « Invalid Date »
|
||||
pub fn js_date(value: &Value) -> Option<DateTime<Utc>> {
|
||||
match value {
|
||||
Value::String(text) => parse_js_date(text),
|
||||
Value::Array(_) => parse_js_date(&crate::js::to_js_string(value)),
|
||||
Value::Object(_) => None,
|
||||
other => crate::js::new_date(other),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Analyse des dates de V8 (src/date/dateparser-inl.h) ----------
|
||||
|
||||
const NONE: i64 = i64::MIN;
|
||||
const MAX_TIME_MS: f64 = 8.64e15;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Keyword {
|
||||
Month(i64),
|
||||
AmPm(i64),
|
||||
TimeZone(i64),
|
||||
TimeSeparator,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Token {
|
||||
End,
|
||||
Number { value: i64, length: usize },
|
||||
Symbol(char),
|
||||
Word { keyword: Keyword, length: usize },
|
||||
WhiteSpace,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn is_fixed_number(&self, length: usize) -> bool {
|
||||
matches!(self, Token::Number { length: l, .. } if *l == length)
|
||||
}
|
||||
fn number(&self) -> i64 {
|
||||
match self {
|
||||
Token::Number { value, .. } => *value,
|
||||
_ => NONE,
|
||||
}
|
||||
}
|
||||
fn is_sign(&self) -> bool {
|
||||
matches!(self, Token::Symbol('+' | '-'))
|
||||
}
|
||||
fn sign(&self) -> i64 {
|
||||
if matches!(self, Token::Symbol('-')) { -1 } else { 1 }
|
||||
}
|
||||
fn is_keyword_z(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Token::Word {
|
||||
keyword: Keyword::TimeZone(0),
|
||||
length: 1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const KEYWORDS: [(&str, Keyword); 27] = [
|
||||
("jan", Keyword::Month(1)),
|
||||
("feb", Keyword::Month(2)),
|
||||
("mar", Keyword::Month(3)),
|
||||
("apr", Keyword::Month(4)),
|
||||
("may", Keyword::Month(5)),
|
||||
("jun", Keyword::Month(6)),
|
||||
("jul", Keyword::Month(7)),
|
||||
("aug", Keyword::Month(8)),
|
||||
("sep", Keyword::Month(9)),
|
||||
("oct", Keyword::Month(10)),
|
||||
("nov", Keyword::Month(11)),
|
||||
("dec", Keyword::Month(12)),
|
||||
("am", Keyword::AmPm(0)),
|
||||
("pm", Keyword::AmPm(12)),
|
||||
("ut", Keyword::TimeZone(0)),
|
||||
("utc", Keyword::TimeZone(0)),
|
||||
("z", Keyword::TimeZone(0)),
|
||||
("gmt", Keyword::TimeZone(0)),
|
||||
("cdt", Keyword::TimeZone(-5)),
|
||||
("cst", Keyword::TimeZone(-6)),
|
||||
("edt", Keyword::TimeZone(-4)),
|
||||
("est", Keyword::TimeZone(-5)),
|
||||
("mdt", Keyword::TimeZone(-6)),
|
||||
("mst", Keyword::TimeZone(-7)),
|
||||
("pdt", Keyword::TimeZone(-7)),
|
||||
("pst", Keyword::TimeZone(-8)),
|
||||
("t", Keyword::TimeSeparator),
|
||||
];
|
||||
|
||||
/// KeywordTable::Lookup : les 3 premières lettres ; un mot plus long n'est
|
||||
/// reconnu que pour un nom de mois
|
||||
fn lookup_keyword(prefix: &str, length: usize) -> Keyword {
|
||||
KEYWORDS
|
||||
.iter()
|
||||
.find(|(name, keyword)| *name == prefix && (length <= 3 || matches!(keyword, Keyword::Month(_))))
|
||||
.map(|(_, keyword)| *keyword)
|
||||
.unwrap_or(Keyword::Invalid)
|
||||
}
|
||||
|
||||
/// Blancs pour V8 (IsWhiteSpaceChar) : blancs Unicode et fins de ligne
|
||||
fn is_v8_whitespace(c: char) -> bool {
|
||||
c.is_whitespace() || c == '\u{FEFF}'
|
||||
}
|
||||
|
||||
struct Tokenizer {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
next: Token,
|
||||
}
|
||||
|
||||
impl Tokenizer {
|
||||
fn new(text: &str) -> Self {
|
||||
let mut tokenizer = Self {
|
||||
chars: text.chars().collect(),
|
||||
pos: 0,
|
||||
next: Token::End,
|
||||
};
|
||||
tokenizer.next = tokenizer.scan();
|
||||
tokenizer
|
||||
}
|
||||
|
||||
fn peek(&self) -> Token {
|
||||
self.next
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Token {
|
||||
let current = self.next;
|
||||
self.next = self.scan();
|
||||
current
|
||||
}
|
||||
|
||||
fn skip_symbol(&mut self, symbol: char) -> bool {
|
||||
if self.next == Token::Symbol(symbol) {
|
||||
self.next();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&mut self) -> Token {
|
||||
let Some(&c) = self.chars.get(self.pos) else {
|
||||
return Token::End;
|
||||
};
|
||||
if c.is_ascii_digit() {
|
||||
// ReadUnsignedNumeral : zéros de tête ignorés, 9 chiffres significatifs au plus
|
||||
let start = self.pos;
|
||||
while self.chars.get(self.pos) == Some(&'0') {
|
||||
self.pos += 1;
|
||||
}
|
||||
let mut value: i64 = 0;
|
||||
let mut significant = 0;
|
||||
while let Some(d) = self.chars.get(self.pos).and_then(|c| c.to_digit(10)) {
|
||||
if significant < 9 {
|
||||
value = value * 10 + i64::from(d);
|
||||
}
|
||||
significant += 1;
|
||||
self.pos += 1;
|
||||
}
|
||||
return Token::Number {
|
||||
value,
|
||||
length: self.pos - start,
|
||||
};
|
||||
}
|
||||
if matches!(c, ':' | '-' | '+' | '.' | ')') {
|
||||
self.pos += 1;
|
||||
return Token::Symbol(c);
|
||||
}
|
||||
if c >= 'A' && !is_v8_whitespace(c) {
|
||||
let start = self.pos;
|
||||
while self
|
||||
.chars
|
||||
.get(self.pos)
|
||||
.is_some_and(|c| *c >= 'A' && !is_v8_whitespace(*c))
|
||||
{
|
||||
self.pos += 1;
|
||||
}
|
||||
let length = self.pos - start;
|
||||
let prefix: String = self.chars[start..self.pos]
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
return Token::Word {
|
||||
keyword: lookup_keyword(&prefix, length),
|
||||
length,
|
||||
};
|
||||
}
|
||||
if is_v8_whitespace(c) {
|
||||
while self.chars.get(self.pos).is_some_and(|c| is_v8_whitespace(*c)) {
|
||||
self.pos += 1;
|
||||
}
|
||||
return Token::WhiteSpace;
|
||||
}
|
||||
if c == '(' {
|
||||
// SkipParentheses : commentaire entre parenthèses, imbrications comprises
|
||||
let mut depth = 0;
|
||||
while let Some(&c) = self.chars.get(self.pos) {
|
||||
self.pos += 1;
|
||||
if c == '(' {
|
||||
depth += 1;
|
||||
} else if c == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Token::Unknown;
|
||||
}
|
||||
self.pos += 1;
|
||||
Token::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DayComposer {
|
||||
comp: Vec<i64>,
|
||||
named_month: Option<i64>,
|
||||
iso_date: bool,
|
||||
}
|
||||
|
||||
impl DayComposer {
|
||||
fn add(&mut self, n: i64) -> bool {
|
||||
if self.comp.len() < 3 {
|
||||
self.comp.push(n);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> Option<(i64, i64, i64)> {
|
||||
if self.comp.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut comp = self.comp.clone();
|
||||
comp.resize(3, 1);
|
||||
let is_day = |n: i64| (1..=31).contains(&n);
|
||||
let (mut year, month, day) = match self.named_month {
|
||||
None if self.iso_date || !is_day(comp[0]) => (comp[0], comp[1], comp[2]),
|
||||
None => (comp[2], comp[0], comp[1]),
|
||||
Some(month) if !is_day(comp[0]) => (comp[0], month, comp[1]),
|
||||
Some(month) => (comp[1], month, comp[0]),
|
||||
};
|
||||
if !self.iso_date {
|
||||
if (0..=49).contains(&year) {
|
||||
year += 2000;
|
||||
} else if (50..=99).contains(&year) {
|
||||
year += 1900;
|
||||
}
|
||||
}
|
||||
((1..=12).contains(&month) && is_day(day)).then_some((year, month, day))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TimeComposer {
|
||||
comp: Vec<i64>,
|
||||
hour_offset: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimeComposer {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.comp.is_empty()
|
||||
}
|
||||
fn add(&mut self, n: i64) -> bool {
|
||||
if self.comp.len() < 4 {
|
||||
self.comp.push(n);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn add_final(&mut self, n: i64) -> bool {
|
||||
if !self.add(n) {
|
||||
return false;
|
||||
}
|
||||
self.comp.resize(4, 0);
|
||||
true
|
||||
}
|
||||
fn is_expecting(&self, n: i64) -> bool {
|
||||
match self.comp.len() {
|
||||
1 => (0..=59).contains(&n),
|
||||
2 => (0..=59).contains(&n),
|
||||
3 => (0..=999).contains(&n),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn write(&self) -> Option<(i64, i64, i64, i64)> {
|
||||
let mut comp = self.comp.clone();
|
||||
comp.resize(4, 0);
|
||||
let (mut hour, minute, second, ms) = (comp[0], comp[1], comp[2], comp[3]);
|
||||
if let Some(offset) = self.hour_offset {
|
||||
if !(0..=12).contains(&hour) {
|
||||
return None;
|
||||
}
|
||||
hour = hour % 12 + offset;
|
||||
}
|
||||
let valid = (0..=23).contains(&hour)
|
||||
&& (0..=59).contains(&minute)
|
||||
&& (0..=59).contains(&second)
|
||||
&& (0..=999).contains(&ms);
|
||||
(valid || (hour == 24 && minute == 0 && second == 0 && ms == 0)).then_some((hour, minute, second, ms))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TimeZoneComposer {
|
||||
sign: Option<i64>,
|
||||
hour: Option<i64>,
|
||||
minute: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimeZoneComposer {
|
||||
fn set(&mut self, offset_hours: i64) {
|
||||
self.sign = Some(if offset_hours < 0 { -1 } else { 1 });
|
||||
self.hour = Some(offset_hours.abs());
|
||||
self.minute = Some(0);
|
||||
}
|
||||
fn is_expecting(&self, n: i64) -> bool {
|
||||
self.hour.is_some() && self.minute.is_none() && (0..=59).contains(&n)
|
||||
}
|
||||
fn is_utc(&self) -> bool {
|
||||
self.hour == Some(0) && self.minute == Some(0)
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
self.hour.is_none()
|
||||
}
|
||||
/// Décalage en secondes, None pour l'heure locale
|
||||
fn write(&self) -> Option<i64> {
|
||||
let sign = self.sign?;
|
||||
Some(sign * (self.hour.unwrap_or(0) * 3600 + self.minute.unwrap_or(0) * 60))
|
||||
}
|
||||
}
|
||||
|
||||
/// ReadMilliseconds : les trois premiers chiffres significatifs
|
||||
fn read_milliseconds(token: Token) -> i64 {
|
||||
let Token::Number { value, length } = token else {
|
||||
return 0;
|
||||
};
|
||||
match length {
|
||||
1 => value * 100,
|
||||
2 => value * 10,
|
||||
3 => value,
|
||||
_ => {
|
||||
let length = length.min(9);
|
||||
value / 10_i64.pow((length - 3) as u32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ParseES5DateTime : rend le premier jeton non traité, ou None (date invalide)
|
||||
fn parse_es5(
|
||||
scanner: &mut Tokenizer,
|
||||
day: &mut DayComposer,
|
||||
time: &mut TimeComposer,
|
||||
tz: &mut TimeZoneComposer,
|
||||
) -> Option<Token> {
|
||||
if scanner.peek().is_sign() {
|
||||
let sign_token = scanner.next();
|
||||
if !scanner.peek().is_fixed_number(6) {
|
||||
return Some(sign_token);
|
||||
}
|
||||
let year = scanner.next().number();
|
||||
if sign_token.sign() < 0 && year == 0 {
|
||||
return Some(sign_token);
|
||||
}
|
||||
day.add(sign_token.sign() * year);
|
||||
} else if scanner.peek().is_fixed_number(4) {
|
||||
day.add(scanner.next().number());
|
||||
} else {
|
||||
return Some(scanner.next());
|
||||
}
|
||||
if scanner.skip_symbol('-') {
|
||||
let month = scanner.peek();
|
||||
if !month.is_fixed_number(2) || !(1..=12).contains(&month.number()) {
|
||||
return Some(scanner.next());
|
||||
}
|
||||
day.add(scanner.next().number());
|
||||
if scanner.skip_symbol('-') {
|
||||
let day_token = scanner.peek();
|
||||
if !day_token.is_fixed_number(2) || !(1..=31).contains(&day_token.number()) {
|
||||
return Some(scanner.next());
|
||||
}
|
||||
day.add(scanner.next().number());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
scanner.peek(),
|
||||
Token::Word {
|
||||
keyword: Keyword::TimeSeparator,
|
||||
..
|
||||
}
|
||||
) {
|
||||
if scanner.peek() != Token::End {
|
||||
return Some(scanner.next());
|
||||
}
|
||||
} else {
|
||||
scanner.next();
|
||||
let hour = scanner.peek();
|
||||
if !hour.is_fixed_number(2) || !(0..=24).contains(&hour.number()) {
|
||||
return None;
|
||||
}
|
||||
let hour_is_24 = hour.number() == 24;
|
||||
time.add(scanner.next().number());
|
||||
if !scanner.skip_symbol(':') {
|
||||
return None;
|
||||
}
|
||||
let minute = scanner.peek();
|
||||
if !minute.is_fixed_number(2)
|
||||
|| !(0..=59).contains(&minute.number())
|
||||
|| (hour_is_24 && minute.number() > 0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
time.add(scanner.next().number());
|
||||
if scanner.skip_symbol(':') {
|
||||
let second = scanner.peek();
|
||||
if !second.is_fixed_number(2)
|
||||
|| !(0..=59).contains(&second.number())
|
||||
|| (hour_is_24 && second.number() > 0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
time.add(scanner.next().number());
|
||||
if scanner.skip_symbol('.') {
|
||||
let ms = scanner.peek();
|
||||
if !matches!(ms, Token::Number { .. }) || (hour_is_24 && ms.number() > 0) {
|
||||
return None;
|
||||
}
|
||||
time.add(read_milliseconds(scanner.next()));
|
||||
}
|
||||
}
|
||||
if scanner.peek().is_keyword_z() {
|
||||
scanner.next();
|
||||
tz.set(0);
|
||||
} else if scanner.peek().is_sign() {
|
||||
tz.sign = Some(scanner.next().sign());
|
||||
if scanner.peek().is_fixed_number(4) {
|
||||
let hour_minute = scanner.next().number();
|
||||
let (hour, minute) = (hour_minute / 100, hour_minute % 100);
|
||||
if !(0..=23).contains(&hour) || !(0..=59).contains(&minute) {
|
||||
return None;
|
||||
}
|
||||
tz.hour = Some(hour);
|
||||
tz.minute = Some(minute);
|
||||
} else {
|
||||
let hour = scanner.peek();
|
||||
if !hour.is_fixed_number(2) || !(0..=23).contains(&hour.number()) {
|
||||
return None;
|
||||
}
|
||||
tz.hour = Some(scanner.next().number());
|
||||
if !scanner.skip_symbol(':') {
|
||||
return None;
|
||||
}
|
||||
let minute = scanner.peek();
|
||||
if !minute.is_fixed_number(2) || !(0..=59).contains(&minute.number()) {
|
||||
return None;
|
||||
}
|
||||
tz.minute = Some(scanner.next().number());
|
||||
}
|
||||
}
|
||||
if scanner.peek() != Token::End {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Date seule sans fuseau : UTC ; date et heure sans fuseau : heure locale
|
||||
if tz.is_empty() && time.is_empty() {
|
||||
tz.set(0);
|
||||
}
|
||||
day.iso_date = true;
|
||||
Some(Token::End)
|
||||
}
|
||||
|
||||
/// new Date(texte) de V8 : None pour « Invalid Date »
|
||||
pub fn parse_js_date(text: &str) -> Option<DateTime<Utc>> {
|
||||
let mut scanner = Tokenizer::new(text);
|
||||
let mut day = DayComposer::default();
|
||||
let mut time = TimeComposer::default();
|
||||
let mut tz = TimeZoneComposer::default();
|
||||
|
||||
let mut token = parse_es5(&mut scanner, &mut day, &mut time, &mut tz)?;
|
||||
let mut has_read_number = !day.comp.is_empty();
|
||||
while token != Token::End {
|
||||
match token {
|
||||
Token::Number { value: n, .. } => {
|
||||
has_read_number = true;
|
||||
if scanner.skip_symbol(':') {
|
||||
if scanner.skip_symbol(':') {
|
||||
if !time.is_empty() {
|
||||
return None;
|
||||
}
|
||||
time.add(n);
|
||||
time.add(0);
|
||||
} else {
|
||||
if !time.add(n) {
|
||||
return None;
|
||||
}
|
||||
if scanner.peek() == Token::Symbol('.') {
|
||||
scanner.next();
|
||||
}
|
||||
}
|
||||
} else if scanner.skip_symbol('.') && time.is_expecting(n) {
|
||||
// Comme V8, le point est consommé même si l'heure ne l'attend pas
|
||||
time.add(n);
|
||||
if !matches!(scanner.peek(), Token::Number { .. }) {
|
||||
return None;
|
||||
}
|
||||
let ms = read_milliseconds(scanner.next());
|
||||
time.add_final(ms);
|
||||
} else if tz.is_expecting(n) {
|
||||
tz.minute = Some(n);
|
||||
} else if time.is_expecting(n) {
|
||||
time.add_final(n);
|
||||
let peek = scanner.peek();
|
||||
if !matches!(peek, Token::End | Token::WhiteSpace)
|
||||
&& !peek.is_keyword_z()
|
||||
&& !peek.is_sign()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
if !day.add(n) {
|
||||
return None;
|
||||
}
|
||||
scanner.skip_symbol('-');
|
||||
}
|
||||
}
|
||||
Token::Word { keyword, .. } => match keyword {
|
||||
Keyword::AmPm(offset) if !time.is_empty() => time.hour_offset = Some(offset),
|
||||
Keyword::Month(month) => {
|
||||
day.named_month = Some(month);
|
||||
scanner.skip_symbol('-');
|
||||
}
|
||||
Keyword::TimeZone(offset) if has_read_number => tz.set(offset),
|
||||
_ => {
|
||||
// Mot inconnu : interdit après un nombre, ou collé à un nombre
|
||||
if has_read_number || matches!(scanner.peek(), Token::Number { .. }) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
},
|
||||
Token::Symbol('+' | '-') if tz.is_utc() || !time.is_empty() => {
|
||||
tz.sign = Some(token.sign());
|
||||
let (mut n, mut length) = (0, 0);
|
||||
if let Token::Number { value, length: l } = scanner.peek() {
|
||||
scanner.next();
|
||||
n = value;
|
||||
length = l;
|
||||
}
|
||||
has_read_number = true;
|
||||
if scanner.peek() == Token::Symbol(':') {
|
||||
tz.hour = Some(n);
|
||||
tz.minute = None;
|
||||
} else if length == 1 || length == 2 {
|
||||
tz.hour = Some(n);
|
||||
tz.minute = Some(0);
|
||||
} else if length == 3 || length == 4 {
|
||||
tz.hour = Some(n / 100);
|
||||
tz.minute = Some(n % 100);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Token::Symbol('+' | '-' | ')') if has_read_number => return None,
|
||||
_ => {}
|
||||
}
|
||||
token = scanner.next();
|
||||
}
|
||||
|
||||
let (year, month, day) = day.write()?;
|
||||
let (hour, minute, second, ms) = time.write()?;
|
||||
let offset = tz.write();
|
||||
make_date(year, month, day, hour, minute, second, ms, offset)
|
||||
}
|
||||
|
||||
/// MakeDay / MakeTime / TimeClip : le jour peut déborder sur le mois suivant
|
||||
/// (« 2026-02-31 » donne le 3 mars), l'heure 24 sur le lendemain
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_date(
|
||||
year: i64,
|
||||
month: i64,
|
||||
day: i64,
|
||||
hour: i64,
|
||||
minute: i64,
|
||||
second: i64,
|
||||
ms: i64,
|
||||
offset_seconds: Option<i64>,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let first = NaiveDate::from_ymd_opt(i32::try_from(year).ok()?, month as u32, 1)?;
|
||||
let date = first.checked_add_signed(Duration::days(day - 1))?;
|
||||
let millis = date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis() as f64
|
||||
+ ((hour * 3600 + minute * 60 + second) * 1000 + ms) as f64
|
||||
- offset_seconds.unwrap_or(0) as f64 * 1000.0;
|
||||
if millis.abs() > MAX_TIME_MS {
|
||||
return None;
|
||||
}
|
||||
Utc.timestamp_millis_opt(millis as i64).single()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn iso(text: &str) -> Option<String> {
|
||||
parse_js_date(text).map(|date| crate::js::iso(&date))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalise_comme_node() {
|
||||
// Valeurs relevées avec normalizeDateString (server/dateUtils.ts) dans Node 22, TZ=UTC
|
||||
let cases = [
|
||||
("2026-10-25", Some("2026-10-25")),
|
||||
(" 2026-10-25 ", Some("2026-10-25")),
|
||||
("25/10/2026", Some("2026-10-25")),
|
||||
("10/25/2026", Some("2026-10-25")),
|
||||
("05/06/2026", Some("2026-06-05")),
|
||||
("5-6-2026", Some("2026-06-05")),
|
||||
("31/12/2026", Some("2026-12-31")),
|
||||
("32/01/2026", None),
|
||||
("00/12/2026", None),
|
||||
("13/13/2026", None),
|
||||
("12/31/2026", Some("2026-12-31")),
|
||||
("2026-10-25T10:30:00.000Z", Some("2026-10-25")),
|
||||
("2026-10-25T23:30:00+02:00", Some("2026-10-25")),
|
||||
("2026-10-25 08:00:00", Some("2026-10-25")),
|
||||
("2026/10/25", Some("2026-10-25")),
|
||||
("Oct 25 2026", Some("2026-10-25")),
|
||||
("25 Oct 2026", Some("2026-10-25")),
|
||||
("Sun, 25 Oct 2026 10:00:00 GMT", Some("2026-10-25")),
|
||||
("pas une date", None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("2026-02-31", Some("2026-02-31")),
|
||||
("2026-2-3", Some("2026-02-03")),
|
||||
("1/2/2026", Some("2026-02-01")),
|
||||
("2026-10", Some("2026-10-01")),
|
||||
("2026", Some("2026-01-01")),
|
||||
("20261025", None),
|
||||
("2026-10-25T10:30", Some("2026-10-25")),
|
||||
("10/25/2026 10:00", Some("2026-10-25")),
|
||||
("2026-13-45", Some("2026-13-45")),
|
||||
("2026-02-30T00:00:00Z", Some("2026-03-02")),
|
||||
("2026/2/30", Some("2026-03-02")),
|
||||
("99/99/2026", None),
|
||||
("1/13/2026", Some("2026-01-13")),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(normalize_date_string(input).as_deref(), expected, "{input:?}");
|
||||
}
|
||||
assert_eq!(normalize_date_value(&json!(20261025)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_date_comme_v8() {
|
||||
// new Date(texte).toISOString() dans Node 22, TZ=UTC
|
||||
let cases = [
|
||||
("2026-02-31", Some("2026-03-03T00:00:00.000Z")),
|
||||
("2026-02-30T00:00:00Z", Some("2026-03-02T00:00:00.000Z")),
|
||||
("2026/2/30", Some("2026-03-02T00:00:00.000Z")),
|
||||
("2026-10-25 08:00", Some("2026-10-25T08:00:00.000Z")),
|
||||
("10/25/2026", Some("2026-10-25T00:00:00.000Z")),
|
||||
("2026/10/25 10:00:00", Some("2026-10-25T10:00:00.000Z")),
|
||||
("Oct 25 2026", Some("2026-10-25T00:00:00.000Z")),
|
||||
("2026-10-25T10:30:00.123456Z", Some("2026-10-25T10:30:00.123Z")),
|
||||
("+002026-10-25", Some("2026-10-25T00:00:00.000Z")),
|
||||
("2026-10-25T24:00:00Z", Some("2026-10-26T00:00:00.000Z")),
|
||||
("2026-10-25T10:30:00Z ", None),
|
||||
("2026-1-5", Some("2026-01-05T00:00:00.000Z")),
|
||||
("2026-10-25T00:00:00.000Z", Some("2026-10-25T00:00:00.000Z")),
|
||||
("2026-10-25T23:30:00+02:00", Some("2026-10-25T21:30:00.000Z")),
|
||||
("25/10/2026", None),
|
||||
("2026-13-45", None),
|
||||
("pas une date", None),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(iso(input).as_deref(), expected, "{input:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
js_date(&json!(0)).map(|d| crate::js::iso(&d)).as_deref(),
|
||||
Some("1970-01-01T00:00:00.000Z")
|
||||
);
|
||||
assert_eq!(js_date(&json!({})), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,548 @@
|
||||
// Courriel de demande de documents aux fournisseurs (rapprochement BL / factures) :
|
||||
// - contenu : shared/supplierMail.ts (objet, texte, HTML avec la signature et le
|
||||
// logo du magasin), reproduit caractère pour caractère ;
|
||||
// - envoi : server/emailService.ts, par le serveur SMTP propre à chaque magasin
|
||||
// (mot de passe chiffré en base, déchiffré au moment de l'envoi), logo joint
|
||||
// en pièce intégrée « cid:logo » ;
|
||||
// - journal : table supplier_mail_logs (storage.createSupplierMailLog).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{Datelike, NaiveDate, NaiveDateTime};
|
||||
use lettre::message::header::{ContentDisposition, ContentId, ContentType};
|
||||
use lettre::message::{Body, Mailbox, Mailboxes, MultiPart, SinglePart, header};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
use rand::RngCore;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::schema::{GroupsRow, SupplierMailLogsRow};
|
||||
|
||||
/// Livraison telle que la lit le contenu du mail (SupplierMailDelivery)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MailDelivery {
|
||||
pub bl_number: Option<String>,
|
||||
pub bl_amount: Option<Decimal>,
|
||||
pub invoice_reference: Option<String>,
|
||||
pub scheduled_date: Option<NaiveDate>,
|
||||
pub delivered_date: Option<NaiveDateTime>,
|
||||
pub supplier_name: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Coordonnées du magasin reprises dans la signature (StoreSignature)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StoreSignature {
|
||||
pub name: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl StoreSignature {
|
||||
/// Signature d'un magasin : nom, adresse, téléphone, adresse d'expédition
|
||||
pub fn of_group(group: &GroupsRow) -> Self {
|
||||
Self {
|
||||
name: Some(group.name.clone()),
|
||||
address: group.address.clone(),
|
||||
phone: group.phone.clone(),
|
||||
email: group.smtp_sender_email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Valeur « vraie » en JavaScript : présente et non vide
|
||||
fn filled(value: &Option<String>) -> Option<&str> {
|
||||
value.as_deref().filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
/// formatDate : jj/mm/aaaa de la date de livraison, sinon de la date prévue
|
||||
fn formatted_date(delivery: &MailDelivery) -> Option<String> {
|
||||
let date = match (delivery.delivered_date, delivery.scheduled_date) {
|
||||
(Some(delivered), _) => delivered.date(),
|
||||
(None, Some(scheduled)) => scheduled,
|
||||
(None, None) => return None,
|
||||
};
|
||||
Some(format!("{:02}/{:02}/{}", date.day(), date.month(), date.year()))
|
||||
}
|
||||
|
||||
/// buildSupplierMailSubject : identifie la livraison concernée
|
||||
pub fn subject(delivery: &MailDelivery) -> String {
|
||||
let date = formatted_date(delivery);
|
||||
let bl_number = filled(&delivery.bl_number);
|
||||
let reference = match (bl_number, &date) {
|
||||
(Some(bl), _) => format!("BL n° {bl}"),
|
||||
(None, Some(date)) => format!("Livraison du {date}"),
|
||||
(None, None) => "Livraison".to_string(),
|
||||
};
|
||||
let mut parts = vec!["Demande de facture (PDF) ou BL (Excel)".to_string()];
|
||||
parts.push(match (bl_number, &date) {
|
||||
(Some(_), Some(date)) => format!("{reference} du {date}"),
|
||||
_ => reference,
|
||||
});
|
||||
if let Some(name) = filled(&delivery.group_name) {
|
||||
parts.push(name.to_string());
|
||||
}
|
||||
parts.join(" - ")
|
||||
}
|
||||
|
||||
/// buildDeliveryDetails : seules les informations renseignées sont listées
|
||||
fn details(delivery: &MailDelivery) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(name) = filled(&delivery.supplier_name) {
|
||||
lines.push(format!("Fournisseur : {name}"));
|
||||
}
|
||||
if let Some(name) = filled(&delivery.group_name) {
|
||||
lines.push(format!("Magasin : {name}"));
|
||||
}
|
||||
if let Some(date) = formatted_date(delivery) {
|
||||
lines.push(format!("Date de livraison : {date}"));
|
||||
}
|
||||
if let Some(bl) = filled(&delivery.bl_number) {
|
||||
lines.push(format!("N° de BL : {bl}"));
|
||||
}
|
||||
if let Some(amount) = delivery.bl_amount {
|
||||
// parseFloat(montant).toFixed(2)
|
||||
lines.push(format!("Montant du BL : {} €", to_fixed_2(amount)));
|
||||
}
|
||||
if let Some(reference) = filled(&delivery.invoice_reference) {
|
||||
lines.push(format!("Référence facture : {reference}"));
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push("(informations de livraison à préciser)".to_string());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// parseFloat(montant).toFixed(2) d'un montant à deux décimales au plus
|
||||
/// (« -0 » s'écrit « 0.00 »)
|
||||
fn to_fixed_2(amount: Decimal) -> String {
|
||||
let amount = amount.round_dp(2);
|
||||
let amount = if amount.is_zero() { Decimal::ZERO } else { amount };
|
||||
format!("{amount:.2}")
|
||||
}
|
||||
|
||||
/// buildSupplierMailText : version texte (repli des clients sans HTML)
|
||||
pub fn text(delivery: &MailDelivery, store: Option<&StoreSignature>) -> String {
|
||||
let signature = store
|
||||
.map(|store| {
|
||||
[
|
||||
filled(&store.name).map(str::to_string),
|
||||
filled(&store.address).map(str::to_string),
|
||||
filled(&store.phone).map(|phone| format!("Tél. {phone}")),
|
||||
filled(&store.email).map(str::to_string),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut lines: Vec<String> = [
|
||||
"Bonjour,",
|
||||
"",
|
||||
"Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :",
|
||||
"",
|
||||
"- la facture au format PDF, ou",
|
||||
"- le bon de livraison au format Excel",
|
||||
"",
|
||||
"Livraison concernée :",
|
||||
]
|
||||
.iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect();
|
||||
lines.extend(details(delivery).into_iter().map(|line| format!("- {line}")));
|
||||
lines.extend(
|
||||
["", "Vous en remerciant par avance.", "", "Cordialement,"]
|
||||
.iter()
|
||||
.map(|line| line.to_string()),
|
||||
);
|
||||
if !signature.is_empty() {
|
||||
lines.push(signature);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// escapeHtml : & < > " (l'apostrophe reste telle quelle)
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// buildSupplierMailHtml : version HTML, signature et logo (référencé par cid:logo)
|
||||
pub fn html(delivery: &MailDelivery, store: Option<&StoreSignature>, has_logo: bool) -> String {
|
||||
let details: String = details(delivery)
|
||||
.iter()
|
||||
.map(|line| format!("<li>{}</li>", escape_html(line)))
|
||||
.collect();
|
||||
let signature_lines: Vec<String> = store
|
||||
.map(|store| {
|
||||
[
|
||||
filled(&store.name).map(|name| format!("<strong>{}</strong>", escape_html(name))),
|
||||
filled(&store.address).map(|address| escape_html(address).replace('\n', "<br>")),
|
||||
filled(&store.phone).map(|phone| format!("Tél. {}", escape_html(phone))),
|
||||
filled(&store.email).map(|email| {
|
||||
let email = escape_html(email);
|
||||
format!("<a href=\"mailto:{email}\">{email}</a>")
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let logo_block = if has_logo {
|
||||
"<p style=\"margin:0 0 8px 0;\"><img src=\"cid:logo\" alt=\"\" style=\"max-width:200px;height:auto;border:0;\"></p>"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let signature_block = if !signature_lines.is_empty() || !logo_block.is_empty() {
|
||||
format!(
|
||||
"{logo_block}<p style=\"margin:0;color:#444;font-size:13px;line-height:1.5;\">{}</p>",
|
||||
signature_lines.join("<br>")
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"<div style=\"font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;\">
|
||||
<p>Bonjour,</p>
|
||||
<p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :</p>
|
||||
<ul>
|
||||
<li>la facture au format PDF, ou</li>
|
||||
<li>le bon de livraison au format Excel</li>
|
||||
</ul>
|
||||
<p style=\"margin-bottom:4px;\">Livraison concernée :</p>
|
||||
<ul>{details}</ul>
|
||||
<p>Vous en remerciant par avance.</p>
|
||||
<p style=\"margin-bottom:12px;\">Cordialement,</p>
|
||||
{signature_block}
|
||||
</div>"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Envoi ----------
|
||||
|
||||
/// getMissingSmtpFields : ce qu'il reste à renseigner sur la fiche du magasin
|
||||
pub fn missing_smtp_fields(group: &GroupsRow) -> Vec<&'static str> {
|
||||
let blank = |value: &Option<String>| crate::js::trim(value.as_deref().unwrap_or_default()).is_empty();
|
||||
let mut missing = Vec::new();
|
||||
if blank(&group.smtp_host) {
|
||||
missing.push("serveur SMTP");
|
||||
}
|
||||
if group.smtp_port.is_none_or(|port| port == 0) {
|
||||
missing.push("port SMTP");
|
||||
}
|
||||
if blank(&group.smtp_sender_email) {
|
||||
missing.push("adresse expéditeur");
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// Logo du magasin en data URI (parseLogoDataUri) : contenu, type, nom de fichier
|
||||
struct Logo {
|
||||
content: Vec<u8>,
|
||||
content_type: String,
|
||||
filename: String,
|
||||
}
|
||||
|
||||
/// /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/ sur le logo sans blancs autour
|
||||
fn parse_logo(logo: Option<&str>) -> Option<Logo> {
|
||||
let logo = crate::js::trim(logo.filter(|logo| !logo.is_empty())?);
|
||||
let rest = logo.strip_prefix("data:image/")?;
|
||||
let (subtype, data) = rest.split_once(";base64,")?;
|
||||
let subtype_ok = !subtype.is_empty()
|
||||
&& subtype
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || ".+-".contains(c));
|
||||
// « . » de JavaScript : tout sauf les fins de ligne
|
||||
let data_ok = !data.is_empty() && !data.contains(['\n', '\r', '\u{2028}', '\u{2029}']);
|
||||
if !subtype_ok || !data_ok {
|
||||
return None;
|
||||
}
|
||||
let extension = subtype
|
||||
.split('+')
|
||||
.next()
|
||||
.filter(|ext| !ext.is_empty())
|
||||
.unwrap_or("png");
|
||||
Some(Logo {
|
||||
content: crate::js::buffer_from_base64(data),
|
||||
content_type: format!("image/{subtype}"),
|
||||
filename: format!("logo.{extension}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Erreur d'envoi : le message que nodemailer donnerait pour les cas courants
|
||||
/// (serveur injoignable, nom inconnu), sinon celui de lettre
|
||||
fn smtp_error_message(error: &lettre::transport::smtp::Error, host: &str, port: u16) -> String {
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error);
|
||||
while let Some(current) = source {
|
||||
if let Some(io) = current.downcast_ref::<std::io::Error>() {
|
||||
if io.kind() == std::io::ErrorKind::ConnectionRefused {
|
||||
return format!("connect ECONNREFUSED {host}:{port}");
|
||||
}
|
||||
if io.to_string().contains("failed to lookup address") {
|
||||
return format!("getaddrinfo ENOTFOUND {host}");
|
||||
}
|
||||
}
|
||||
source = current.source();
|
||||
}
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
/// Transporteur SMTP du magasin (createTransporter) : authentification seulement
|
||||
/// si un utilisateur et un mot de passe sont renseignés
|
||||
fn transport(
|
||||
group: &GroupsRow,
|
||||
encryption_key: &str,
|
||||
) -> Result<(AsyncSmtpTransport<Tokio1Executor>, String, u16), String> {
|
||||
let missing = missing_smtp_fields(group);
|
||||
if !missing.is_empty() {
|
||||
return Err(format!("Configuration SMTP incomplète : {}", missing.join(", ")));
|
||||
}
|
||||
let password = match group.smtp_password.as_deref() {
|
||||
Some(value) if !value.is_empty() => Some(crate::crypto::decrypt(value, encryption_key)?),
|
||||
_ => None,
|
||||
};
|
||||
let host = crate::js::trim(group.smtp_host.as_deref().unwrap_or_default()).to_string();
|
||||
let port = u16::try_from(group.smtp_port.unwrap_or_default())
|
||||
.map_err(|_| format!("Port SMTP invalide : {:?}", group.smtp_port))?;
|
||||
// secure : TLS dès la connexion ; sinon STARTTLS si le serveur le propose (un
|
||||
// nom d'hôte inutilisable en TLS laisse alors la connexion échouer d'elle-même)
|
||||
let tls = match (group.smtp_secure == Some(true), TlsParameters::new(host.clone())) {
|
||||
(true, parameters) => Tls::Wrapper(parameters.map_err(|error| error.to_string())?),
|
||||
(false, Ok(parameters)) => Tls::Opportunistic(parameters),
|
||||
(false, Err(_)) => Tls::None,
|
||||
};
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.clone())
|
||||
.port(port)
|
||||
.tls(tls)
|
||||
.timeout(Some(Duration::from_secs(60)));
|
||||
let user = group
|
||||
.smtp_user
|
||||
.as_deref()
|
||||
.map(crate::js::trim)
|
||||
.unwrap_or_default();
|
||||
if let Some(password) = password.filter(|password| !password.is_empty() && !user.is_empty()) {
|
||||
builder = builder.credentials(Credentials::new(user.to_string(), password));
|
||||
}
|
||||
Ok((builder.build(), host, port))
|
||||
}
|
||||
|
||||
/// verifySmtpConfig : connexion, présentation et authentification, sans envoi
|
||||
pub async fn verify_smtp_config(group: &GroupsRow, encryption_key: &str) -> Result<(), String> {
|
||||
let (transport, host, port) = transport(group, encryption_key)?;
|
||||
transport
|
||||
.test_connection()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| smtp_error_message(&error, &host, port))
|
||||
}
|
||||
|
||||
/// Identifiant du message, au format de nodemailer : <xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx@domaine>
|
||||
fn message_id(sender_email: &str) -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let domain = sender_email.rsplit('@').next().unwrap_or("localhost");
|
||||
format!(
|
||||
"<{}-{}-{}-{}-{}@{domain}>",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
/// sendSupplierDocumentRequest : envoie la demande de facture PDF / BL Excel et
|
||||
/// rend l'identifiant du message, ou le message d'erreur à journaliser
|
||||
pub async fn send_supplier_document_request(
|
||||
group: &GroupsRow,
|
||||
delivery: &MailDelivery,
|
||||
supplier_email: &str,
|
||||
encryption_key: &str,
|
||||
) -> Result<String, String> {
|
||||
let (transport, host, port) = transport(group, encryption_key)?;
|
||||
let sender_email = crate::js::trim(group.smtp_sender_email.as_deref().unwrap_or_default()).to_string();
|
||||
let sender_name = group
|
||||
.smtp_sender_name
|
||||
.as_deref()
|
||||
.map(crate::js::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.or_else(|| Some(crate::js::trim(&group.name)).filter(|name| !name.is_empty()));
|
||||
let invalid = |error: lettre::address::AddressError| error.to_string();
|
||||
let from = Mailbox::new(
|
||||
sender_name.map(str::to_string),
|
||||
sender_email.parse().map_err(invalid)?,
|
||||
);
|
||||
let reply_to = Mailbox::new(None, sender_email.parse().map_err(invalid)?);
|
||||
let to: Mailboxes = crate::js::trim(supplier_email).parse().map_err(invalid)?;
|
||||
|
||||
let store = StoreSignature::of_group(group);
|
||||
let logo = parse_logo(group.logo.as_deref());
|
||||
let html_part = SinglePart::html(html(delivery, Some(&store), logo.is_some()));
|
||||
let text_part = SinglePart::plain(text(delivery, Some(&store)));
|
||||
let alternative = match logo {
|
||||
Some(logo) => {
|
||||
let content_type = ContentType::parse(&format!("{}; name={}", logo.content_type, logo.filename))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let image = SinglePart::builder()
|
||||
.header(content_type)
|
||||
.header(ContentId::from("<logo>".to_string()))
|
||||
.header(ContentDisposition::inline_with_name(&logo.filename))
|
||||
.body(Body::new(logo.content));
|
||||
MultiPart::alternative()
|
||||
.singlepart(text_part)
|
||||
.multipart(MultiPart::related().singlepart(html_part).singlepart(image))
|
||||
}
|
||||
None => MultiPart::alternative()
|
||||
.singlepart(text_part)
|
||||
.singlepart(html_part),
|
||||
};
|
||||
let id = message_id(&sender_email);
|
||||
let message = Message::builder()
|
||||
.from(from)
|
||||
.mailbox(header::To::from(to))
|
||||
.reply_to(reply_to)
|
||||
.subject(subject(delivery))
|
||||
.message_id(Some(id.clone()))
|
||||
.multipart(alternative)
|
||||
.map_err(|error| error.to_string())?;
|
||||
transport
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|error| smtp_error_message(&error, &host, port))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Ligne du journal des relances (insertSupplierMailLogSchema)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MailLog {
|
||||
pub delivery_id: i32,
|
||||
pub group_id: i32,
|
||||
pub supplier_id: Option<i32>,
|
||||
pub supplier_name: Option<String>,
|
||||
pub sent_to: String,
|
||||
pub subject: String,
|
||||
/// « sent » ou « failed »
|
||||
pub status: &'static str,
|
||||
pub error_message: Option<String>,
|
||||
pub message_id: Option<String>,
|
||||
pub sent_by: String,
|
||||
pub sent_by_name: String,
|
||||
}
|
||||
|
||||
/// storage.createSupplierMailLog
|
||||
pub async fn record_mail_log(db: &PgPool, log: &MailLog) -> Result<SupplierMailLogsRow, sqlx::Error> {
|
||||
let mut data = Map::new();
|
||||
data.insert("deliveryId".into(), json!(log.delivery_id));
|
||||
data.insert("groupId".into(), json!(log.group_id));
|
||||
data.insert("supplierId".into(), json!(log.supplier_id));
|
||||
data.insert("supplierName".into(), json!(log.supplier_name));
|
||||
data.insert("sentTo".into(), json!(log.sent_to));
|
||||
data.insert("subject".into(), json!(log.subject));
|
||||
data.insert("status".into(), json!(log.status));
|
||||
data.insert("errorMessage".into(), json!(log.error_message));
|
||||
data.insert("messageId".into(), json!(log.message_id));
|
||||
data.insert("sentBy".into(), json!(log.sent_by));
|
||||
data.insert("sentByName".into(), Value::String(log.sent_by_name.clone()));
|
||||
crate::db::insert::<SupplierMailLogsRow>(db, &data).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn delivery() -> MailDelivery {
|
||||
MailDelivery {
|
||||
bl_number: Some("BL-1002".into()),
|
||||
bl_amount: Some(Decimal::from_str("310.00").unwrap()),
|
||||
invoice_reference: None,
|
||||
scheduled_date: NaiveDate::from_ymd_opt(2026, 10, 10),
|
||||
delivered_date: NaiveDate::from_ymd_opt(2026, 10, 10).and_then(|d| d.and_hms_opt(14, 0, 0)),
|
||||
supplier_name: Some("Bonduelle".into()),
|
||||
group_name: Some("Magasin Nancy".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> StoreSignature {
|
||||
StoreSignature {
|
||||
name: Some("Magasin Nancy".into()),
|
||||
address: Some("12 rue des Dominicains, 54000 Nancy".into()),
|
||||
phone: Some("03 83 00 00 01".into()),
|
||||
email: Some("nancy@banc.test".into()),
|
||||
}
|
||||
}
|
||||
|
||||
// Valeurs produites par shared/supplierMail.ts (Node 22, TZ=UTC)
|
||||
#[test]
|
||||
fn objet_comme_node() {
|
||||
assert_eq!(
|
||||
subject(&delivery()),
|
||||
"Demande de facture (PDF) ou BL (Excel) - BL n° BL-1002 du 10/10/2026 - Magasin Nancy"
|
||||
);
|
||||
let scheduled_only = MailDelivery {
|
||||
scheduled_date: NaiveDate::from_ymd_opt(2026, 10, 10),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
subject(&scheduled_only),
|
||||
"Demande de facture (PDF) ou BL (Excel) - Livraison du 10/10/2026"
|
||||
);
|
||||
assert_eq!(
|
||||
subject(&MailDelivery::default()),
|
||||
"Demande de facture (PDF) ou BL (Excel) - Livraison"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texte_comme_node() {
|
||||
assert_eq!(
|
||||
text(&delivery(), Some(&store())),
|
||||
"Bonjour,\n\nDans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :\n\n- la facture au format PDF, ou\n- le bon de livraison au format Excel\n\nLivraison concernée :\n- Fournisseur : Bonduelle\n- Magasin : Magasin Nancy\n- Date de livraison : 10/10/2026\n- N° de BL : BL-1002\n- Montant du BL : 310.00 €\n\nVous en remerciant par avance.\n\nCordialement,\nMagasin Nancy\n12 rue des Dominicains, 54000 Nancy\nTél. 03 83 00 00 01\nnancy@banc.test"
|
||||
);
|
||||
assert_eq!(
|
||||
text(&MailDelivery::default(), None),
|
||||
"Bonjour,\n\nDans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :\n\n- la facture au format PDF, ou\n- le bon de livraison au format Excel\n\nLivraison concernée :\n- (informations de livraison à préciser)\n\nVous en remerciant par avance.\n\nCordialement,"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_comme_node() {
|
||||
let html = html(&delivery(), Some(&store()), true);
|
||||
assert!(html.contains("<ul><li>Fournisseur : Bonduelle</li><li>Magasin : Magasin Nancy</li><li>Date de livraison : 10/10/2026</li><li>N° de BL : BL-1002</li><li>Montant du BL : 310.00 €</li></ul>"));
|
||||
assert!(html.contains("<p style=\"margin:0 0 8px 0;\"><img src=\"cid:logo\" alt=\"\" style=\"max-width:200px;height:auto;border:0;\"></p><p style=\"margin:0;color:#444;font-size:13px;line-height:1.5;\"><strong>Magasin Nancy</strong><br>12 rue des Dominicains, 54000 Nancy<br>Tél. 03 83 00 00 01<br><a href=\"mailto:nancy@banc.test\">nancy@banc.test</a></p>\n</div>"));
|
||||
let escaped = MailDelivery {
|
||||
supplier_name: Some("A & B <x>".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let signature = StoreSignature {
|
||||
name: Some("S\"1".into()),
|
||||
address: Some("l1\nl2".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
super::html(&escaped, Some(&signature), false),
|
||||
"<div style=\"font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#222;line-height:1.5;\">\n <p>Bonjour,</p>\n <p>Dans le cadre du rapprochement de nos bons de livraison et de vos factures, nous vous remercions de bien vouloir nous transmettre :</p>\n <ul>\n <li>la facture au format PDF, ou</li>\n <li>le bon de livraison au format Excel</li>\n </ul>\n <p style=\"margin-bottom:4px;\">Livraison concernée :</p>\n <ul><li>Fournisseur : A & B <x></li></ul>\n <p>Vous en remerciant par avance.</p>\n <p style=\"margin-bottom:12px;\">Cordialement,</p>\n <p style=\"margin:0;color:#444;font-size:13px;line-height:1.5;\"><strong>S"1</strong><br>l1<br>l2</p>\n</div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logo_en_data_uri() {
|
||||
let logo = parse_logo(Some(" data:image/svg+xml;base64,PHN2Zz4= ")).unwrap();
|
||||
assert_eq!(logo.content_type, "image/svg+xml");
|
||||
assert_eq!(logo.filename, "logo.svg");
|
||||
assert_eq!(logo.content, b"<svg>");
|
||||
assert!(parse_logo(Some("data:text/plain;base64,QQ==")).is_none());
|
||||
assert!(parse_logo(Some("data:image/png;base64,QQ\n==")).is_none());
|
||||
assert!(parse_logo(None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Services partagés par plusieurs modules (lot 4).
|
||||
pub mod dates;
|
||||
pub mod invoice_verification;
|
||||
pub mod mail;
|
||||
pub mod nocodb;
|
||||
@@ -0,0 +1,397 @@
|
||||
// Client NocoDB (table des factures), au comportement du serveur Node :
|
||||
// - recherche d'une facture par colonne (searchInNocoDB, server/invoiceVerification.ts) :
|
||||
// GET {baseUrl}/api/v1/db/data/v1/{projectId}/{tableId}?where=(colonne,eq,valeur),
|
||||
// jeton dans l'en-tête xc-token, délai maximal de 10 s ;
|
||||
// - configuration active et déchiffrement du jeton (storage.getActiveNocodbConfig,
|
||||
// decryptNocodbConfig) ;
|
||||
// - ancien client de server/nocodbService.ts (POST /api/verify-invoices), qui lit
|
||||
// toute la table et compare les références sans tenir compte de la casse.
|
||||
//
|
||||
// Node appelle NocoDB avec le fetch intégré (undici) : toute erreur réseau donne
|
||||
// « fetch failed », un délai dépassé « Timeout - API NocoDB non accessible ».
|
||||
// Les proxys de l'environnement sont ignorés, comme par Node.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::db::Table;
|
||||
use crate::schema::{GroupsRow, NocodbConfigRow};
|
||||
|
||||
/// Délai maximal d'un appel NocoDB (AbortController de searchInNocoDB)
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("client HTTP NocoDB")
|
||||
});
|
||||
|
||||
/// decryptNocodbConfig : jeton en clair ; illisible, il devient une chaîne vide
|
||||
pub fn decrypt_config(mut config: NocodbConfigRow, encryption_key: &str) -> NocodbConfigRow {
|
||||
config.api_token = crate::crypto::decrypt(&config.api_token, encryption_key).unwrap_or_else(|error| {
|
||||
tracing::error!(config = config.id, error, "jeton NocoDB illisible");
|
||||
String::new()
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
/// storage.getActiveNocodbConfig : première configuration active (sans ordre,
|
||||
/// comme Node), jeton déchiffré ; None aussi en cas d'erreur de base
|
||||
pub async fn active_config(state: &AppState) -> Option<NocodbConfigRow> {
|
||||
let row =
|
||||
sqlx::query_as::<_, NocodbConfigRow>(&NocodbConfigRow::select("WHERE is_active = true LIMIT 1"))
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
match row {
|
||||
Ok(row) => row.map(|config| decrypt_config(config, &state.config.encryption_key)),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "lecture de la configuration NocoDB active");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat d'une recherche dans la table des factures
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Search {
|
||||
/// Première ligne trouvée, telle que NocoDB la renvoie
|
||||
Found(Value),
|
||||
NotFound,
|
||||
/// Message d'erreur repris dans le résultat de la vérification
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Message de JSON.parse (V8) pour une réponse illisible, dans les cas simples
|
||||
fn json_parse_error(text: &str) -> String {
|
||||
let trimmed = text.trim_start();
|
||||
match trimmed.chars().next() {
|
||||
None => "Unexpected end of JSON input".to_string(),
|
||||
Some(first) => {
|
||||
let excerpt: String = text.chars().take(10).collect();
|
||||
let more = if text.chars().count() > 10 { "..." } else { "" };
|
||||
format!("Unexpected token '{first}', \"{excerpt}\"{more} is not valid JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse JSON de NocoDB, ou le message d'erreur que Node en tirerait
|
||||
async fn read_json(response: reqwest::Response) -> Result<Value, String> {
|
||||
let text = response.text().await.map_err(|_| "fetch failed".to_string())?;
|
||||
serde_json::from_str(&text).map_err(|_| json_parse_error(&text))
|
||||
}
|
||||
|
||||
/// Envoi d'une requête GET avec le jeton ; erreurs au format d'undici
|
||||
async fn get(url: &str, token: &str, timeout: Option<Duration>) -> Result<reqwest::Response, String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|_| format!("Failed to parse URL from {url}"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err("fetch failed".to_string());
|
||||
}
|
||||
let mut request = CLIENT
|
||||
.get(parsed)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("xc-token", token);
|
||||
if let Some(timeout) = timeout {
|
||||
request = request.timeout(timeout);
|
||||
}
|
||||
request.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"Timeout - API NocoDB non accessible".to_string()
|
||||
} else {
|
||||
tracing::warn!(%error, "appel NocoDB");
|
||||
"fetch failed".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// searchInNocoDB : première ligne dont la colonne vaut exactement la valeur
|
||||
pub async fn search(config: &NocodbConfigRow, table_id: &str, column: &str, value: &str) -> Search {
|
||||
if value.is_empty() || column.is_empty() || table_id.is_empty() {
|
||||
return Search::Error("Paramètres invalides".to_string());
|
||||
}
|
||||
tracing::debug!(table_id, column, "recherche NocoDB");
|
||||
let url = format!(
|
||||
"{}/api/v1/db/data/v1/{}/{}?where=({},eq,{})",
|
||||
config.base_url,
|
||||
config.project_id,
|
||||
table_id,
|
||||
column,
|
||||
crate::js::encode_uri_component(value)
|
||||
);
|
||||
let response = match get(&url, &config.api_token, Some(TIMEOUT)).await {
|
||||
Ok(response) => response,
|
||||
Err(message) => return Search::Error(message),
|
||||
};
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Search::Error(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or_default()
|
||||
));
|
||||
}
|
||||
let result = match read_json(response).await {
|
||||
Ok(result) => result,
|
||||
Err(message) => return Search::Error(message),
|
||||
};
|
||||
// result.list && result.list.length > 0
|
||||
match &result {
|
||||
Value::Null => Search::Error("Cannot read properties of null (reading 'list')".to_string()),
|
||||
_ => match result.get("list") {
|
||||
Some(Value::Array(list)) if !list.is_empty() => Search::Found(list[0].clone()),
|
||||
Some(Value::String(text)) if !text.is_empty() => {
|
||||
Search::Found(Value::String(text.chars().take(1).collect()))
|
||||
}
|
||||
_ => Search::NotFound,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Ancien client (server/nocodbService.ts) ----------
|
||||
|
||||
/// InvoiceVerificationResult de nocodbService : { exists, error? }
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct LegacyResult {
|
||||
pub exists: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LegacyResult {
|
||||
fn error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
exists: false,
|
||||
error: Some(message.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Valeur « vraie » en JavaScript
|
||||
pub fn truthy(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::Bool(flag) => *flag,
|
||||
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()),
|
||||
Value::String(text) => !text.is_empty(),
|
||||
Value::Array(_) | Value::Object(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// TypeError de JavaScript pour `nom.methode()` appelé sur une valeur qui n'est pas une chaîne
|
||||
pub fn not_a_string_error(name: &str, method: &str, value: Option<&Value>) -> String {
|
||||
match value {
|
||||
None => format!("Cannot read properties of undefined (reading '{method}')"),
|
||||
Some(Value::Null) => format!("Cannot read properties of null (reading '{method}')"),
|
||||
Some(_) => format!("{name}.{method} is not a function"),
|
||||
}
|
||||
}
|
||||
|
||||
/// storage.getGroup avec l'identifiant tel que reçu (texte converti par PostgreSQL,
|
||||
/// comme node-pg) : Err avec le message de PostgreSQL s'il n'est pas un entier
|
||||
pub async fn group_by_raw_id(db: &PgPool, group_id: Option<&Value>) -> Result<Option<GroupsRow>, String> {
|
||||
group_by_text_id(db, group_id.and_then(crate::db::pg_text).as_deref()).await
|
||||
}
|
||||
|
||||
/// storage.getGroup avec l'identifiant en texte (None : aucune ligne, comme `id = NULL`)
|
||||
pub async fn group_by_text_id(db: &PgPool, group_id: Option<&str>) -> Result<Option<GroupsRow>, String> {
|
||||
sqlx::query_as::<_, GroupsRow>(&GroupsRow::select("WHERE id = $1::int4"))
|
||||
.bind(group_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|error| database_message(&error))
|
||||
}
|
||||
|
||||
/// Message d'une erreur PostgreSQL tel que node-pg le donne (error.message)
|
||||
pub fn database_message(error: &sqlx::Error) -> String {
|
||||
match error {
|
||||
sqlx::Error::Database(database) => database.message().to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// verifyInvoiceReference : la référence existe-t-elle dans la table du magasin ?
|
||||
pub async fn verify_invoice_reference(
|
||||
state: &AppState,
|
||||
group_id: Option<&Value>,
|
||||
invoice_reference: Option<&Value>,
|
||||
supplier_name: Option<&Value>,
|
||||
) -> LegacyResult {
|
||||
let group = match group_by_raw_id(&state.db, group_id).await {
|
||||
Ok(group) => group,
|
||||
Err(message) => return LegacyResult::error(message),
|
||||
};
|
||||
let Some((group, config_id, table_id)) = group.and_then(|group| {
|
||||
let config_id = group.nocodb_config_id.filter(|id| *id != 0)?;
|
||||
let table_id = group.nocodb_table_id.clone().filter(|id| !id.is_empty())?;
|
||||
Some((group, config_id, table_id))
|
||||
}) else {
|
||||
return LegacyResult::error("No NocoDB configuration for this group");
|
||||
};
|
||||
let config = match crate::db::find_by_id::<NocodbConfigRow>(&state.db, config_id).await {
|
||||
Ok(Some(config)) => decrypt_config(config, &state.config.encryption_key),
|
||||
Ok(None) => return LegacyResult::error("NocoDB configuration not found"),
|
||||
Err(error) => return LegacyResult::error(database_message(&error)),
|
||||
};
|
||||
let column = group
|
||||
.invoice_column_name
|
||||
.clone()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "Ref Facture".to_string());
|
||||
let Some(reference) = invoice_reference.and_then(Value::as_str) else {
|
||||
return LegacyResult::error(not_a_string_error("invoiceReference", "trim", invoice_reference));
|
||||
};
|
||||
let normalized_reference = crate::js::trim(reference).to_lowercase();
|
||||
|
||||
// Sans délai maximal côté Node : 10 s ici, comme tout appel NocoDB (plan, lot 2)
|
||||
let url = format!(
|
||||
"{}/api/v1/db/data/noco/{}/{}",
|
||||
config.base_url, config.project_id, table_id
|
||||
);
|
||||
let response = match get(&url, &config.api_token, Some(TIMEOUT)).await {
|
||||
Ok(response) => response,
|
||||
Err(message) => return LegacyResult::error(message),
|
||||
};
|
||||
if !response.status().is_success() {
|
||||
return LegacyResult::error(format!("NocoDB API error: {}", response.status().as_u16()));
|
||||
}
|
||||
let data = match read_json(response).await {
|
||||
Ok(Value::Null) => return LegacyResult::error("Cannot read properties of null (reading 'pageInfo')"),
|
||||
Ok(data) => data,
|
||||
Err(message) => return LegacyResult::error(message),
|
||||
};
|
||||
let records = match data.get("list") {
|
||||
Some(Value::Array(records)) => records.as_slice(),
|
||||
Some(Value::String(text)) if !text.is_empty() => {
|
||||
return LegacyResult::error("data.list.filter is not a function");
|
||||
}
|
||||
_ => &[],
|
||||
};
|
||||
let supplier = supplier_name.filter(|name| truthy(name));
|
||||
let mut exists = false;
|
||||
for record in records {
|
||||
let cell = match record {
|
||||
Value::Null => {
|
||||
return LegacyResult::error(format!("Cannot read properties of null (reading '{column}')"));
|
||||
}
|
||||
record => record.get(&column).filter(|cell| truthy(cell)),
|
||||
};
|
||||
let Some(cell) = cell else { continue };
|
||||
if crate::js::to_js_string(cell).to_lowercase() != normalized_reference {
|
||||
continue;
|
||||
}
|
||||
// Avec un fournisseur : l'un des deux noms doit contenir l'autre
|
||||
let record_supplier = ["Fournisseur", "Supplier", "fournisseur", "supplier"]
|
||||
.iter()
|
||||
.filter_map(|key| record.get(*key))
|
||||
.find(|value| truthy(value));
|
||||
let matched = match (supplier, record_supplier) {
|
||||
(Some(searched), Some(found)) => {
|
||||
let Some(searched) = searched.as_str() else {
|
||||
return LegacyResult::error("supplierName.toLowerCase is not a function");
|
||||
};
|
||||
let (searched, found) = (
|
||||
searched.to_lowercase(),
|
||||
crate::js::to_js_string(found).to_lowercase(),
|
||||
);
|
||||
found.contains(&searched) || searched.contains(&found)
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
// Comme filter() : toutes les lignes sont lues, même après une correspondance
|
||||
exists |= matched;
|
||||
}
|
||||
LegacyResult { exists, error: None }
|
||||
}
|
||||
|
||||
/// Clé d'objet JavaScript « indice de tableau » : rangée avant les autres, par ordre croissant
|
||||
pub fn array_index(key: &str) -> Option<u32> {
|
||||
let index: u32 = key.parse().ok()?;
|
||||
(index != u32::MAX && index.to_string() == key).then_some(index)
|
||||
}
|
||||
|
||||
/// verifyMultipleInvoiceReferences : résultats rangés par identifiant de livraison,
|
||||
/// dans l'ordre des clés d'un objet JavaScript. Node traite les références en
|
||||
/// parallèle ; elles le sont ici l'une après l'autre (même résultat, la dernière
|
||||
/// référence d'un identifiant répété l'emportant). None si un élément est null
|
||||
/// (TypeError côté Node, avant tout appel).
|
||||
pub async fn verify_multiple_invoice_references(
|
||||
state: &AppState,
|
||||
references: &[Value],
|
||||
) -> Option<Map<String, Value>> {
|
||||
if references.iter().any(Value::is_null) {
|
||||
return None;
|
||||
}
|
||||
let mut results: Vec<(String, Value)> = Vec::new();
|
||||
for reference in references {
|
||||
let field = |name: &str| reference.as_object().and_then(|object| object.get(name));
|
||||
let result = verify_invoice_reference(
|
||||
state,
|
||||
field("groupId"),
|
||||
field("invoiceReference"),
|
||||
field("supplierName"),
|
||||
)
|
||||
.await;
|
||||
let key = field("deliveryId")
|
||||
.map(crate::js::to_js_string)
|
||||
.unwrap_or_else(|| "undefined".to_string());
|
||||
let value = serde_json::to_value(result).unwrap_or(Value::Null);
|
||||
match results.iter_mut().find(|(existing, _)| *existing == key) {
|
||||
Some(entry) => entry.1 = value,
|
||||
None => results.push((key, value)),
|
||||
}
|
||||
}
|
||||
let (mut indexes, others): (Vec<_>, Vec<_>) = results
|
||||
.into_iter()
|
||||
.partition(|(key, _)| array_index(key).is_some());
|
||||
indexes.sort_by_key(|(key, _)| array_index(key));
|
||||
Some(indexes.into_iter().chain(others).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn messages_d_erreur_de_node() {
|
||||
assert_eq!(json_parse_error(""), "Unexpected end of JSON input");
|
||||
assert_eq!(
|
||||
json_parse_error("abc"),
|
||||
"Unexpected token 'a', \"abc\" is not valid JSON"
|
||||
);
|
||||
assert_eq!(
|
||||
not_a_string_error("invoiceReference", "trim", Some(&json!(12))),
|
||||
"invoiceReference.trim is not a function"
|
||||
);
|
||||
assert_eq!(
|
||||
not_a_string_error("invoiceReference", "trim", None),
|
||||
"Cannot read properties of undefined (reading 'trim')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordre_des_cles_d_un_objet_javascript() {
|
||||
assert_eq!(array_index("12"), Some(12));
|
||||
assert_eq!(array_index("012"), None);
|
||||
assert_eq!(array_index("1.5"), None);
|
||||
assert_eq!(array_index("undefined"), None);
|
||||
assert!(truthy(&json!("x")) && !truthy(&json!("")) && !truthy(&json!(0)) && truthy(&json!([])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resultat_sans_erreur_omet_le_champ() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(LegacyResult {
|
||||
exists: true,
|
||||
error: None
|
||||
})
|
||||
.unwrap(),
|
||||
json!({ "exists": true })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Migrations de démarrage, reprises de server/migrations.production.ts et du CMD
|
||||
// de l'ancien Dockerfile : toutes idempotentes (IF NOT EXISTS), une erreur est
|
||||
// journalisée sans empêcher le démarrage, comme dans Node. Les index de
|
||||
// performance se créent ensuite en arrière-plan (CREATE INDEX CONCURRENTLY).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
/// Schéma attendu par le code : colonnes et tables ajoutées depuis init.sql
|
||||
const SCHEMA_STATEMENTS: &[(&str, &str)] = &[
|
||||
(
|
||||
"colonne priority des tickets SAV",
|
||||
"ALTER TABLE sav_tickets ADD COLUMN IF NOT EXISTS priority varchar(50) NOT NULL DEFAULT 'normale'",
|
||||
),
|
||||
(
|
||||
"rupture de stock des DLC",
|
||||
"ALTER TABLE dlc_products ADD COLUMN IF NOT EXISTS stock_epuise boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS stock_epuise_by varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS stock_epuise_at timestamp",
|
||||
),
|
||||
(
|
||||
"index de rupture de stock des DLC",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dlc_products_stock_epuise ON dlc_products(stock_epuise)",
|
||||
),
|
||||
(
|
||||
"client prévenu des commandes clients",
|
||||
"ALTER TABLE customer_orders ADD COLUMN IF NOT EXISTS notified_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS notified_comment TEXT",
|
||||
),
|
||||
(
|
||||
"code fournisseur",
|
||||
"ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS codefou VARCHAR",
|
||||
),
|
||||
(
|
||||
"coordonnées et SMTP des magasins",
|
||||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS phone VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS logo TEXT,
|
||||
ADD COLUMN IF NOT EXISTS smtp_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS smtp_host VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS smtp_port INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS smtp_secure BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS smtp_user VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS smtp_password VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS smtp_sender_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS smtp_sender_name VARCHAR(255)",
|
||||
),
|
||||
(
|
||||
"historique des courriels fournisseurs",
|
||||
"CREATE TABLE IF NOT EXISTS supplier_mail_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
delivery_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
supplier_name VARCHAR(255),
|
||||
sent_to VARCHAR(255) NOT NULL,
|
||||
subject TEXT,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
message_id VARCHAR(255),
|
||||
sent_by VARCHAR NOT NULL,
|
||||
sent_by_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
),
|
||||
(
|
||||
"index des courriels fournisseurs par livraison",
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id)",
|
||||
),
|
||||
(
|
||||
"index des courriels fournisseurs par magasin",
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id)",
|
||||
),
|
||||
(
|
||||
"clés de l'API externe",
|
||||
"CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_by VARCHAR,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
)",
|
||||
),
|
||||
(
|
||||
"configuration du webhook BAP",
|
||||
"CREATE TABLE IF NOT EXISTS webhook_bap_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL DEFAULT 'Configuration BAP',
|
||||
webhook_url TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
),
|
||||
(
|
||||
"sessions de connexion",
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid VARCHAR PRIMARY KEY,
|
||||
sess JSONB NOT NULL,
|
||||
expire TIMESTAMP NOT NULL
|
||||
)",
|
||||
),
|
||||
(
|
||||
"index d'expiration des sessions",
|
||||
"CREATE INDEX IF NOT EXISTS \"IDX_session_expire\" ON sessions (expire)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Applique les migrations de schéma, puis chiffre les secrets encore en clair
|
||||
pub async fn run_migrations(db: &PgPool, encryption_key: &str) {
|
||||
for (label, statement) in SCHEMA_STATEMENTS {
|
||||
if let Err(error) = sqlx::query(statement).execute(db).await {
|
||||
tracing::error!(%error, "migration « {label} » en échec");
|
||||
}
|
||||
}
|
||||
match encrypt_plain_secrets(db, encryption_key).await {
|
||||
Ok(0) => {}
|
||||
Ok(count) => tracing::info!(count, "secrets en clair chiffrés"),
|
||||
Err(error) => tracing::error!(%error, "chiffrement des secrets en clair en échec"),
|
||||
}
|
||||
tracing::info!("migrations de démarrage terminées");
|
||||
}
|
||||
|
||||
/// Mots de passe SMTP des magasins et jetons NocoDB encore en clair → enc:v1
|
||||
async fn encrypt_plain_secrets(db: &PgPool, key: &str) -> Result<usize, sqlx::Error> {
|
||||
let mut count = 0;
|
||||
let groups: Vec<(i32, String)> = sqlx::query_as(
|
||||
"SELECT id, smtp_password FROM groups
|
||||
WHERE smtp_password IS NOT NULL AND smtp_password != '' AND smtp_password NOT LIKE 'enc:v1:%'",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for (id, secret) in groups {
|
||||
sqlx::query("UPDATE groups SET smtp_password = $1 WHERE id = $2")
|
||||
.bind(crate::crypto::encrypt(&secret, key))
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
count += 1;
|
||||
}
|
||||
let tokens: Vec<(i32, String)> = sqlx::query_as(
|
||||
"SELECT id, api_token FROM nocodb_config
|
||||
WHERE api_token IS NOT NULL AND api_token != '' AND api_token NOT LIKE 'enc:v1:%'",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for (id, secret) in tokens {
|
||||
sqlx::query("UPDATE nocodb_config SET api_token = $1 WHERE id = $2")
|
||||
.bind(crate::crypto::encrypt(&secret, key))
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct PerformanceIndex {
|
||||
name: &'static str,
|
||||
table: &'static str,
|
||||
columns: &'static str,
|
||||
predicate: Option<&'static str>,
|
||||
}
|
||||
|
||||
const fn index(name: &'static str, table: &'static str, columns: &'static str) -> PerformanceIndex {
|
||||
PerformanceIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
predicate: None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn partial(
|
||||
name: &'static str,
|
||||
table: &'static str,
|
||||
columns: &'static str,
|
||||
predicate: &'static str,
|
||||
) -> PerformanceIndex {
|
||||
PerformanceIndex {
|
||||
name,
|
||||
table,
|
||||
columns,
|
||||
predicate: Some(predicate),
|
||||
}
|
||||
}
|
||||
|
||||
/// Index de performance garantis à chaque démarrage (même liste que Node)
|
||||
const PERFORMANCE_INDEXES: &[PerformanceIndex] = &[
|
||||
index("idx_user_groups_user_id", "user_groups", "user_id"),
|
||||
index("idx_user_groups_group_id", "user_groups", "group_id"),
|
||||
index("idx_orders_group_id", "orders", "group_id"),
|
||||
index("idx_orders_supplier_id", "orders", "supplier_id"),
|
||||
index("idx_orders_created_by", "orders", "created_by"),
|
||||
index("idx_orders_planned_date", "orders", "planned_date"),
|
||||
index(
|
||||
"idx_orders_group_planned_date",
|
||||
"orders",
|
||||
"group_id, planned_date",
|
||||
),
|
||||
index("idx_orders_created_at", "orders", "created_at DESC"),
|
||||
index("idx_orders_status", "orders", "status"),
|
||||
index("idx_orders_group_status", "orders", "group_id, status"),
|
||||
index("idx_deliveries_order_id", "deliveries", "order_id"),
|
||||
index("idx_deliveries_group_id", "deliveries", "group_id"),
|
||||
index("idx_deliveries_supplier_id", "deliveries", "supplier_id"),
|
||||
index("idx_deliveries_created_by", "deliveries", "created_by"),
|
||||
index("idx_deliveries_scheduled_date", "deliveries", "scheduled_date"),
|
||||
index(
|
||||
"idx_deliveries_group_scheduled_date",
|
||||
"deliveries",
|
||||
"group_id, scheduled_date",
|
||||
),
|
||||
index("idx_deliveries_created_at", "deliveries", "created_at DESC"),
|
||||
partial(
|
||||
"idx_deliveries_invoice_reference",
|
||||
"deliveries",
|
||||
"invoice_reference",
|
||||
"invoice_reference IS NOT NULL",
|
||||
),
|
||||
index("idx_deliveries_status", "deliveries", "status"),
|
||||
index("idx_deliveries_bl_number", "deliveries", "bl_number"),
|
||||
index("idx_deliveries_due_date", "deliveries", "due_date"),
|
||||
index("idx_deliveries_group_status", "deliveries", "group_id, status"),
|
||||
index(
|
||||
"idx_deliveries_group_delivered_date",
|
||||
"deliveries",
|
||||
"group_id, delivered_date",
|
||||
),
|
||||
index(
|
||||
"idx_reconciliation_comments_delivery_id",
|
||||
"reconciliation_comments",
|
||||
"delivery_id",
|
||||
),
|
||||
index(
|
||||
"idx_reconciliation_comments_group_id",
|
||||
"reconciliation_comments",
|
||||
"group_id",
|
||||
),
|
||||
index("idx_contacts_group_id", "contacts", "group_id"),
|
||||
index("idx_customer_orders_group_id", "customer_orders", "group_id"),
|
||||
index(
|
||||
"idx_customer_orders_supplier_id",
|
||||
"customer_orders",
|
||||
"supplier_id",
|
||||
),
|
||||
index(
|
||||
"idx_customer_orders_created_at",
|
||||
"customer_orders",
|
||||
"created_at DESC",
|
||||
),
|
||||
index(
|
||||
"idx_customer_orders_group_status",
|
||||
"customer_orders",
|
||||
"group_id, status",
|
||||
),
|
||||
partial(
|
||||
"idx_customer_orders_pending_calls",
|
||||
"customer_orders",
|
||||
"group_id, created_at DESC",
|
||||
"customer_notified = false",
|
||||
),
|
||||
index("idx_dlc_products_group_id", "dlc_products", "group_id"),
|
||||
index("idx_dlc_products_supplier_id", "dlc_products", "supplier_id"),
|
||||
index("idx_dlc_products_expiry_date", "dlc_products", "expiry_date"),
|
||||
index("idx_dlc_products_status", "dlc_products", "status"),
|
||||
index(
|
||||
"idx_dlc_products_group_status",
|
||||
"dlc_products",
|
||||
"group_id, status",
|
||||
),
|
||||
index(
|
||||
"idx_dlc_products_group_expiry",
|
||||
"dlc_products",
|
||||
"group_id, expiry_date",
|
||||
),
|
||||
index("idx_tasks_group_id", "tasks", "group_id"),
|
||||
index("idx_tasks_created_by", "tasks", "created_by"),
|
||||
index("idx_tasks_due_date", "tasks", "due_date"),
|
||||
index("idx_tasks_group_status", "tasks", "group_id, status"),
|
||||
index("idx_tasks_group_created", "tasks", "group_id, created_at DESC"),
|
||||
index("idx_avoirs_group_id", "avoirs", "group_id"),
|
||||
index("idx_avoirs_supplier_id", "avoirs", "supplier_id"),
|
||||
index("idx_avoirs_created_at", "avoirs", "created_at DESC"),
|
||||
index("idx_avoirs_group_status", "avoirs", "group_id, status"),
|
||||
index("idx_sav_tickets_group_id", "sav_tickets", "group_id"),
|
||||
index("idx_sav_tickets_supplier_id", "sav_tickets", "supplier_id"),
|
||||
index("idx_sav_tickets_created_at", "sav_tickets", "created_at DESC"),
|
||||
index("idx_sav_tickets_status", "sav_tickets", "status"),
|
||||
index("idx_sav_tickets_priority", "sav_tickets", "priority"),
|
||||
index("idx_sav_tickets_group_status", "sav_tickets", "group_id, status"),
|
||||
index(
|
||||
"idx_sav_tickets_group_created",
|
||||
"sav_tickets",
|
||||
"group_id, created_at DESC",
|
||||
),
|
||||
index(
|
||||
"idx_invoice_cache_expires_at",
|
||||
"invoice_verification_cache",
|
||||
"expires_at",
|
||||
),
|
||||
index(
|
||||
"idx_invoice_cache_group_id",
|
||||
"invoice_verification_cache",
|
||||
"group_id",
|
||||
),
|
||||
index(
|
||||
"idx_publicity_participations_group_id",
|
||||
"publicity_participations",
|
||||
"group_id",
|
||||
),
|
||||
index("idx_publicities_year", "publicities", "year"),
|
||||
index("idx_publicities_start_date", "publicities", "start_date"),
|
||||
index(
|
||||
"idx_dashboard_messages_store_id",
|
||||
"dashboard_messages",
|
||||
"store_id",
|
||||
),
|
||||
index(
|
||||
"idx_dashboard_messages_created_at",
|
||||
"dashboard_messages",
|
||||
"created_at DESC",
|
||||
),
|
||||
index("idx_weather_data_date_location", "weather_data", "date, location"),
|
||||
index(
|
||||
"idx_weather_data_date_year",
|
||||
"weather_data",
|
||||
"date, is_current_year",
|
||||
),
|
||||
index(
|
||||
"idx_supplier_mail_logs_delivery",
|
||||
"supplier_mail_logs",
|
||||
"delivery_id",
|
||||
),
|
||||
index("idx_supplier_mail_logs_group", "supplier_mail_logs", "group_id"),
|
||||
index(
|
||||
"idx_supplier_mail_logs_group_created",
|
||||
"supplier_mail_logs",
|
||||
"group_id, created_at DESC",
|
||||
),
|
||||
];
|
||||
|
||||
static INDEXES_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Lance la création des index manquants en arrière-plan : ni le démarrage ni le
|
||||
/// contrôle de santé ne l'attendent. Un seul passage à la fois.
|
||||
pub fn spawn_performance_indexes(db: PgPool) {
|
||||
if INDEXES_RUNNING.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = ensure_performance_indexes(&db).await {
|
||||
tracing::error!(%error, "vérification des index en échec");
|
||||
}
|
||||
INDEXES_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
async fn ensure_performance_indexes(db: &PgPool) -> Result<(), sqlx::Error> {
|
||||
let started = Instant::now();
|
||||
|
||||
// 1. Une création CONCURRENTLY interrompue laisse un index invalide, que IF NOT
|
||||
// EXISTS prendrait pour présent : purge des reliquats (sauf en construction)
|
||||
let invalid: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT c.relname::text
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_stat_progress_create_index p WHERE p.index_relid = i.indexrelid)",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (name,) in invalid {
|
||||
let statement = format!(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS public.\"{}\"",
|
||||
name.replace('"', "\"\"")
|
||||
);
|
||||
if let Err(error) = sqlx::query(&statement).execute(db).await {
|
||||
tracing::warn!(%error, index = %name, "index invalide non supprimé");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tables présentes, index valides par nom, colonnes déjà indexées sous un autre nom
|
||||
let tables: HashSet<String> = sqlx::query(
|
||||
"SELECT c.relname::text AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|row| row.get("name"))
|
||||
.collect();
|
||||
let indexes = sqlx::query(
|
||||
"SELECT t.relname::text AS table_name, c.relname::text AS index_name, i.indisvalid AS valid,
|
||||
(i.indpred IS NULL AND i.indexprs IS NULL) AS plain,
|
||||
array_to_string(ARRAY(
|
||||
SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||
WHERE k.ord <= i.indnkeyatts ORDER BY k.ord
|
||||
), ',') AS columns
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
let mut existing = HashSet::new();
|
||||
let mut invalid_names = HashSet::new();
|
||||
let mut indexed_columns = HashSet::new();
|
||||
for row in &indexes {
|
||||
let name: String = row.get("index_name");
|
||||
if !row.get::<bool, _>("valid") {
|
||||
invalid_names.insert(name);
|
||||
continue;
|
||||
}
|
||||
existing.insert(name);
|
||||
if row.get::<bool, _>("plain") {
|
||||
let table: String = row.get("table_name");
|
||||
let columns: String = row.get("columns");
|
||||
indexed_columns.insert(format!("{table}({columns})"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Index manquants, une requête chacun (CONCURRENTLY interdit en transaction)
|
||||
let (mut created, mut present, mut skipped, mut failed) = (0, 0, 0, Vec::new());
|
||||
let mut to_analyze = Vec::new();
|
||||
for index in PERFORMANCE_INDEXES {
|
||||
let key_columns: Vec<&str> = index
|
||||
.columns
|
||||
.split(',')
|
||||
.map(|column| column.split_whitespace().next().unwrap_or(""))
|
||||
.collect();
|
||||
if existing.contains(index.name)
|
||||
|| indexed_columns.contains(&format!("{}({})", index.table, key_columns.join(",")))
|
||||
{
|
||||
present += 1;
|
||||
continue;
|
||||
}
|
||||
if !tables.contains(index.table) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if invalid_names.contains(index.name) {
|
||||
failed.push(index.name);
|
||||
continue;
|
||||
}
|
||||
let mut statement = format!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS {} ON {} ({})",
|
||||
index.name, index.table, index.columns
|
||||
);
|
||||
if let Some(predicate) = index.predicate {
|
||||
statement.push_str(&format!(" WHERE {predicate}"));
|
||||
}
|
||||
match sqlx::query(&statement).execute(db).await {
|
||||
Ok(_) => {
|
||||
created += 1;
|
||||
if !to_analyze.contains(&index.table) {
|
||||
to_analyze.push(index.table);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, index = index.name, "création d'index en échec");
|
||||
failed.push(index.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Statistiques à jour pour que le planificateur utilise les nouveaux index
|
||||
for table in to_analyze {
|
||||
if let Err(error) = sqlx::query(&format!("ANALYZE {table}")).execute(db).await {
|
||||
tracing::warn!(%error, table, "ANALYZE en échec");
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
created,
|
||||
present,
|
||||
skipped,
|
||||
failed = failed.len(),
|
||||
seconds = started.elapsed().as_secs_f32(),
|
||||
"index de performance vérifiés"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Fichiers de l'interface (dist/public), comme server/index.production.ts :
|
||||
// - /assets/… : noms hachés par Vite, cache d'un an ;
|
||||
// - autres fichiers publics : revalidés à chaque visite (index.html : no-cache) ;
|
||||
// - toute autre adresse hors /api : index.html, que l'application React route.
|
||||
// Mêmes en-têtes qu'Express (type, ETag faible taille-date, Last-Modified, 304).
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::{AppState, express_routes};
|
||||
|
||||
const ASSETS_CACHE: &str = "public, max-age=31536000, immutable";
|
||||
const DEFAULT_CACHE: &str = "public, max-age=0";
|
||||
const NO_CACHE: &str = "no-cache";
|
||||
|
||||
/// Tout ce que le routeur ne sert pas : interface, variante d'une route, ou 404
|
||||
pub async fn fallback(state: State<AppState>, request: Request) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
if path.starts_with("/api/") || path == "/api" || !matches!(*request.method(), Method::GET | Method::HEAD)
|
||||
{
|
||||
// Adresse servie par Express malgré la casse ou une « / » finale
|
||||
let request = match express_routes::redispatch(request).await {
|
||||
Ok(response) => return response,
|
||||
Err(request) => request,
|
||||
};
|
||||
return not_found(request).await;
|
||||
}
|
||||
serve(
|
||||
&state.config.static_dir,
|
||||
&path,
|
||||
request.method(),
|
||||
request.headers(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn serve(root: &Path, path: &str, method: &Method, headers: &HeaderMap) -> Response {
|
||||
if let Some(relative) = safe_relative(path) {
|
||||
let is_asset = relative.starts_with("assets");
|
||||
let file = root.join(&relative);
|
||||
if let Some(response) = send_file(&file, method, headers, |name| {
|
||||
if is_asset {
|
||||
ASSETS_CACHE
|
||||
} else if name.ends_with(".html") {
|
||||
NO_CACHE
|
||||
} else {
|
||||
DEFAULT_CACHE
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
// Retour à l'application : index.html, jamais mis en cache
|
||||
send_file(&root.join("index.html"), method, headers, |_| NO_CACHE)
|
||||
.await
|
||||
.unwrap_or_else(|| (StatusCode::INTERNAL_SERVER_ERROR, "Error loading application").into_response())
|
||||
}
|
||||
|
||||
/// Aucune route : 404 d'Express, avec l'adresse demandée avant toute réécriture
|
||||
pub async fn not_found(request: Request) -> Response {
|
||||
let path = request
|
||||
.extensions()
|
||||
.get::<express_routes::OriginalPath>()
|
||||
.map(|original| original.0.clone())
|
||||
.unwrap_or_else(|| request.uri().path().to_string());
|
||||
express_not_found(request.method(), &path)
|
||||
}
|
||||
|
||||
/// Réponse 404 d'Express (finalhandler) pour une adresse qu'aucune route ne sert :
|
||||
/// « Cannot GET /chemin », sans la chaîne de requête, chemin encodé puis échappé
|
||||
pub fn express_not_found(method: &Method, path: &str) -> Response {
|
||||
let resource = escape_html(&encode_url(path));
|
||||
let body = format!(
|
||||
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>Cannot {method} {resource}</pre>\n</body>\n</html>\n"
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'"),
|
||||
);
|
||||
headers.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
if path.starts_with("/api") {
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-cache"),
|
||||
);
|
||||
}
|
||||
(StatusCode::NOT_FOUND, headers, body).into_response()
|
||||
}
|
||||
|
||||
/// Paquet encodeurl : encode ce qui n'est pas permis dans une adresse, sans
|
||||
/// réencoder les séquences %XX déjà présentes
|
||||
fn encode_url(path: &str) -> String {
|
||||
let bytes = path.as_bytes();
|
||||
let mut out = String::with_capacity(path.len());
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let allowed = byte.is_ascii_alphanumeric() || b"!#$&'()*+,-./:;=?@[]_~".contains(&byte);
|
||||
let escape = byte == b'%'
|
||||
&& bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit)
|
||||
&& bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit);
|
||||
if allowed || escape {
|
||||
out.push(byte as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Paquet escape-html
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Chemin relatif sûr : décodé, sans « .. », sans fichier caché (comme express.static)
|
||||
fn safe_relative(path: &str) -> Option<PathBuf> {
|
||||
let decoded = percent_decode(path)?;
|
||||
let mut relative = PathBuf::new();
|
||||
for component in Path::new(&decoded).components() {
|
||||
match component {
|
||||
Component::RootDir => {}
|
||||
Component::Normal(part) => {
|
||||
if part.to_string_lossy().starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
relative.push(part);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if relative.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> Option<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 hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?;
|
||||
out.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
async fn send_file(
|
||||
file: &Path,
|
||||
method: &Method,
|
||||
request_headers: &HeaderMap,
|
||||
cache_control: impl Fn(&str) -> &'static str,
|
||||
) -> Option<Response> {
|
||||
let metadata = tokio::fs::metadata(file).await.ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let name = file.file_name()?.to_string_lossy().to_string();
|
||||
let modified = metadata.modified().ok()?;
|
||||
let mtime_ms = modified.duration_since(UNIX_EPOCH).ok()?.as_millis();
|
||||
let etag = format!("W/\"{:x}-{:x}\"", metadata.len(), mtime_ms);
|
||||
let last_modified = DateTime::<Utc>::from(modified)
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT")
|
||||
.to_string();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(cache_control(&name)),
|
||||
);
|
||||
headers.insert(header::LAST_MODIFIED, HeaderValue::from_str(&last_modified).ok()?);
|
||||
headers.insert(header::ETAG, HeaderValue::from_str(&etag).ok()?);
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(content_type(&name)),
|
||||
);
|
||||
|
||||
if is_fresh(request_headers, &etag, modified) {
|
||||
return Some((StatusCode::NOT_MODIFIED, headers).into_response());
|
||||
}
|
||||
headers.insert(header::CONTENT_LENGTH, HeaderValue::from(metadata.len()));
|
||||
if *method == Method::HEAD {
|
||||
return Some((StatusCode::OK, headers).into_response());
|
||||
}
|
||||
let content = tokio::fs::read(file).await.ok()?;
|
||||
Some((StatusCode::OK, headers, Body::from(content)).into_response())
|
||||
}
|
||||
|
||||
/// Même règle que le paquet « fresh » d'Express : If-None-Match, sinon If-Modified-Since
|
||||
fn is_fresh(request: &HeaderMap, etag: &str, modified: std::time::SystemTime) -> bool {
|
||||
let cache_control = request
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if cache_control.contains("no-cache") {
|
||||
return false;
|
||||
}
|
||||
if let Some(none_match) = request.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) {
|
||||
let bare = etag.trim_start_matches("W/");
|
||||
return none_match.trim() == "*"
|
||||
|| none_match
|
||||
.split(',')
|
||||
.any(|candidate| candidate.trim().trim_start_matches("W/") == bare);
|
||||
}
|
||||
if let Some(since) = request
|
||||
.get(header::IF_MODIFIED_SINCE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Ok(since) = DateTime::parse_from_rfc2822(since) {
|
||||
let modified = DateTime::<Utc>::from(modified);
|
||||
return modified.timestamp() <= since.timestamp();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Types renvoyés par express.static (paquet mime 1.x, avec charset pour texte, JS et JSON)
|
||||
fn content_type(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().unwrap_or("") {
|
||||
"html" | "htm" => "text/html; charset=UTF-8",
|
||||
"js" | "mjs" => "application/javascript; charset=UTF-8",
|
||||
"css" => "text/css; charset=UTF-8",
|
||||
"json" | "map" => "application/json; charset=UTF-8",
|
||||
"txt" => "text/plain; charset=UTF-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"webmanifest" => "application/manifest+json",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refuse_les_chemins_hors_du_dossier() {
|
||||
assert_eq!(safe_relative("/../etc/passwd"), None);
|
||||
assert_eq!(safe_relative("/assets/%2e%2e/x"), None);
|
||||
assert_eq!(safe_relative("/.env"), None);
|
||||
assert_eq!(safe_relative("/"), None);
|
||||
assert_eq!(
|
||||
safe_relative("/assets/app.js"),
|
||||
Some(PathBuf::from("assets/app.js"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_404_d_express() {
|
||||
assert_eq!(encode_url("/api/x y<é>"), "/api/x%20y%3C%C3%A9%3E");
|
||||
assert_eq!(escape_html("/a&'b"), "/a&'b");
|
||||
assert_eq!(encode_url("/déjà%20là"), "/d%C3%A9j%C3%A0%20l%C3%A0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_comme_express() {
|
||||
assert_eq!(
|
||||
content_type("index-abc.js"),
|
||||
"application/javascript; charset=UTF-8"
|
||||
);
|
||||
assert_eq!(content_type("favicon.ico"), "image/x-icon");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// Validation des corps de requête, au comportement de zod 3 (schémas de
|
||||
// shared/schema.ts) : mêmes contrôles dans le même ordre, mêmes erreurs au même
|
||||
// format ({ code, expected, received, path, message }), champs inconnus retirés.
|
||||
// L'interface affiche ces erreurs ; les reproduire évite tout écart visible.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::js;
|
||||
|
||||
const INT32_MIN: f64 = -2_147_483_648.0;
|
||||
const INT32_MAX: f64 = 2_147_483_647.0;
|
||||
const SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
||||
|
||||
// Toute l'API de zod utilisée par shared/schema.ts n'est pas encore appelée par
|
||||
// un module : elle reste disponible (et testée) pour les schémas à venir.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
String,
|
||||
Number,
|
||||
/// z.coerce.number() : Number(valeur)
|
||||
CoerceNumber,
|
||||
Boolean,
|
||||
/// z.date() : jamais satisfait par du JSON
|
||||
Date,
|
||||
/// z.coerce.date() : new Date(valeur), rendu en chaîne ISO
|
||||
CoerceDate,
|
||||
Enum(&'static [&'static str]),
|
||||
/// N'importe quelle valeur JSON (colonnes jsonb, z.any())
|
||||
Json,
|
||||
/// z.union([…]) : options essayées dans l'ordre (leurs noms et leurs
|
||||
/// .optional() / .nullable() ne servent pas : z.null() s'écrit Field::null)
|
||||
Union(Vec<Field>),
|
||||
/// z.literal(valeur)
|
||||
Literal(Value),
|
||||
/// z.null()
|
||||
Null,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
enum Check {
|
||||
MinLen(usize),
|
||||
MaxLen(usize),
|
||||
Email,
|
||||
Min(f64, bool),
|
||||
Max(f64, bool),
|
||||
Int,
|
||||
/// .min(n, "message") d'une chaîne
|
||||
MinLenMessage(usize, String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Field {
|
||||
name: String,
|
||||
kind: Kind,
|
||||
checks: Vec<Check>,
|
||||
optional: bool,
|
||||
nullable: bool,
|
||||
default: Option<Value>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Field {
|
||||
pub fn new(name: &str, kind: Kind) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
kind,
|
||||
checks: Vec::new(),
|
||||
optional: false,
|
||||
nullable: false,
|
||||
default: None,
|
||||
}
|
||||
}
|
||||
pub fn string(name: &str) -> Self {
|
||||
Self::new(name, Kind::String)
|
||||
}
|
||||
pub fn number(name: &str) -> Self {
|
||||
Self::new(name, Kind::Number)
|
||||
}
|
||||
pub fn coerce_number(name: &str) -> Self {
|
||||
Self::new(name, Kind::CoerceNumber)
|
||||
}
|
||||
pub fn boolean(name: &str) -> Self {
|
||||
Self::new(name, Kind::Boolean)
|
||||
}
|
||||
pub fn date(name: &str) -> Self {
|
||||
Self::new(name, Kind::Date)
|
||||
}
|
||||
pub fn coerce_date(name: &str) -> Self {
|
||||
Self::new(name, Kind::CoerceDate)
|
||||
}
|
||||
pub fn json(name: &str) -> Self {
|
||||
Self::new(name, Kind::Json)
|
||||
}
|
||||
pub fn union(name: &str, options: Vec<Field>) -> Self {
|
||||
Self::new(name, Kind::Union(options))
|
||||
}
|
||||
pub fn literal(name: &str, value: Value) -> Self {
|
||||
Self::new(name, Kind::Literal(value))
|
||||
}
|
||||
pub fn null(name: &str) -> Self {
|
||||
Self::new(name, Kind::Null)
|
||||
}
|
||||
pub fn enumeration(name: &str, values: &'static [&'static str]) -> Self {
|
||||
Self::new(name, Kind::Enum(values))
|
||||
}
|
||||
/// Colonne integer / serial pour drizzle-zod : number().min().max().int()
|
||||
pub fn int32(name: &str) -> Self {
|
||||
Self::number(name).min(INT32_MIN).max(INT32_MAX).int()
|
||||
}
|
||||
/// Colonne bigint (mode number) pour drizzle-zod
|
||||
pub fn safe_integer(name: &str) -> Self {
|
||||
Self::number(name).min(-SAFE_INTEGER).max(SAFE_INTEGER).int()
|
||||
}
|
||||
|
||||
pub fn min_len(mut self, length: usize) -> Self {
|
||||
self.checks.push(Check::MinLen(length));
|
||||
self
|
||||
}
|
||||
/// .min(n, "message") : message choisi au lieu de celui de zod
|
||||
pub fn min_len_message(mut self, length: usize, message: &str) -> Self {
|
||||
self.checks
|
||||
.push(Check::MinLenMessage(length, message.to_string()));
|
||||
self
|
||||
}
|
||||
pub fn max_len(mut self, length: usize) -> Self {
|
||||
self.checks.push(Check::MaxLen(length));
|
||||
self
|
||||
}
|
||||
pub fn email(mut self) -> Self {
|
||||
self.checks.push(Check::Email);
|
||||
self
|
||||
}
|
||||
pub fn min(mut self, value: f64) -> Self {
|
||||
self.checks.push(Check::Min(value, true));
|
||||
self
|
||||
}
|
||||
pub fn max(mut self, value: f64) -> Self {
|
||||
self.checks.push(Check::Max(value, true));
|
||||
self
|
||||
}
|
||||
pub fn positive(mut self) -> Self {
|
||||
self.checks.push(Check::Min(0.0, false));
|
||||
self
|
||||
}
|
||||
pub fn int(mut self) -> Self {
|
||||
self.checks.push(Check::Int);
|
||||
self
|
||||
}
|
||||
pub fn optional(mut self) -> Self {
|
||||
self.optional = true;
|
||||
self
|
||||
}
|
||||
pub fn nullable(mut self) -> Self {
|
||||
self.nullable = true;
|
||||
self
|
||||
}
|
||||
/// .default(valeur) : appliquée quand le champ est absent
|
||||
pub fn default(mut self, value: Value) -> Self {
|
||||
self.default = Some(value);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Object {
|
||||
fields: Vec<Field>,
|
||||
}
|
||||
|
||||
/// Corps validé : seuls les champs du schéma, avec leurs valeurs converties
|
||||
pub type Parsed = Map<String, Value>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Object {
|
||||
pub fn new(fields: Vec<Field>) -> Self {
|
||||
Self { fields }
|
||||
}
|
||||
|
||||
/// .omit({ … })
|
||||
pub fn omit(mut self, names: &[&str]) -> Self {
|
||||
self.fields.retain(|field| !names.contains(&field.name.as_str()));
|
||||
self
|
||||
}
|
||||
|
||||
/// .pick({ … }) : l'ordre reste celui du schéma
|
||||
pub fn pick(mut self, names: &[&str]) -> Self {
|
||||
self.fields.retain(|field| names.contains(&field.name.as_str()));
|
||||
self
|
||||
}
|
||||
|
||||
/// .extend({ … }) : un champ existant garde sa place, un nouveau va à la fin
|
||||
pub fn extend(mut self, fields: Vec<Field>) -> Self {
|
||||
for field in fields {
|
||||
match self
|
||||
.fields
|
||||
.iter_mut()
|
||||
.find(|existing| existing.name == field.name)
|
||||
{
|
||||
Some(existing) => *existing = field,
|
||||
None => self.fields.push(field),
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// .partial() : tout devient facultatif, et les valeurs par défaut ne s'appliquent plus
|
||||
pub fn partial(mut self) -> Self {
|
||||
for field in &mut self.fields {
|
||||
field.optional = true;
|
||||
field.default = None;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn parse(&self, input: &Value) -> Result<Parsed, Vec<Value>> {
|
||||
let Some(object) = input.as_object() else {
|
||||
return Err(vec![invalid_type("object", input, &[])]);
|
||||
};
|
||||
let mut issues = Vec::new();
|
||||
let mut output = Map::new();
|
||||
for field in &self.fields {
|
||||
let path = [Value::String(field.name.clone())];
|
||||
let value = match object.get(&field.name) {
|
||||
None => match &field.default {
|
||||
Some(default) => default.clone(),
|
||||
None if field.optional => continue,
|
||||
None => {
|
||||
issues.push(json!({
|
||||
"code": "invalid_type",
|
||||
"expected": expected_name(&field.kind),
|
||||
"received": "undefined",
|
||||
"path": path,
|
||||
"message": "Required",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Some(value) => value.clone(),
|
||||
};
|
||||
if value.is_null() && field.nullable {
|
||||
output.insert(field.name.clone(), Value::Null);
|
||||
continue;
|
||||
}
|
||||
match check_value(field, &value, &path) {
|
||||
Ok(parsed) => {
|
||||
output.insert(field.name.clone(), parsed);
|
||||
}
|
||||
Err(mut field_issues) => issues.append(&mut field_issues),
|
||||
}
|
||||
}
|
||||
if issues.is_empty() {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_value(field: &Field, value: &Value, path: &[Value]) -> Result<Value, Vec<Value>> {
|
||||
let parsed = match &field.kind {
|
||||
Kind::String => match value {
|
||||
Value::String(_) => value.clone(),
|
||||
_ => return Err(vec![invalid_type("string", value, path)]),
|
||||
},
|
||||
Kind::Number => match value {
|
||||
Value::Number(_) => value.clone(),
|
||||
_ => return Err(vec![invalid_type("number", value, path)]),
|
||||
},
|
||||
Kind::CoerceNumber => {
|
||||
let number = js::to_number(value);
|
||||
if number.is_nan() {
|
||||
return Err(vec![issue_nan(path)]);
|
||||
}
|
||||
js::number_value(number)
|
||||
}
|
||||
Kind::Boolean => match value {
|
||||
Value::Bool(_) => value.clone(),
|
||||
_ => return Err(vec![invalid_type("boolean", value, path)]),
|
||||
},
|
||||
Kind::Date => return Err(vec![invalid_type("date", value, path)]),
|
||||
Kind::CoerceDate => match js::new_date(value) {
|
||||
Some(date) => Value::String(js::iso(&date)),
|
||||
None => {
|
||||
return Err(vec![
|
||||
json!({ "code": "invalid_date", "path": path, "message": "Invalid date" }),
|
||||
]);
|
||||
}
|
||||
},
|
||||
Kind::Enum(options) => match value {
|
||||
Value::String(text) if options.contains(&text.as_str()) => value.clone(),
|
||||
Value::String(text) => {
|
||||
return Err(vec![json!({
|
||||
"received": text,
|
||||
"code": "invalid_enum_value",
|
||||
"options": options,
|
||||
"path": path,
|
||||
"message": format!("Invalid enum value. Expected {}, received '{}'", join_values(options), text),
|
||||
})]);
|
||||
}
|
||||
_ => return Err(vec![invalid_type(&join_values(options), value, path)]),
|
||||
},
|
||||
Kind::Json => value.clone(),
|
||||
Kind::Union(options) => return check_union(options, value, path),
|
||||
Kind::Literal(expected) => {
|
||||
if value != expected {
|
||||
return Err(vec![json!({
|
||||
"received": value,
|
||||
"code": "invalid_literal",
|
||||
"expected": expected,
|
||||
"path": path,
|
||||
"message": format!("Invalid literal value, expected {expected}"),
|
||||
})]);
|
||||
}
|
||||
value.clone()
|
||||
}
|
||||
Kind::Null => match value {
|
||||
Value::Null => Value::Null,
|
||||
_ => return Err(vec![invalid_type("null", value, path)]),
|
||||
},
|
||||
};
|
||||
|
||||
let mut issues = Vec::new();
|
||||
for check in &field.checks {
|
||||
match (check, &parsed) {
|
||||
(Check::MinLen(min), Value::String(text)) => {
|
||||
if js::length(text) < *min {
|
||||
issues.push(json!({
|
||||
"code": "too_small", "minimum": min, "type": "string", "inclusive": true, "exact": false,
|
||||
"message": format!("String must contain at least {min} character(s)"), "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::MaxLen(max), Value::String(text)) => {
|
||||
if js::length(text) > *max {
|
||||
issues.push(json!({
|
||||
"code": "too_big", "maximum": max, "type": "string", "inclusive": true, "exact": false,
|
||||
"message": format!("String must contain at most {max} character(s)"), "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::Email, Value::String(text)) => {
|
||||
if !is_email(text) {
|
||||
issues.push(json!({
|
||||
"validation": "email", "code": "invalid_string", "message": "Invalid email", "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::Min(min, inclusive), Value::Number(number)) => {
|
||||
let number = number.as_f64().unwrap_or(f64::NAN);
|
||||
let too_small = if *inclusive { number < *min } else { number <= *min };
|
||||
if too_small {
|
||||
let minimum = js::number_value(*min);
|
||||
let message = if *inclusive {
|
||||
format!(
|
||||
"Number must be greater than or equal to {}",
|
||||
js::number_to_string(*min)
|
||||
)
|
||||
} else {
|
||||
format!("Number must be greater than {}", js::number_to_string(*min))
|
||||
};
|
||||
issues.push(json!({
|
||||
"code": "too_small", "minimum": minimum, "type": "number", "inclusive": inclusive,
|
||||
"exact": false, "message": message, "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::Max(max, inclusive), Value::Number(number)) => {
|
||||
let number = number.as_f64().unwrap_or(f64::NAN);
|
||||
let too_big = if *inclusive { number > *max } else { number >= *max };
|
||||
if too_big {
|
||||
let maximum = js::number_value(*max);
|
||||
let message = if *inclusive {
|
||||
format!(
|
||||
"Number must be less than or equal to {}",
|
||||
js::number_to_string(*max)
|
||||
)
|
||||
} else {
|
||||
format!("Number must be less than {}", js::number_to_string(*max))
|
||||
};
|
||||
issues.push(json!({
|
||||
"code": "too_big", "maximum": maximum, "type": "number", "inclusive": inclusive,
|
||||
"exact": false, "message": message, "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::Int, Value::Number(number)) => {
|
||||
let number = number.as_f64().unwrap_or(f64::NAN);
|
||||
if number.fract() != 0.0 {
|
||||
issues.push(json!({
|
||||
"code": "invalid_type", "expected": "integer", "received": "float",
|
||||
"message": "Expected integer, received float", "path": path,
|
||||
}));
|
||||
}
|
||||
}
|
||||
(Check::MinLenMessage(min, message), Value::String(text)) if js::length(text) < *min => {
|
||||
issues.push(json!({
|
||||
"code": "too_small", "minimum": min, "type": "string", "inclusive": true, "exact": false,
|
||||
"message": message, "path": path,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if issues.is_empty() {
|
||||
Ok(parsed)
|
||||
} else {
|
||||
Err(issues)
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_name(kind: &Kind) -> String {
|
||||
match kind {
|
||||
Kind::String => "string".into(),
|
||||
Kind::Number | Kind::CoerceNumber => "number".into(),
|
||||
Kind::Boolean => "boolean".into(),
|
||||
Kind::Date | Kind::CoerceDate => "date".into(),
|
||||
Kind::Enum(options) => join_values(options),
|
||||
Kind::Json => "unknown".into(),
|
||||
// Champ union ou littéral obligatoire et absent : aucun schéma porté n'en
|
||||
// a (zod rendrait invalid_union / invalid_literal)
|
||||
Kind::Union(_) => "union".into(),
|
||||
Kind::Literal(value) => value.to_string(),
|
||||
Kind::Null => "null".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ZodUnion : la valeur de la première option valide ; sinon les erreurs de la
|
||||
/// première option du bon type dont un contrôle échoue (résultat « dirty » de
|
||||
/// zod) ; sinon invalid_union avec les erreurs de chaque option (ZodError
|
||||
/// sérialisée : { issues, name })
|
||||
fn check_union(options: &[Field], value: &Value, path: &[Value]) -> Result<Value, Vec<Value>> {
|
||||
let mut dirty: Option<Vec<Value>> = None;
|
||||
let mut union_errors = Vec::new();
|
||||
for option in options {
|
||||
match check_value(option, value, path) {
|
||||
Ok(parsed) => return Ok(parsed),
|
||||
Err(issues) => {
|
||||
if dirty.is_none() && type_matches(&option.kind, value) {
|
||||
dirty = Some(issues.clone());
|
||||
}
|
||||
union_errors.push(json!({ "issues": issues, "name": "ZodError" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(issues) = dirty {
|
||||
return Err(issues);
|
||||
}
|
||||
Err(vec![json!({
|
||||
"code": "invalid_union",
|
||||
"unionErrors": union_errors,
|
||||
"path": path,
|
||||
"message": "Invalid input",
|
||||
})])
|
||||
}
|
||||
|
||||
/// La valeur a-t-elle le type de l'option (seuls ses contrôles peuvent alors échouer) ?
|
||||
fn type_matches(kind: &Kind, value: &Value) -> bool {
|
||||
match kind {
|
||||
Kind::String => value.is_string(),
|
||||
Kind::Number => value.is_number(),
|
||||
Kind::CoerceNumber => !js::to_number(value).is_nan(),
|
||||
Kind::Boolean => value.is_boolean(),
|
||||
Kind::Date => false,
|
||||
Kind::CoerceDate => js::new_date(value).is_some(),
|
||||
Kind::Enum(options) => value.as_str().is_some_and(|text| options.contains(&text)),
|
||||
Kind::Json => true,
|
||||
Kind::Union(options) => options.iter().any(|option| type_matches(&option.kind, value)),
|
||||
Kind::Literal(expected) => value == expected,
|
||||
Kind::Null => value.is_null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type reçu selon zod (getParsedType)
|
||||
fn received_name(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_type(expected: &str, value: &Value, path: &[Value]) -> Value {
|
||||
let received = received_name(value);
|
||||
json!({
|
||||
"code": "invalid_type",
|
||||
"expected": expected,
|
||||
"received": received,
|
||||
"path": path,
|
||||
"message": format!("Expected {expected}, received {received}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_nan(path: &[Value]) -> Value {
|
||||
json!({
|
||||
"code": "invalid_type",
|
||||
"expected": "number",
|
||||
"received": "nan",
|
||||
"path": path,
|
||||
"message": "Expected number, received nan",
|
||||
})
|
||||
}
|
||||
|
||||
/// util.joinValues de zod : 'a' | 'b'
|
||||
fn join_values(options: &[&str]) -> String {
|
||||
options
|
||||
.iter()
|
||||
.map(|option| format!("'{option}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
}
|
||||
|
||||
/// Expression régulière des courriels de zod 3.25, écrite à la main
|
||||
/// (/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i)
|
||||
fn is_email(text: &str) -> bool {
|
||||
if text.starts_with('.') || text.contains("..") {
|
||||
return false;
|
||||
}
|
||||
let Some((local, domain)) = text.split_once('@') else {
|
||||
return false;
|
||||
};
|
||||
if domain.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let local_ok = local
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "_'+-.".contains(c))
|
||||
&& local
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric() || "_+-".contains(c));
|
||||
let labels: Vec<&str> = domain.split('.').collect();
|
||||
if labels.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let (tld, rest) = labels.split_last().unwrap();
|
||||
let rest_ok = rest.iter().all(|label| {
|
||||
label.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
|
||||
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
});
|
||||
local_ok && rest_ok && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn contact_schema() -> Object {
|
||||
Object::new(vec![
|
||||
Field::int32("id").optional(),
|
||||
Field::int32("groupId"),
|
||||
Field::string("name").max_len(255),
|
||||
Field::string("email").max_len(255).nullable().optional(),
|
||||
])
|
||||
.omit(&["id"])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn champ_obligatoire_absent() {
|
||||
let issues = contact_schema().parse(&json!({ "groupId": 1 })).unwrap_err();
|
||||
assert_eq!(
|
||||
issues,
|
||||
vec![json!({
|
||||
"code": "invalid_type", "expected": "string", "received": "undefined",
|
||||
"path": ["name"], "message": "Required",
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn champs_inconnus_retires_et_null_accepte() {
|
||||
let parsed = contact_schema()
|
||||
.parse(&json!({ "groupId": 2, "name": "Metz", "email": null, "autre": 1 }))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Value::Object(parsed),
|
||||
json!({ "groupId": 2, "name": "Metz", "email": null })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entier_hors_limites_et_decimal() {
|
||||
let issues = contact_schema()
|
||||
.parse(&json!({ "groupId": 2.5, "name": "x" }))
|
||||
.unwrap_err();
|
||||
assert_eq!(issues[0]["message"], "Expected integer, received float");
|
||||
let issues = contact_schema()
|
||||
.parse(&json!({ "groupId": "1", "name": 3 }))
|
||||
.unwrap_err();
|
||||
assert_eq!(issues[0]["message"], "Expected number, received string");
|
||||
assert_eq!(issues[1]["message"], "Expected string, received number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partiel_sans_valeur_par_defaut() {
|
||||
let schema = Object::new(vec![
|
||||
Field::coerce_number("quantity")
|
||||
.int()
|
||||
.positive()
|
||||
.default(json!(1)),
|
||||
]);
|
||||
assert_eq!(
|
||||
Value::Object(schema.clone().parse(&json!({})).unwrap()),
|
||||
json!({ "quantity": 1 })
|
||||
);
|
||||
assert_eq!(
|
||||
Value::Object(schema.partial().parse(&json!({})).unwrap()),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn courriels() {
|
||||
assert!(is_email("paul@lactalis.test"));
|
||||
assert!(!is_email("paul@lactalis"));
|
||||
assert!(!is_email(".paul@x.fr"));
|
||||
assert!(!is_email("pa..ul@x.fr"));
|
||||
}
|
||||
|
||||
// z.union([z.string().email(), z.literal(""), z.null()]).optional()
|
||||
fn email_union() -> Object {
|
||||
Object::new(vec![
|
||||
Field::union(
|
||||
"email",
|
||||
vec![
|
||||
Field::string("").email(),
|
||||
Field::literal("", json!("")),
|
||||
Field::null(""),
|
||||
],
|
||||
)
|
||||
.optional(),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_premiere_option_valide() {
|
||||
for value in [json!("a@b.fr"), json!(""), json!(null)] {
|
||||
let parsed = email_union().parse(&json!({ "email": value })).unwrap();
|
||||
assert_eq!(parsed["email"], value);
|
||||
}
|
||||
assert!(email_union().parse(&json!({})).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_option_du_bon_type_en_erreur() {
|
||||
// Le courriel invalide est une chaîne : seule l'erreur de format remonte
|
||||
let issues = email_union()
|
||||
.parse(&json!({ "email": "pas-un-courriel" }))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
issues,
|
||||
vec![
|
||||
json!({ "validation": "email", "code": "invalid_string", "message": "Invalid email", "path": ["email"] })
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_sans_option_possible() {
|
||||
// Valeur rendue par Node (POST /api/users, { "email": 5 })
|
||||
let issues = email_union().parse(&json!({ "email": 5 })).unwrap_err();
|
||||
let node: Value = serde_json::from_str(
|
||||
r#"[{"code":"invalid_union","unionErrors":[{"issues":[{"code":"invalid_type","expected":"string","received":"number","path":["email"],"message":"Expected string, received number"}],"name":"ZodError"},{"issues":[{"received":5,"code":"invalid_literal","expected":"","path":["email"],"message":"Invalid literal value, expected \"\""}],"name":"ZodError"},{"issues":[{"code":"invalid_type","expected":"null","received":"number","path":["email"],"message":"Expected null, received number"}],"name":"ZodError"}],"path":["email"],"message":"Invalid input"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(serde_json::to_string(&issues).unwrap(), node.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longueur_minimale_avec_message() {
|
||||
let schema = Object::new(vec![
|
||||
Field::string("username").min_len_message(1, "L'identifiant est obligatoire"),
|
||||
]);
|
||||
let issues = schema.parse(&json!({ "username": "" })).unwrap_err();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&issues).unwrap(),
|
||||
r#"[{"code":"too_small","minimum":1,"type":"string","inclusive":true,"exact":false,"message":"L'identifiant est obligatoire","path":["username"]}]"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration() {
|
||||
let schema = Object::new(vec![Field::enumeration("status", &["nouveau", "resolu"])]);
|
||||
let issues = schema.parse(&json!({ "status": "x" })).unwrap_err();
|
||||
assert_eq!(
|
||||
issues[0]["message"],
|
||||
"Invalid enum value. Expected 'nouveau' | 'resolu', received 'x'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user