diff --git a/scripts/gen-rust-schema.ts b/scripts/gen-rust-schema.ts index a68b13a..3f185e1 100644 --- a/scripts/gen-rust-schema.ts +++ b/scripts/gen-rust-schema.ts @@ -178,6 +178,11 @@ async function main() { const attributes: string[] = []; const field = RUST_KEYWORDS.has(column.name) ? `${column.name}_` : column.name; if (field !== column.name) attributes.push(`#[sqlx(rename = "${column.name}")]`); + // timestamp dans shared/schema.ts, mais parfois timestamptz en production + // (tables créées hors des migrations du dépôt) : lu dans les deux cas + if (base === "NaiveDateTime") { + attributes.push(`#[sqlx(try_from = "${nullable ? "crate::db::AnyTimestampOpt" : "crate::db::AnyTimestamp"}")]`); + } const serde = [`rename = "${column.jsKey}"`]; if (base === "NaiveDateTime") serde.push(`serialize_with = "${nullable ? "crate::json::ts_opt" : "crate::json::ts"}"`); if (base === "Decimal") { diff --git a/server-rs/src/db.rs b/server-rs/src/db.rs index c1ebd75..f8a96fd 100644 --- a/server-rs/src/db.rs +++ b/server-rs/src/db.rs @@ -4,9 +4,11 @@ // 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 chrono::{DateTime, NaiveDateTime, Utc}; use serde_json::Value; -use sqlx::postgres::{PgArguments, PgRow}; -use sqlx::{Arguments, FromRow, PgPool}; +use sqlx::error::BoxDynError; +use sqlx::postgres::{PgArguments, PgRow, PgTypeInfo, PgValueRef}; +use sqlx::{Arguments, Decode, FromRow, PgPool, Postgres, Type, ValueRef}; use crate::zod::Parsed; @@ -34,6 +36,85 @@ pub trait Table: for<'r> FromRow<'r, PgRow> + Send + Unpin { } } +/// Colonne `timestamp` de shared/schema.ts, lue que la base réelle la déclare +/// `timestamp` ou `timestamptz`. Certaines tables de production ont été créées en +/// dehors des migrations du dépôt avec `timestamptz` (weather_settings.created_at, +/// avoirs…), et sqlx refuse de lire un `timestamptz` en NaiveDateTime. +/// +/// Un `timestamptz` est rendu en heure UTC : la même valeur que l'API sérialise +/// ensuite avec crate::json::ts (« …Z »). schema.rs l'utilise par +/// `#[sqlx(try_from = "crate::db::AnyTimestamp")]` : le champ reste NaiveDateTime. +pub struct AnyTimestamp(pub NaiveDateTime); + +/// AnyTimestamp d'une colonne qui peut être NULL +pub struct AnyTimestampOpt(pub Option); + +fn is_timestamptz(ty: &PgTypeInfo) -> bool { + as Type>::compatible(ty) +} + +fn any_timestamp_compatible(ty: &PgTypeInfo) -> bool { + >::compatible(ty) || is_timestamptz(ty) +} + +fn decode_any_timestamp(value: PgValueRef<'_>) -> Result { + let timestamptz = is_timestamptz(&value.type_info()); + if timestamptz { + Ok( as Decode<'_, Postgres>>::decode(value)?.naive_utc()) + } else { + >::decode(value) + } +} + +impl Type for AnyTimestamp { + fn type_info() -> PgTypeInfo { + >::type_info() + } + + fn compatible(ty: &PgTypeInfo) -> bool { + any_timestamp_compatible(ty) + } +} + +impl<'r> Decode<'r, Postgres> for AnyTimestamp { + fn decode(value: PgValueRef<'r>) -> Result { + decode_any_timestamp(value).map(Self) + } +} + +impl From for NaiveDateTime { + fn from(value: AnyTimestamp) -> Self { + value.0 + } +} + +impl Type for AnyTimestampOpt { + fn type_info() -> PgTypeInfo { + >::type_info() + } + + fn compatible(ty: &PgTypeInfo) -> bool { + any_timestamp_compatible(ty) + } +} + +impl<'r> Decode<'r, Postgres> for AnyTimestampOpt { + fn decode(value: PgValueRef<'r>) -> Result { + // Row::try_get ne vérifie pas le type d'une valeur NULL et la passe telle + // quelle à decode (comme pour Option) + if value.is_null() { + return Ok(Self(None)); + } + decode_any_timestamp(value).map(|timestamp| Self(Some(timestamp))) + } +} + +impl From for Option { + fn from(value: AnyTimestampOpt) -> Self { + value.0 + } +} + /// Valeur JavaScript telle que node-pg l'envoie (prepareValue) : texte, ou NULL pub fn pg_text(value: &Value) -> Option { match value { @@ -263,6 +344,19 @@ mod tests { value.as_object().cloned().unwrap() } + #[test] + fn horodatage_lu_en_timestamp_comme_en_timestamptz() { + let timestamp = >::type_info(); + let timestamptz = as Type>::type_info(); + let date = >::type_info(); + for ty in [×tamp, ×tamptz] { + assert!(>::compatible(ty)); + assert!(>::compatible(ty)); + } + assert!(!>::compatible(&date)); + assert!(!>::compatible(&date)); + } + #[test] fn valeurs_comme_node_pg() { // Valeurs de require('pg/lib/utils').prepareValue dans Node diff --git a/server-rs/src/schema.rs b/server-rs/src/schema.rs index 30b4952..6fd94ed 100644 --- a/server-rs/src/schema.rs +++ b/server-rs/src/schema.rs @@ -34,14 +34,18 @@ pub struct AvoirsRow { pub webhook_sent: Option, #[serde(rename = "nocodbVerified")] pub nocodb_verified: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "nocodbVerifiedAt", serialize_with = "crate::json::ts_opt")] pub nocodb_verified_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "processedAt", serialize_with = "crate::json::ts_opt")] pub processed_at: Option, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -173,8 +177,10 @@ pub struct ContactsRow { pub email: Option, #[serde(rename = "notes")] pub notes: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -285,6 +291,7 @@ pub struct CustomerOrdersRow { pub is_promotional_price: Option, #[serde(rename = "customerNotified")] pub customer_notified: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "notifiedAt", serialize_with = "crate::json::ts_opt")] pub notified_at: Option, #[serde(rename = "notifiedComment")] @@ -295,8 +302,10 @@ pub struct CustomerOrdersRow { pub group_id: i32, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -458,6 +467,7 @@ pub struct DashboardMessagesRow { pub store_id: Option, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, } @@ -531,6 +541,7 @@ pub struct DatabaseBackupsRow { pub description: Option, #[serde(rename = "size")] pub size: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, #[serde(rename = "createdBy")] @@ -625,6 +636,7 @@ pub struct DeliveriesRow { pub group_id: i32, #[serde(rename = "scheduledDate")] pub scheduled_date: NaiveDate, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "deliveredDate", serialize_with = "crate::json::ts_opt")] pub delivered_date: Option, #[serde(rename = "quantity")] @@ -648,22 +660,27 @@ pub struct DeliveriesRow { serialize_with = "crate::json::numeric_opt::<2, _>" )] pub invoice_amount_ttc: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "dueDate", serialize_with = "crate::json::ts_opt")] pub due_date: Option, #[serde(rename = "reconciled")] pub reconciled: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "validatedAt", serialize_with = "crate::json::ts_opt")] pub validated_at: Option, #[serde(rename = "controlValidated")] pub control_validated: Option, #[serde(rename = "controlValidatedBy")] pub control_validated_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "controlValidatedAt", serialize_with = "crate::json::ts_opt")] pub control_validated_at: Option, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -860,8 +877,10 @@ pub struct DlcProductsRow { pub stock_epuise: Option, #[serde(rename = "stockEpuiseBy")] pub stock_epuise_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "stockEpuiseAt", serialize_with = "crate::json::ts_opt")] pub stock_epuise_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "processedAt", serialize_with = "crate::json::ts_opt")] pub processed_at: Option, #[serde(rename = "processedBy")] @@ -874,10 +893,13 @@ pub struct DlcProductsRow { pub created_by: String, #[serde(rename = "validatedBy")] pub validated_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "validatedAt", serialize_with = "crate::json::ts_opt")] pub validated_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1060,10 +1082,13 @@ pub struct ExternalApiKeysRow { pub key_hash: String, #[serde(rename = "createdBy")] pub created_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "lastUsedAt", serialize_with = "crate::json::ts_opt")] pub last_used_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "revokedAt", serialize_with = "crate::json::ts_opt")] pub revoked_at: Option, } @@ -1182,8 +1207,10 @@ pub struct GroupsRow { pub smtp_sender_email: Option, #[serde(rename = "smtpSenderName")] pub smtp_sender_name: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1384,6 +1411,7 @@ pub struct InvoiceVerificationCacheRow { serialize_with = "crate::json::numeric_opt::<2, _>" )] pub invoice_amount_ttc: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "dueDate", serialize_with = "crate::json::ts_opt")] pub due_date: Option, #[serde(rename = "exists")] @@ -1398,10 +1426,13 @@ pub struct InvoiceVerificationCacheRow { pub api_call_time: Option, #[serde(rename = "isReconciled")] pub is_reconciled: Option, + #[sqlx(try_from = "crate::db::AnyTimestamp")] #[serde(rename = "expiresAt", serialize_with = "crate::json::ts")] pub expires_at: NaiveDateTime, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1542,8 +1573,10 @@ pub struct NocodbConfigRow { pub is_active: Option, #[serde(rename = "createdBy")] pub created_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1644,8 +1677,10 @@ pub struct OrdersRow { pub notes: Option, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1748,8 +1783,10 @@ pub struct PublicitiesRow { pub year: i32, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1872,8 +1909,10 @@ pub struct ReconciliationCommentsRow { pub type_: String, #[serde(rename = "authorId")] pub author_id: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -1974,12 +2013,16 @@ pub struct SavTicketsRow { pub client_phone: Option, #[serde(rename = "createdBy")] pub created_by: String, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "resolvedAt", serialize_with = "crate::json::ts_opt")] pub resolved_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "closedAt", serialize_with = "crate::json::ts_opt")] pub closed_at: Option, } @@ -2123,6 +2166,7 @@ pub struct SessionsRow { pub sid: String, #[serde(rename = "sess")] pub sess: serde_json::Value, + #[sqlx(try_from = "crate::db::AnyTimestamp")] #[serde(rename = "expire", serialize_with = "crate::json::ts")] pub expire: NaiveDateTime, } @@ -2187,6 +2231,7 @@ pub struct SupplierMailLogsRow { pub sent_by: String, #[serde(rename = "sentByName")] pub sent_by_name: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, } @@ -2303,8 +2348,10 @@ pub struct SuppliersRow { pub has_dlc: Option, #[serde(rename = "paymentMethod")] pub payment_method: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, #[serde(rename = "automaticReconciliation")] @@ -2409,6 +2456,7 @@ pub struct TasksRow { pub title: String, #[serde(rename = "description")] pub description: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "startDate", serialize_with = "crate::json::ts_opt")] pub start_date: Option, #[serde(rename = "dueDate")] @@ -2423,12 +2471,15 @@ pub struct TasksRow { pub created_by: String, #[serde(rename = "groupId")] pub group_id: i32, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "completedAt", serialize_with = "crate::json::ts_opt")] pub completed_at: Option, #[serde(rename = "completedBy")] pub completed_by: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -2539,6 +2590,7 @@ pub struct UserGroupsRow { pub user_id: String, #[serde(rename = "groupId")] pub group_id: i32, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, } @@ -2599,8 +2651,10 @@ pub struct UsersRow { pub role: String, #[serde(rename = "passwordChanged")] pub password_changed: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -2701,8 +2755,10 @@ pub struct UtilitiesRow { pub sales_analysis_url: Option, #[serde(rename = "automaticBackupsEnabled")] pub automatic_backups_enabled: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -2775,8 +2831,10 @@ pub struct WeatherDataRow { pub conditions: String, #[serde(rename = "isCurrentYear")] pub is_current_year: bool, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -2867,8 +2925,10 @@ pub struct WeatherSettingsRow { pub location: String, #[serde(rename = "isActive")] pub is_active: bool, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, } @@ -2938,8 +2998,10 @@ pub struct WebhookBapConfigRow { pub description: Option, #[serde(rename = "isActive")] pub is_active: bool, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "createdAt", serialize_with = "crate::json::ts_opt")] pub created_at: Option, + #[sqlx(try_from = "crate::db::AnyTimestampOpt")] #[serde(rename = "updatedAt", serialize_with = "crate::json::ts_opt")] pub updated_at: Option, }