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:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-15 20:31:14 +02:00
1 parent 6760f43e87
commit 600627c632
146 files changed
+31521 -20331

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
+3188
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -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
+71
View File
@@ -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.
+50
View File
@@ -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`.
+54
View File
@@ -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());
}
}
}
+1
View File
@@ -0,0 +1 @@
max_width = 110
+35
View File
@@ -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"));
}
}
+257
View File
@@ -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);
}
}
+69
View File
@@ -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"));
}
}
+60
View File
@@ -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(),
})
}
}
+92
View File
@@ -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"), "");
}
}
+313
View File
@@ -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\""));
}
}
+87
View File
@@ -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(),
}
}
+139
View File
@@ -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());
}
}
+116
View File
@@ -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}")
);
}
}
+197
View File
@@ -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"),
}
}
}
+542
View File
@@ -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);
}
}
+158
View File
@@ -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");
}
}
+97
View File
@@ -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é");
}
+329
View File
@@ -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)"
);
}
}
+89
View File
@@ -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())
}
+564
View File
@@ -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));
}
}
+713
View File
@@ -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);
}
}
+386
View File
@@ -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" }));
}
}
+570
View File
@@ -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" }));
}
}
+563
View File
@@ -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()
);
}
}
+389
View File
@@ -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);
}
}
+99
View File
@@ -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())
}
+242
View File
@@ -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))
}
+918
View File
@@ -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"));
}
}
+423
View File
@@ -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
+226
View File
@@ -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}}"#
);
}
}
+264
View File
@@ -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"]}]"#
);
}
}
+16
View File
@@ -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",
}))
}
+33
View File
@@ -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()
}
+136
View File
@@ -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())
}
+467
View File
@@ -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")]
);
}
}
+656
View File
@@ -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());
}
}
+472
View File
@@ -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());
}
}
+795
View File
@@ -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(&section[..header_end]);
let content_start = header_end + 4;
let content_end = section.len().saturating_sub(2).max(content_start);
let content = section.get(content_start..content_end).unwrap_or_default();
let Some(name) = capture(&headers, "name=\"", &['"'], Some('"')) else {
continue;
};
let part = match capture(&headers, "filename=\"", &['"'], Some('"')) {
Some(filename) => Part::File {
filename,
content_type: capture(&headers, "Content-Type: ", &['\r', '\n'], None)
.unwrap_or_else(|| "application/octet-stream".to_string()),
data: content.to_vec(),
},
None => Part::Text(String::from_utf8_lossy(content).into_owned()),
};
parts.insert(name, part);
}
parts
}
// ---------- Renvoi en multipart (formDataToBlob de node-fetch) ----------
/// Type d'un Blob : vide s'il sort de U+0020–U+007E, sinon en minuscules
fn blob_type(content_type: &str) -> String {
if content_type.chars().all(|c| (' '..='~').contains(&c)) {
content_type.to_ascii_lowercase()
} else {
String::new()
}
}
/// Fin de ligne isolée (\r ou \n) remplacée par \r\n
fn crlf(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\r' => {
out.push_str("\r\n");
if chars.peek() == Some(&'\n') {
chars.next();
}
}
'\n' => out.push_str("\r\n"),
other => out.push(other),
}
}
out
}
/// Échappement des noms de champ et de fichier dans Content-Disposition
fn escape_name(name: &str, keep_newlines: bool) -> String {
let name = if keep_newlines {
name.to_string()
} else {
crlf(name)
};
name.replace('\n', "%0A").replace('\r', "%0D").replace('"', "%22")
}
/// Corps multipart et son type (boundary aléatoire, 32 caractères)
fn encode_form(file: (&str, &str, &[u8]), fields: &[(&str, String)]) -> (Vec<u8>, String) {
let digits: String = (0..28)
.map(|_| char::from(b'0' + rand::thread_rng().gen_range(0..10)))
.collect();
let boundary = format!("----{digits}");
let (filename, content_type, data) = file;
let mut body = Vec::with_capacity(data.len() + 512);
let content_type = if content_type.is_empty() {
"application/octet-stream"
} else {
content_type
};
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: {content_type}\r\n\r\n",
escape_name(filename, true)
)
.as_bytes(),
);
body.extend_from_slice(data);
body.extend_from_slice(b"\r\n");
for (name, value) in fields {
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n",
escape_name(name, false),
crlf(value)
)
.as_bytes(),
);
}
body.extend_from_slice(format!("--{boundary}--").as_bytes());
(body, format!("multipart/form-data; boundary={boundary}"))
}
// ---------- Adresse autorisée (RAPPRO-21) ----------
/// L'adresse est-elle celle d'un magasin ou du webhook BAP actif (première ligne
/// de la configuration, comme storage.getWebhookBapConfig) ?
async fn is_configured_webhook(db: &sqlx::PgPool, url: &str) -> Result<bool, sqlx::Error> {
let (allowed,): (bool,) = sqlx::query_as(
"SELECT EXISTS (SELECT 1 FROM groups WHERE webhook_url = $1)
OR EXISTS (SELECT 1 FROM (SELECT webhook_url, is_active FROM webhook_bap_config LIMIT 1) bap
WHERE bap.is_active AND bap.webhook_url = $1)",
)
.bind(url)
.fetch_one(db)
.await?;
Ok(allowed)
}
pub async fn send_invoice(
State(state): State<AppState>,
user: CurrentUser,
headers: HeaderMap,
body: Body,
) -> ApiResult {
let server_error = |message: &str| {
let message = if message.is_empty() {
"Erreur serveur"
} else {
message
};
error_body(StatusCode::INTERNAL_SERVER_ERROR, message)
};
if !["admin", "directeur"].contains(&user.role()) {
return Err(error_body(StatusCode::FORBIDDEN, "Accès refusé"));
}
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let Some(boundary) = boundary(content_type) else {
return Err(error_body(StatusCode::BAD_REQUEST, "Format multipart invalide"));
};
// Corps lu en entier, sans limite de taille, comme Node
let buffer = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|error| server_error(&error.to_string()))?;
let parts = parse_multipart(&buffer, &boundary);
let (Some(file), Some(webhook_url)) = (
parts.get("file").filter(|part| part.truthy()),
parts.get("webhookUrl").filter(|part| part.truthy()),
) else {
return Err(error_body(StatusCode::BAD_REQUEST, "Fichier ou webhook manquant"));
};
// Champ « file » en texte : parts.file.buffer.length lève une TypeError
let Part::File {
filename,
content_type: file_type,
data,
} = file
else {
return Err(server_error(
"Cannot read properties of undefined (reading 'length')",
));
};
tracing::info!(
user = user.id(),
filename,
size = data.len(),
"envoi de facture au webhook"
);
// Écart voulu (RAPPRO-21) : seule une adresse configurée est appelée
let allowed_url = match webhook_url {
Part::Text(url) => is_configured_webhook(&state.db, url)
.await
.map_err(|error| server_error(&database_message(&error)))?
.then_some(url),
Part::File { .. } => None,
};
let Some(url) = allowed_url else {
return Err(error_body(
StatusCode::BAD_REQUEST,
"Adresse de webhook non autorisée : seule l'adresse configurée pour un magasin ou pour le webhook BAP est acceptée",
));
};
let fields = [
("supplier", Part::text_or(parts.get("supplier"), "")),
("blNumber", Part::text_or(parts.get("blNumber"), "")),
("type", Part::text_or(parts.get("type"), "Facture")),
];
let (form, form_type) = encode_form((filename, &blob_type(file_type), data), &fields);
let response = node_fetch_post(
url,
|request| request.header(header::CONTENT_TYPE, form_type).body(form),
None,
)
.await;
match response {
Ok((status, _)) if status.is_success() => {
Ok(Json(json!({ "success": true, "message": "Facture envoyée" })).into_response())
}
Ok((status, details)) => {
tracing::error!(%status, "webhook de facture en erreur");
Err(AppError::with_body(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": format!("Webhook error: {}", status.as_u16()), "details": details }),
))
}
Err(error) => Err(server_error(&error.message)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boundary_comme_l_expression_de_node() {
assert_eq!(
boundary("multipart/form-data; boundary=abc").as_deref(),
Some("abc")
);
assert_eq!(
boundary("multipart/form-data; BOUNDARY=\"a b\"; x=1").as_deref(),
Some("a b")
);
assert_eq!(
boundary("multipart/form-data; boundary=;boundary=z").as_deref(),
Some("z")
);
assert_eq!(boundary("application/json"), None);
assert_eq!(boundary(""), None);
}
#[test]
fn analyse_multipart_comme_node() {
let body = b"preambule\r\n--XX\r\nContent-Disposition: form-data; name=\"supplier\"\r\n\r\nLactalis\r\n\
--XX\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.pdf\"\r\nContent-Type: application/pdf\r\n\r\n%%%\r\n\
--XX\r\nContent-Disposition: form-data; name=\"vide\"; filename=\"\"\r\n\r\nabc\r\n\
--XX\r\nContent-Disposition: form-data; name=\"supplier\"\r\n\r\nBonduelle\r\n--XX--\r\n";
let parts = parse_multipart(body, "XX");
assert_eq!(parts.get("supplier"), Some(&Part::Text("Bonduelle".into())));
assert_eq!(
parts.get("file"),
Some(&Part::File {
filename: "f.pdf".into(),
content_type: "application/pdf".into(),
data: b"%%%".to_vec()
})
);
// filename vide : /filename="([^"]+)"/ échoue, la partie est un texte
assert_eq!(parts.get("vide"), Some(&Part::Text("abc".into())));
}
#[test]
fn encodage_comme_form_data_to_blob() {
assert_eq!(blob_type("Application/PDF"), "application/pdf");
assert_eq!(blob_type("image/é"), "");
assert_eq!(crlf("a\nb\rc\r\nd"), "a\r\nb\r\nc\r\nd");
assert_eq!(escape_name("a\"b\nc", true), "a%22b%0Ac");
let (body, content_type) = encode_form(("f.pdf", "", b"xy"), &[("type", "Facture".into())]);
let boundary = content_type
.strip_prefix("multipart/form-data; boundary=")
.unwrap();
assert_eq!(boundary.len(), 32);
assert_eq!(
String::from_utf8(body).unwrap(),
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.pdf\"\r\nContent-Type: application/octet-stream\r\n\r\nxy\r\n\
--{boundary}\r\nContent-Disposition: form-data; name=\"type\"\r\n\r\nFacture\r\n--{boundary}--"
)
);
}
}
+407
View File
@@ -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"
]
);
}
}
+647
View File
@@ -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(), &params, &mut key).expect("longueur de clé valide");
key
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Buffer.from(texte, "hex") : paires décodées jusqu'au premier caractère non
/// hexadécimal, demi-octet final ignoré
fn node_hex(text: &str) -> Vec<u8> {
let digit = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
let mut out = Vec::with_capacity(text.len() / 2);
for pair in text.as_bytes().chunks_exact(2) {
match (digit(pair[0]), digit(pair[1])) {
(Some(high), Some(low)) => out.push((high << 4) | low),
_ => break,
}
}
out
}
fn hash_with_salt(password: &str, salt: &str) -> String {
format!("{}.{salt}", to_hex(&derive(password, salt)))
}
/// hashPassword (server/localAuth.ts) : « empreinte_hex.sel_hex », le sel
/// hexadécimal (16 octets aléatoires) servant tel quel de texte. Calcul de
/// plusieurs dizaines de millisecondes : hors des fils de tokio.
pub async fn hash_password(password: String) -> String {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
let salt = to_hex(&salt);
tokio::task::spawn_blocking(move || hash_with_salt(&password, &salt))
.await
.expect("calcul scrypt")
}
/// comparePasswords (server/localAuth.ts), sans l'accès de développement
/// « admin.salt » (AUTH-04) ; comparaison en temps constant
fn verify_password(supplied: &str, stored: &str) -> bool {
let mut parts = stored.split('.');
let (Some(hashed), Some(salt)) = (parts.next(), parts.next()) else {
return false;
};
if hashed.is_empty() || salt.is_empty() {
return false;
}
let expected = node_hex(hashed);
// timingSafeEqual lève une erreur quand les longueurs diffèrent : refus
if expected.len() != KEY_LEN {
return false;
}
expected.ct_eq(&derive(supplied, salt)).into()
}
// ---------- Session d'express-session ----------
/// cookie.maxAge de localAuth.ts : 24 heures
const MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000;
/// uid-safe(24), comme express-session : 32 caractères base64url
fn new_session_id() -> String {
let mut bytes = [0u8; 24];
rand::thread_rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// Colonne sess : la session d'express-session (cookie) et l'utilisateur de passport
fn session_json(user_id: &str, expires: &DateTime<Utc>) -> Value {
json!({
"cookie": {
"originalMaxAge": MAX_AGE_MS,
"expires": js::iso(expires),
"secure": false,
"httpOnly": true,
"path": "/",
"sameSite": "lax",
},
"passport": { "user": user_id },
})
}
/// Colonne expire : connect-pg-simple écrit to_timestamp(Math.ceil(expires / 1000))
fn expire_seconds(expires: &DateTime<Utc>) -> i64 {
let millis = expires.timestamp_millis();
millis.div_euclid(1000) + i64::from(millis.rem_euclid(1000) != 0)
}
/// Date.prototype.toUTCString
fn http_date(date: &DateTime<Utc>) -> String {
date.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
/// encodeURIComponent, utilisé par le paquet cookie pour la valeur
fn encode_uri_component(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
/// Set-Cookie d'express-session : s:<sid>.<signature>, mêmes attributs
fn session_cookie(sid: &str, secret: &str, expires: &DateTime<Utc>) -> String {
let value = encode_uri_component(&format!("s:{sid}.{}", auth::sign(sid, secret)));
format!(
"{SESSION_COOKIE}={value}; Path=/; Expires={}; HttpOnly; SameSite=Lax",
http_date(expires)
)
}
/// res.clearCookie('connect.sid', { path: '/' }) d'Express 4
fn cleared_cookie() -> String {
format!("{SESSION_COOKIE}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT")
}
/// Erreur transmise à next(err) : le gestionnaire d'erreurs de Node renvoie son message
fn server_error(error: sqlx::Error) -> AppError {
tracing::error!(%error, "session");
let message = match &error {
sqlx::Error::Database(database) => database.message().to_string(),
_ => "Internal Server Error".to_string(),
};
AppError::internal(&message)
}
/// Session détruite par req.session.regenerate / destroy. express-session n'a
/// chargé (donc ne détruit) que la session encore valide du cookie.
async fn destroy_session(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
if let Some(sid) = auth::session_id(headers, &state.config.session_secret) {
sqlx::query("DELETE FROM sessions WHERE sid = $1 AND expire >= now()")
.bind(sid)
.execute(&state.db)
.await
.map_err(server_error)?;
}
Ok(())
}
// ---------- Connexion ----------
/// lookup(req.body, champ) || lookup(req.query, champ) de passport-local : une
/// valeur simple (texte, nombre, booléen) non vide ; un objet, un tableau ou null
/// ne compte pas
fn credential(body: &Value, query: Option<&str>, field: &str) -> Option<Value> {
let truthy = |value: &&Value| match value {
Value::String(text) => !text.is_empty(),
Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0),
Value::Bool(flag) => *flag,
_ => false,
};
body.as_object()
.and_then(|object| object.get(field))
.filter(truthy)
.cloned()
.or_else(|| query_value(query, field).map(Value::String))
}
/// req.query[champ] (analyseur qs d'Express) : une seule occurrence donne du
/// texte ; répété ou entre crochets, c'est un tableau ou un objet, ignoré
fn query_value(query: Option<&str>, field: &str) -> Option<String> {
let mut found = None;
let mut count = 0;
let bracketed = format!("{field}[");
for (key, value) in form_urlencoded::parse(query?.as_bytes()) {
if key == field {
count += 1;
found = Some(value.into_owned());
} else if key.starts_with(&bracketed) {
return None;
}
}
found.filter(|value| count == 1 && !value.is_empty())
}
async fn login(
State(state): State<AppState>,
headers: HeaderMap,
uri: Uri,
JsonBody(body): JsonBody,
) -> ApiResult {
let (Some(username), Some(password)) = (
credential(&body, uri.query(), "username"),
credential(&body, uri.query(), "password"),
) else {
return Err(AppError::bad_request("Missing credentials"));
};
// storage.getUserByUsername : un nombre ou un booléen part en texte (node-pg)
let user: Option<UsersRow> = sqlx::query_as(&UsersRow::select("WHERE username = $1"))
.bind(db::pg_text(&username))
.fetch_optional(&state.db)
.await
.map_err(server_error)?;
let Some((user, stored)) = user.and_then(|user| {
let stored = user.password.clone().filter(|password| !password.is_empty())?;
Some((user, stored))
}) else {
return Err(AppError::bad_request(REFUSED));
};
// Un mot de passe qui n'est pas du texte fait échouer crypto.scrypt : refusé
let valid = match password {
Value::String(password) => tokio::task::spawn_blocking(move || verify_password(&password, &stored))
.await
.unwrap_or(false),
_ => false,
};
if !valid {
return Err(AppError::bad_request(REFUSED));
}
// req.login : passport régénère la session (l'ancienne est détruite), puis
// express-session l'enregistre et envoie le cookie
destroy_session(&state, &headers).await?;
let sid = new_session_id();
let expires = Utc::now() + Duration::milliseconds(MAX_AGE_MS);
sqlx::query(
"INSERT INTO sessions (sess, expire, sid) VALUES ($1, to_timestamp($2), $3)
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire",
)
.bind(sqlx::types::Json(session_json(&user.id, &expires)))
.bind(expire_seconds(&expires) as f64)
.bind(&sid)
.execute(&state.db)
.await
.map_err(server_error)?;
let mut response = Json(json!({
"id": user.id,
"username": user.username,
"email": user.email,
"firstName": user.first_name,
"lastName": user.last_name,
"role": user.role,
"passwordChanged": user.password_changed,
}))
.into_response();
if let Ok(cookie) = HeaderValue::from_str(&session_cookie(&sid, &state.config.session_secret, &expires)) {
response.headers_mut().insert(header::SET_COOKIE, cookie);
}
Ok(response)
}
// ---------- Déconnexion ----------
/// Corps de res.redirect : celui que le client accepte (res.format avec text,
/// html et default, paquets accepts / negotiator)
#[derive(Debug, PartialEq)]
enum RedirectBody {
Text,
Html,
Empty,
}
/// Une entrée de l'en-tête Accept (negotiator, parseMediaType)
struct AcceptSpec {
kind: String,
subtype: String,
params: Vec<(String, String)>,
q: f64,
index: usize,
}
fn parse_accept(accept: &str) -> Vec<AcceptSpec> {
split_unquoted(accept, ',')
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let entry = entry.trim();
let (media, rest) = match entry.split_once(';') {
Some((media, rest)) => (media.trim_end(), Some(rest)),
None => (entry, None),
};
let (kind, subtype) = media.split_once('/')?;
if kind.is_empty() || subtype.is_empty() || media.contains(char::is_whitespace) {
return None;
}
let mut params = Vec::new();
let mut q = 1.0;
for pair in rest.map(|rest| split_unquoted(rest, ';')).unwrap_or_default() {
let pair = pair.trim();
let (key, value) = match pair.split_once('=') {
Some((key, value)) => (key.to_lowercase(), value),
None => (pair.to_lowercase(), ""),
};
let value = if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
&value[1..value.len() - 1]
} else {
value
};
if key == "q" {
q = parse_float(value);
break;
}
params.push((key, value.to_string()));
}
Some(AcceptSpec {
kind: kind.to_string(),
subtype: subtype.to_string(),
params,
q,
index,
})
})
.collect()
}
/// Découpe sur un séparateur hors des guillemets
fn split_unquoted(text: &str, separator: char) -> Vec<String> {
let mut parts = vec![String::new()];
let mut quoted = false;
for c in text.chars() {
if c == '"' {
quoted = !quoted;
}
if c == separator && !quoted {
parts.push(String::new());
} else {
parts.last_mut().expect("au moins une partie").push(c);
}
}
parts
}
/// parseFloat : nombre décimal de tête, NaN sinon
fn parse_float(text: &str) -> f64 {
let text = text.trim_start();
let end = text
.char_indices()
.take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && (*c == '-' || *c == '+')))
.map(|(i, c)| i + c.len_utf8())
.last()
.unwrap_or(0);
text[..end].parse().unwrap_or(f64::NAN)
}
/// accepts(req).types(['text', 'html']) : text/plain ou text/html, ou aucun
fn negotiate(accept: Option<&str>) -> RedirectBody {
let Some(accept) = accept.filter(|accept| !accept.is_empty()) else {
return RedirectBody::Text;
};
let specs = parse_accept(accept);
// (q, spécificité, index de l'entrée Accept, index du type proposé)
let mut ranked: Vec<(f64, u8, i64, usize)> = ["plain", "html"]
.iter()
.enumerate()
.map(|(candidate, subtype)| {
// getMediaTypePriority : l'entrée la plus spécifique, puis de plus
// grand q, puis la dernière ; un q illisible (NaN) ne départage pas
let mut best = (0.0, 0u8, -1i64);
for spec in &specs {
let mut s = 0u8;
if spec.kind.eq_ignore_ascii_case("text") {
s |= 4;
} else if spec.kind != "*" {
continue;
}
if spec.subtype.eq_ignore_ascii_case(subtype) {
s |= 2;
} else if spec.subtype != "*" {
continue;
}
if !spec.params.is_empty() {
// Les types proposés n'ont pas de paramètre
if spec
.params
.iter()
.all(|(_, value)| value == "*" || value.is_empty())
{
s |= 1;
} else {
continue;
}
}
let o = spec.index as i64;
let replaces = if s != best.1 {
s > best.1
} else if spec.q != best.0 && !spec.q.is_nan() && !best.0.is_nan() {
spec.q > best.0
} else {
o > best.2
};
if replaces {
best = (spec.q, s, o);
}
}
(best.0, best.1, best.2, candidate)
})
.filter(|(q, ..)| *q > 0.0)
.collect();
ranked.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(b.1.cmp(&a.1))
.then(a.2.cmp(&b.2))
.then(a.3.cmp(&b.3))
});
match ranked.first().map(|best| best.3) {
Some(0) => RedirectBody::Text,
Some(_) => RedirectBody::Html,
None => RedirectBody::Empty,
}
}
/// res.clearCookie puis res.redirect('/auth') d'Express 4
fn redirect_to_auth(headers: &HeaderMap) -> Response {
let accept = headers.get(header::ACCEPT).and_then(|value| value.to_str().ok());
let (content_type, body) = match negotiate(accept) {
RedirectBody::Text => (Some("text/plain; charset=utf-8"), "Found. Redirecting to /auth"),
RedirectBody::Html => (
Some("text/html; charset=utf-8"),
"<p>Found. Redirecting to /auth</p>",
),
RedirectBody::Empty => (None, ""),
};
let mut response = (StatusCode::FOUND, body).into_response();
let out = response.headers_mut();
out.remove(header::CONTENT_TYPE);
if let Some(content_type) = content_type {
out.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
}
out.insert(header::LOCATION, HeaderValue::from_static("/auth"));
out.insert(header::VARY, HeaderValue::from_static("Accept"));
if let Ok(cookie) = HeaderValue::from_str(&cleared_cookie()) {
out.insert(header::SET_COOKIE, cookie);
}
response
}
/// req.logout puis req.session.destroy : la session du cookie disparaît, que
/// Node ou Rust l'ait ouverte
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> ApiResult {
destroy_session(&state, &headers).await?;
Ok(redirect_to_auth(&headers))
}
// ---------- Utilisateur connecté ----------
async fn current_user(MaybeUser(user): MaybeUser) -> ApiResult {
let Some(current) = user else {
return Err(AppError::new(StatusCode::UNAUTHORIZED, "Non authentifié"));
};
let user = &current.user;
Ok(Json(json!({
"id": user.id,
"username": user.username,
"email": user.email,
"firstName": user.first_name,
"lastName": user.last_name,
"role": user.role,
"passwordChanged": user.password_changed,
"userGroups": current.user_groups,
}))
.into_response())
}
/// Identifiants par défaut à afficher : compte « admin » dont le mot de passe n'a
/// pas été changé (true aussi en cas d'erreur)
async fn default_credentials_check(State(state): State<AppState>) -> Response {
let admin: Result<Option<(Option<bool>,)>, sqlx::Error> =
sqlx::query_as("SELECT password_changed FROM users WHERE username = 'admin'")
.fetch_optional(&state.db)
.await;
let show_default = match admin {
Ok(Some((changed,))) => changed != Some(true),
Ok(None) => false,
Err(error) => {
tracing::error!(%error, "identifiants par défaut");
true
}
};
Json(json!({ "showDefault": show_default })).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
// Produit par hashPassword de server/localAuth.ts (Node 20)
const NODE_HASH: &str = "b72e456b13b111145d2092d3d54bb640795e2635232a2d87227c38456ee4adca56d349fb141f212c51954c8f86b39975aabb3067ca8527bc8197e808f76fadd7.a6e8e83d32cdaaf698eca9e18fb65909";
#[test]
fn verifie_une_empreinte_produite_par_node() {
assert!(verify_password("mot-de-passe-été", NODE_HASH));
assert!(!verify_password("mot-de-passe-ete", NODE_HASH));
assert!(!verify_password("", NODE_HASH));
}
#[test]
fn empreinte_identique_a_celle_de_node() {
// crypto.scryptSync('banc-admin', '0123456789abcdef0123456789abcdef', 64)
assert_eq!(
hash_with_salt("banc-admin", "0123456789abcdef0123456789abcdef"),
"b03c7493b9f7715b6936bf0197882c85221196dfbddd7800c99e9175e85f1697c65cbd4fcb9cd175e6612075eeddfb5056728b15d45d0fd4602310c2b48791d7.0123456789abcdef0123456789abcdef"
);
}
#[tokio::test]
async fn nouvelle_empreinte_verifiable() {
let stored = hash_password("secret".into()).await;
let (hash, salt) = stored.split_once('.').unwrap();
assert_eq!((hash.len(), salt.len()), (128, 32));
assert!(verify_password("secret", &stored));
}
#[test]
fn empreintes_mal_formees_refusees() {
// L'accès de développement de Node (AUTH-04) n'est pas reporté
assert!(!verify_password("admin", "admin.salt"));
assert!(!verify_password("x", "sans-point"));
assert!(!verify_password("x", ".sel"));
assert!(!verify_password("x", "abcd."));
// Buffer.from(…, "hex") s'arrête au premier caractère invalide
assert_eq!(node_hex("admin").len(), 1);
assert_eq!(node_hex("abc").len(), 1);
assert_eq!(node_hex("0g12").len(), 0);
assert_eq!(node_hex("aabbz1"), vec![0xaa, 0xbb]);
}
#[test]
fn session_au_format_d_express_session() {
// Connexion de « manager » sur Node : cookie et ligne de la table sessions
let sid = "BulI376e_Tsc2DIl_gcIiFo4700H27-7";
let expires = Utc.timestamp_millis_opt(1_792_151_360_449).unwrap();
assert_eq!(js::iso(&expires), "2026-10-16T11:49:20.449Z");
assert_eq!(
session_cookie(sid, "banc-secret-de-session", &expires),
"connect.sid=s%3ABulI376e_Tsc2DIl_gcIiFo4700H27-7.RnXJIMwqvuD90YKAO4k0RQnTWgzTi%2BzRdqTrrY5cvho; Path=/; Expires=Fri, 16 Oct 2026 11:49:20 GMT; HttpOnly; SameSite=Lax"
);
let node_sess: Value = serde_json::from_str(
r#"{"cookie": {"path": "/", "secure": false, "expires": "2026-10-16T11:49:20.449Z", "httpOnly": true, "sameSite": "lax", "originalMaxAge": 86400000}, "passport": {"user": "bench_manager"}}"#,
)
.unwrap();
assert_eq!(session_json("bench_manager", &expires), node_sess);
// expire = 2026-10-16 11:49:21 (arrondi à la seconde supérieure)
assert_eq!(
expire_seconds(&expires),
Utc.with_ymd_and_hms(2026, 10, 16, 11, 49, 21)
.unwrap()
.timestamp()
);
let whole = Utc.timestamp_millis_opt(1_792_151_360_000).unwrap();
assert_eq!(expire_seconds(&whole), 1_792_151_360);
assert_eq!(new_session_id().len(), 32);
}
#[test]
fn cookie_efface_comme_express() {
assert_eq!(
cleared_cookie(),
"connect.sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
);
assert_eq!(encode_uri_component("s:abc+/=.def"), "s%3Aabc%2B%2F%3D.def");
}
#[test]
fn corps_de_la_redirection_selon_accept() {
assert_eq!(negotiate(None), RedirectBody::Text);
assert_eq!(negotiate(Some("*/*")), RedirectBody::Text);
assert_eq!(
negotiate(Some("text/html,application/xhtml+xml,*/*;q=0.8")),
RedirectBody::Html
);
assert_eq!(negotiate(Some("application/json")), RedirectBody::Empty);
assert_eq!(negotiate(Some("text/*")), RedirectBody::Text);
assert_eq!(negotiate(Some("text/plain;q=0.5, text/html")), RedirectBody::Html);
assert_eq!(negotiate(Some("text/html;q=0")), RedirectBody::Empty);
}
#[test]
fn identifiants_comme_passport_local() {
let body = json!({ "username": "manager", "password": 123, "vide": "", "liste": ["x"] });
assert_eq!(credential(&body, None, "username"), Some(json!("manager")));
assert_eq!(credential(&body, None, "password"), Some(json!(123)));
assert_eq!(credential(&body, None, "vide"), None);
assert_eq!(credential(&body, None, "liste"), None);
assert_eq!(
credential(&json!([]), Some("username=a&x=1"), "username"),
Some(json!("a"))
);
assert_eq!(
credential(&json!({}), Some("username=a&username=b"), "username"),
None
);
assert_eq!(credential(&json!({}), Some("username[0]=a"), "username"), None);
}
}
+380
View File
@@ -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}"#
);
}
}
+95
View File
@@ -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())
}
+303
View File
@@ -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");
}
}
+463
View File
@@ -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);
}
}
+113
View File
@@ -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]"
);
}
}
+873
View File
@@ -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)));
}
}
+173
View File
@@ -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"));
}
}
+132
View File
@@ -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(", ")
}
}
+105
View File
@@ -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))
}
+77
View File
@@ -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
+762
View File
@@ -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
+548
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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&nbsp;:</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&nbsp;:</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&nbsp;:</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&nbsp;:</p>\n <ul><li>Fournisseur : A &amp; B &lt;x&gt;</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&quot;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());
}
}
+5
View File
@@ -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;
+397
View File
@@ -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 })
);
}
}
+494
View File
@@ -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(())
}
+294
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// 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&amp;&#39;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");
}
}
+693
View File
@@ -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'"
);
}
}