diff --git a/server/src/routes/core/agenda.rs b/server/src/routes/core/agenda.rs new file mode 100644 index 0000000..618ae76 --- /dev/null +++ b/server/src/routes/core/agenda.rs @@ -0,0 +1,334 @@ +//! Agenda des publications (portage de la partie serveur de `shared/agenda.ts`) : +//! ce qui est déjà prévu, jour par jour et magasin par magasin, pour aider à +//! choisir la date d'un nouveau post ou Reel. +//! +//! Il réunit les publications planifiées (`scheduled_posts`) et les Reels encore +//! en montage, qui n'ont pas de ligne `scheduled_posts` avant la fin du rendu. + +use std::collections::HashMap; + +use serde::Serialize; +use serde_json::Value; + +use crate::db::{RenderingReelJob, ScheduledPostWithDetails}; +use crate::time::Ts; + +const PREVIEW_LENGTH: usize = 80; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AgendaStatus { + Scheduled, + Rendering, + Published, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgendaItem { + pub id: String, + pub post_id: String, + pub page_id: String, + pub page_name: String, + pub platform: String, + pub avatar_url: Option, + pub post_type: String, + /// ISO 8601. + pub scheduled_at: String, + pub status: AgendaStatus, + pub preview: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgendaPage { + pub id: String, + pub page_name: String, + pub platform: String, + pub avatar_url: Option, +} + +/// Texte d'aperçu : blancs réduits, coupé à 80 caractères (unités UTF-16). +fn preview(text: Option<&str>) -> String { + let flat = text + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" "); + if flat.encode_utf16().count() <= PREVIEW_LENGTH { + return flat; + } + let mut out = String::new(); + let mut units = 0; + for c in flat.chars() { + units += c.len_utf16(); + if units > PREVIEW_LENGTH - 1 { + break; + } + out.push(c); + } + out.push('…'); + out +} + +fn in_range(date: Ts, start: Option, end: Option) -> bool { + start.is_none_or(|s| date >= s) && end.is_none_or(|e| date <= e) +} + +/// Statut d'une publication planifiée : elle n'a pas de colonne dédiée. +pub fn scheduled_post_status(published_at: Option, error: Option<&str>) -> AgendaStatus { + if published_at.is_some() { + AgendaStatus::Published + } else if error.is_some_and(|e| !e.is_empty()) { + AgendaStatus::Failed + } else { + AgendaStatus::Scheduled + } +} + +pub fn agenda_item_from_scheduled_post(row: &ScheduledPostWithDetails) -> AgendaItem { + let sp = &row.scheduled; + AgendaItem { + id: sp.id.clone(), + post_id: sp.post_id.clone(), + page_id: sp.page_id.clone(), + page_name: row + .page + .as_ref() + .map(|p| p.page_name.clone()) + .unwrap_or_else(|| "Page supprimée".into()), + platform: row + .page + .as_ref() + .map(|p| p.platform.as_str().to_string()) + .unwrap_or_else(|| "facebook".into()), + avatar_url: row.page.as_ref().and_then(|p| p.avatar_url.clone()), + post_type: sp.post_type.as_str().to_string(), + scheduled_at: sp.scheduled_at.to_iso(), + status: scheduled_post_status(sp.published_at, sp.error.as_deref()), + preview: preview(Some(&row.post.content)), + } +} + +/// Entrées d'un Reel en montage, une par page visée et visible de l'utilisateur. +/// Sans date demandée, il part dès la fin du rendu : on le place à sa création. +pub fn agenda_items_from_reel_job( + job: &RenderingReelJob, + pages_by_id: &HashMap, + start: Option, + end: Option, +) -> Vec { + let Some(post_id) = &job.post_id else { + return Vec::new(); + }; + let params = &job.params; + let page_ids: Vec<&str> = params + .get("pageIds") + .and_then(Value::as_array) + .map(|ids| ids.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + + let requested = params + .get("scheduledFor") + .and_then(Value::as_str) + .and_then(Ts::parse); + let when = requested.unwrap_or(job.created_at); + if !in_range(when, start, end) { + return Vec::new(); + } + + let param_text = |key: &str| { + params + .get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + }; + let text = job + .content + .as_deref() + .filter(|c| !c.is_empty()) + .or_else(|| param_text("description")) + .or_else(|| param_text("overlayText")); + + page_ids + .into_iter() + .filter_map(|page_id| { + let page = pages_by_id.get(page_id)?; + Some(AgendaItem { + id: format!("{}:{page_id}", job.id), + post_id: post_id.clone(), + page_id: page_id.to_string(), + page_name: page.page_name.clone(), + platform: page.platform.clone(), + avatar_url: page.avatar_url.clone(), + post_type: "reel".into(), + scheduled_at: when.to_iso(), + status: AgendaStatus::Rendering, + preview: preview(text), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{ + Platform, PostStatus, PostType, ScheduledPost, ScheduledPostPage, ScheduledPostPost, + }; + use serde_json::json; + + fn page(id: &str, name: &str, platform: &str) -> AgendaPage { + AgendaPage { + id: id.into(), + page_name: name.into(), + platform: platform.into(), + avatar_url: None, + } + } + + fn pages_by_id() -> HashMap { + [ + page("a", "Magasin A", "facebook"), + page("b", "Magasin B", "tiktok"), + ] + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect() + } + + fn ts(s: &str) -> Ts { + Ts::parse(s).unwrap() + } + + fn row(published_at: Option, error: Option<&str>) -> ScheduledPostWithDetails { + ScheduledPostWithDetails { + scheduled: ScheduledPost { + id: "s".into(), + post_id: "p".into(), + page_id: "a".into(), + post_type: PostType::Reel, + scheduled_at: ts("2026-10-14T18:00:00Z"), + published_at, + external_post_id: None, + error: error.map(str::to_string), + publish_id: None, + publish_status: None, + }, + post: ScheduledPostPost { + id: "p".into(), + user_id: "u".into(), + content: "Promo".into(), + status: PostStatus::Scheduled, + scheduled_for: None, + ai_generated: "false".into(), + generation_status: None, + generation_progress: None, + created_at: None, + }, + page: Some(ScheduledPostPage { + id: "a".into(), + page_id: "fb-a".into(), + page_name: "Magasin A".into(), + platform: Platform::Facebook, + avatar_url: None, + }), + } + } + + fn job(params: Value, created_at: Ts) -> RenderingReelJob { + RenderingReelJob { + id: "j".into(), + post_id: Some("p".into()), + created_at, + params, + content: None, + } + } + + #[test] + fn status_from_publication_columns() { + assert_eq!( + agenda_item_from_scheduled_post(&row(None, None)).status, + AgendaStatus::Scheduled + ); + assert_eq!( + agenda_item_from_scheduled_post(&row(Some(Ts::now()), None)).status, + AgendaStatus::Published + ); + assert_eq!( + agenda_item_from_scheduled_post(&row(None, Some("boom"))).status, + AgendaStatus::Failed + ); + let item = agenda_item_from_scheduled_post(&row(None, None)); + assert_eq!(item.preview, "Promo"); + assert_eq!(item.scheduled_at, "2026-10-14T18:00:00.000Z"); + let mut orphan = row(None, None); + orphan.page = None; + let item = agenda_item_from_scheduled_post(&orphan); + assert_eq!( + (item.page_name.as_str(), item.platform.as_str()), + ("Page supprimée", "facebook") + ); + } + + #[test] + fn one_rendering_entry_per_visible_store() { + let when = ts("2026-10-14T18:00:00Z"); + let items = agenda_items_from_reel_job( + &job( + json!({ "pageIds": ["a", "b", "inconnu"], "scheduledFor": when.to_iso() }), + Ts::now(), + ), + &pages_by_id(), + None, + None, + ); + assert_eq!( + items.iter().map(|i| i.page_id.as_str()).collect::>(), + ["a", "b"] + ); + assert!(items + .iter() + .all(|i| i.status == AgendaStatus::Rendering && i.post_type == "reel")); + assert_eq!(items[0].scheduled_at, when.to_iso()); + assert_eq!(items[0].id, "j:a"); + } + + #[test] + fn reel_without_date_is_placed_at_creation_and_respects_period() { + let created_at = ts("2026-10-12T09:00:00Z"); + let j = job(json!({ "pageIds": ["a"] }), created_at); + assert_eq!( + agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].scheduled_at, + created_at.to_iso() + ); + assert!(agenda_items_from_reel_job( + &j, + &pages_by_id(), + Some(ts("2026-10-13T00:00:00Z")), + None + ) + .is_empty()); + let mut no_post = j.clone(); + no_post.post_id = None; + assert!(agenda_items_from_reel_job(&no_post, &pages_by_id(), None, None).is_empty()); + } + + #[test] + fn preview_text() { + let j = job( + json!({ "pageIds": ["a"], "description": " Belle\n promo " }), + Ts::now(), + ); + assert_eq!( + agenda_items_from_reel_job(&j, &pages_by_id(), None, None)[0].preview, + "Belle promo" + ); + let long = "x".repeat(100); + let p = preview(Some(&long)); + assert_eq!(p.chars().count(), 80); + assert!(p.ends_with('…')); + assert_eq!(preview(Some(&"y".repeat(80))), "y".repeat(80)); + } +} diff --git a/server/src/routes/core/ai.rs b/server/src/routes/core/ai.rs new file mode 100644 index 0000000..921073f --- /dev/null +++ b/server/src/routes/core/ai.rs @@ -0,0 +1,187 @@ +//! Génération de textes par IA et configuration d'OpenRouter. + +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Issues, Presence}; +use crate::auth::{AdminUser, AuthUser}; +use crate::db::Patch; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::Body; +use crate::services::openrouter; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/ai/models", get(models)) + .route("/api/ai/generate", post(generate)) + .route("/api/ai/generations", get(generations)) + .route("/api/openrouter/models", get(openrouter_models)) + .route("/api/openrouter/config", get(get_config).post(save_config)) +} + +async fn models(State(state): State, _admin: AdminUser) -> ApiResult> { + let models = openrouter::get_available_models(&state) + .await + .or_500("Failed to fetch models")?; + Ok(Json(json!({ "models": models }))) +} + +async fn generate( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + // `{ model, ...productInfo } = req.body` + let mut product_info = match body { + Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + let model = product_info.remove("model"); + let model = model.as_ref().and_then(Value::as_str); + let product_info = Value::Object(product_info); + + let result = async { + let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model).await?; + state + .db + .create_ai_generation(&user.id, &product_info, &Value::Array(texts.clone())) + .await?; + anyhow::Ok(texts) + } + .await; + + match result { + Ok(texts) => Ok(Json(json!({ "variants": texts }))), + Err(error) => { + tracing::error!("Error generating text: {error:#}"); + let (status, message) = openrouter::describe_generation_error(&error); + Err(AppError::new(status, message)) + } + } +} + +async fn generations(State(state): State, admin: AdminUser) -> ApiResult> { + let generations = state + .db + .get_ai_generations(&admin.id) + .await + .or_500("Failed to fetch AI generations")?; + Ok(Json(json!(generations))) +} + +/// Catalogue brut des modèles OpenRouter, relayé tel quel. +async fn openrouter_models( + State(state): State, + _user: AuthUser, +) -> ApiResult> { + let result = async { + let response = state + .http + .get("https://openrouter.ai/api/v1/models") + .send() + .await?; + if !response.status().is_success() { + anyhow::bail!("OpenRouter API error: {}", response.status().as_u16()); + } + Ok(response.json::().await?) + } + .await; + result.or_500("Failed to fetch OpenRouter models").map(Json) +} + +async fn get_config(State(state): State, admin: AdminUser) -> ApiResult> { + let config = state + .db + .get_openrouter_config(&admin.id) + .await + .or_500("Failed to fetch OpenRouter config")?; + // La clé API n'est jamais sérialisée + Ok(Json(json!(config))) +} + +async fn save_config( + State(state): State, + admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save OpenRouter config"; + let failed = |reason: String| { + tracing::error!("Error saving OpenRouter config: {reason}"); + AppError::internal(FAILED) + }; + + let existing = state + .db + .get_openrouter_config(&admin.id) + .await + .or_500(FAILED)?; + + // Schéma zod : clé API « trim().min(1) », modèle et prompt texte facultatifs + let mut issues = Issues::new(); + let presence = if existing.is_some() { + Presence::Optional + } else { + Presence::Required + }; + let api_key = issues + .string(&body, "apiKey", presence) + .into_option() + .map(|k| k.trim().to_string()); + if api_key.as_deref() == Some("") { + return Err(failed("apiKey : La clé API ne peut pas être vide".into())); + } + let model = issues + .string(&body, "model", Presence::Optional) + .into_option(); + let system_prompt = issues + .string(&body, "systemPrompt", Presence::Optional) + .into_option(); + if !issues.is_empty() { + return Err(failed(issues.zod_message())); + } + + let config = match existing { + Some(existing) => { + let api_key = api_key + .filter(|k| !k.is_empty()) + .unwrap_or(existing.api_key); + if api_key.is_empty() { + return Err(AppError::bad_request( + "Clé API OpenRouter manquante. Veuillez entrer une clé API valide.", + )); + } + let mut patch = Patch::new() + .set("user_id", admin.id.as_str()) + .set("api_key", api_key); + if let Some(model) = model { + patch = patch.set("model", model); + } + if let Some(prompt) = system_prompt { + patch = patch.set("system_prompt", prompt); + } + state + .db + .update_openrouter_config(&admin.id, patch) + .await + .map_err(|e| failed(e.to_string()))? + .ok_or_else(|| failed("configuration introuvable".into()))? + } + None => { + let api_key = api_key.unwrap_or_default(); + state + .db + .create_openrouter_config( + &admin.id, + &api_key, + model.as_deref(), + system_prompt.as_deref(), + ) + .await + .map_err(|e| failed(e.to_string()))? + } + }; + Ok(Json(json!(config))) +} diff --git a/server/src/routes/core/audio.rs b/server/src/routes/core/audio.rs new file mode 100644 index 0000000..0ad3107 --- /dev/null +++ b/server/src/routes/core/audio.rs @@ -0,0 +1,168 @@ +//! Bibliothèque de pistes audio (envoi réservé aux administrateurs). +//! Les fichiers sont rangés directement dans le dossier audio persistant, +//! servi sous `/uploads/audio/`. + +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; + +use crate::auth::{AdminUser, AuthUser}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::services::audio::{fix_audio_track_encoding, probe_duration_secs}; +use crate::state::AppState; +use crate::upload::{self, UploadRules}; + +const ALLOWED_AUDIO_MIME_TYPES: [&str; 4] = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]; + +/// 50 Mo par fichier, 20 fichiers par envoi. +const MAX_AUDIO_SIZE: u64 = 50 * 1024 * 1024; +const MAX_AUDIO_FILES: usize = 20; + +pub fn router() -> Router { + Router::new() + .route( + "/api/audio-tracks", + get(list_tracks) + .post(upload_tracks) + .layer(DefaultBodyLimit::disable()), + ) + .route("/api/audio-tracks/fix-encoding", post(fix_encoding)) + .route("/api/audio-tracks/{id}", delete(delete_track)) +} + +fn audio_filter(mime: &str, name: &str) -> Result<(), String> { + static AUDIO_EXT: Lazy = Lazy::new(|| Regex::new(r"(?i)\.(mp3|wav|ogg)$").unwrap()); + if ALLOWED_AUDIO_MIME_TYPES.contains(&mime) || AUDIO_EXT.is_match(name) { + Ok(()) + } else { + Err(format!("Type de fichier audio non autorisé: {mime}")) + } +} + +/// Titre d'une piste : nom du fichier sans son extension. +fn title_from_file_name(name: &str) -> &str { + match name.rfind('.') { + Some(pos) if !name[pos + 1..].is_empty() && !name[pos + 1..].contains('/') => &name[..pos], + _ => name, + } +} + +async fn upload_tracks( + State(state): State, + admin: AdminUser, + multipart: Multipart, +) -> ApiResult> { + let dir = state.config.audio_upload_dir.clone(); + let (files, _) = upload::receive_files( + multipart, + UploadRules { + fields: &["files"], + dir: &dir, + max_file_size: MAX_AUDIO_SIZE, + max_files: MAX_AUDIO_FILES, + filter: &audio_filter, + }, + ) + .await + .map_err(super::upload_error)?; + + if files.is_empty() { + return Err(AppError::bad_request("No audio file uploaded")); + } + + let mut results = Vec::with_capacity(files.len()); + for file in &files { + tracing::info!( + "🎵 Audio saved locally: {} ({:.2} MB)", + file.original_name, + file.size as f64 / 1024.0 / 1024.0 + ); + let url = format!("/uploads/audio/{}", file.file_name); + let duration = probe_duration_secs(&file.path).await; + let created = state + .db + .create_audio_track( + Some(&admin.id), + title_from_file_name(&file.original_name), + &file.original_name, + &url, + duration, + ) + .await; + match created { + Ok(track) => results.push(json!({ "success": true, "track": track })), + Err(error) => { + // Fichier retiré si l'enregistrement en base échoue + let _ = tokio::fs::remove_file(&file.path).await; + results.push(json!({ + "success": false, + "fileName": file.original_name, + "error": super::pg_message(&error), + })); + } + } + } + Ok(Json(json!({ "results": results }))) +} + +async fn fix_encoding(State(state): State, _admin: AdminUser) -> ApiResult> { + fix_audio_track_encoding(&state).await; + let tracks = state + .db + .get_audio_tracks() + .await + .or_500("Failed to fix encoding")?; + Ok(Json(json!({ "success": true, "tracks": tracks }))) +} + +/// Accessible à tous : les pistes servent à habiller les Reels. +async fn list_tracks(State(state): State, _user: AuthUser) -> ApiResult> { + let tracks = state + .db + .get_audio_tracks() + .await + .or_500("Failed to fetch audio tracks")?; + Ok(Json(json!(tracks))) +} + +async fn delete_track( + State(state): State, + _admin: AdminUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete audio track"; + if state + .db + .get_audio_track(&id) + .await + .or_500(FAILED)? + .is_none() + { + return Err(AppError::not_found("Audio track not found")); + } + // Comme avant, le fichier local est conservé : seule la piste est retirée + state.db.delete_audio_track(&id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titles_and_filter() { + assert_eq!(title_from_file_name("Été 2024.mp3"), "Été 2024"); + assert_eq!(title_from_file_name("a.b.wav"), "a.b"); + assert_eq!(title_from_file_name("sans_extension"), "sans_extension"); + assert_eq!(title_from_file_name("fin."), "fin."); + assert!(audio_filter("application/octet-stream", "x.OGG").is_ok()); + assert!(audio_filter("audio/mpeg", "x").is_ok()); + assert_eq!( + audio_filter("video/mp4", "x.mp4").unwrap_err(), + "Type de fichier audio non autorisé: video/mp4" + ); + } +} diff --git a/server/src/routes/core/configs.rs b/server/src/routes/core/configs.rs new file mode 100644 index 0000000..8f8a4f4 --- /dev/null +++ b/server/src/routes/core/configs.rs @@ -0,0 +1,253 @@ +//! Configurations globales : stockage des médias (ex-Cloudinary) et logo de +//! l'entreprise, service FFmpeg. + +use axum::extract::{DefaultBodyLimit, Multipart, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::{AdminUser, AuthUser}; +use crate::db::{CloudinaryConfig, Patch, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body}; +use crate::services::{ffmpeg, media_store}; +use crate::state::AppState; +use crate::upload; + +pub fn router() -> Router { + Router::new() + .route( + "/api/cloudinary/config", + get(get_storage_config).post(save_storage_config), + ) + .route( + "/api/cloudinary/logo", + post(upload_logo) + .delete(delete_logo) + .layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/ffmpeg/config", + get(get_ffmpeg_config).post(save_ffmpeg_config), + ) +} + +/// Configuration sans le secret, avec l'URL du logo. +async fn get_storage_config( + State(state): State, + _user: AuthUser, +) -> ApiResult> { + let Some(config) = state + .db + .get_cloudinary_config() + .await + .or_500("Failed to fetch Cloudinary config")? + else { + return Ok(Json(Value::Null)); + }; + let logo_url = config + .logo_public_id + .as_deref() + .filter(|id| !id.is_empty()) + .map(media_store::build_url); + let mut value = json!(config); + if let Value::Object(map) = &mut value { + map.insert("logoUrl".into(), json!(logo_url)); + } + Ok(Json(value)) +} + +fn set_optional_text(patch: Patch, column: &'static str, field: Field) -> Patch { + match field { + Field::Absent => patch, + Field::Null => patch.set(column, Val::Text(None)), + Field::Set(value) => patch.set(column, value), + } +} + +async fn save_storage_config( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save Cloudinary config"; + let existing = state.db.get_cloudinary_config().await.or_500(FAILED)?; + + // Mise à jour : clé et secret facultatifs (les anciens sont conservés) + let secrets = if existing.is_some() { + Presence::Optional + } else { + Presence::Required + }; + let mut issues = Issues::new(); + let cloud_name = issues.string(&body, "cloudName", Presence::Required); + let api_key = issues.string(&body, "apiKey", secrets); + let api_secret = issues.string(&body, "apiSecret", secrets); + let logo_public_id = issues.string(&body, "logoPublicId", Presence::Nullable); + let endpoint_url = issues.string(&body, "endpointUrl", Presence::Nullable); + let public_url = issues.string(&body, "publicUrl", Presence::Nullable); + if !issues.is_empty() { + tracing::error!("Error saving Cloudinary config: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + let cloud_name = cloud_name.into_option().unwrap_or_default(); + + let config: CloudinaryConfig = match existing { + Some(existing) => { + let api_key = api_key + .into_option() + .filter(|k| !k.is_empty()) + .unwrap_or(existing.api_key); + let api_secret = api_secret + .into_option() + .filter(|s| !s.is_empty()) + .unwrap_or(existing.api_secret); + let mut patch = Patch::new() + .set("cloud_name", cloud_name) + .set("api_key", api_key) + .set("api_secret", api_secret); + patch = set_optional_text(patch, "logo_public_id", logo_public_id); + patch = set_optional_text(patch, "endpoint_url", endpoint_url); + patch = set_optional_text(patch, "public_url", public_url); + state + .db + .update_cloudinary_config(patch) + .await + .or_500(FAILED)? + } + None => state + .db + .create_cloudinary_config( + &cloud_name, + &api_key.into_option().unwrap_or_default(), + &api_secret.into_option().unwrap_or_default(), + logo_public_id.into_option().as_deref(), + endpoint_url.into_option().as_deref(), + public_url.into_option().as_deref(), + ) + .await + .or_500(FAILED)?, + }; + // Le secret n'est jamais sérialisé + Ok(Json(json!(config))) +} + +async fn upload_logo( + State(state): State, + _admin: AdminUser, + multipart: Multipart, +) -> ApiResult> { + let files = super::media::receive_media(multipart, "logo").await?; + let Some(file) = files.first().cloned() else { + return Err(AppError::bad_request("No logo file uploaded")); + }; + let result = store_logo(&state, &file).await; + upload::cleanup(&files).await; + match result { + Ok(value) => Ok(Json(value)), + Err(error) => { + tracing::error!("Error uploading logo: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +async fn store_logo(state: &AppState, file: &upload::UploadedFile) -> anyhow::Result { + // Le stockage local n'a pas besoin d'identifiants : ligne créée au besoin + let config = match state.db.get_cloudinary_config().await? { + Some(config) => config, + None => { + state + .db + .create_cloudinary_config("local", "local", "local", None, None, None) + .await? + } + }; + if let Some(old) = config.logo_public_id.as_deref().filter(|id| !id.is_empty()) { + if let Err(error) = media_store::delete_logo(state, old).await { + tracing::warn!("Failed to delete old logo: {error}"); + } + } + + let data = tokio::fs::read(&file.path).await?; + let _ = tokio::fs::remove_file(&file.path).await; + let (public_id, url) = media_store::upload_logo(state, &data, &file.original_name).await?; + state + .db + .update_cloudinary_config(Patch::new().set("logo_public_id", public_id.as_str())) + .await?; + Ok(json!({ "logoPublicId": public_id, "logoUrl": url })) +} + +async fn delete_logo(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_cloudinary_config().await.or_500_cause()?; + let Some(public_id) = config + .and_then(|c| c.logo_public_id) + .filter(|id| !id.is_empty()) + else { + return Err(AppError::not_found("No logo found")); + }; + media_store::delete_logo(&state, &public_id) + .await + .or_500_cause()?; + state + .db + .update_cloudinary_config(Patch::new().set("logo_public_id", Val::Text(None))) + .await + .or_500_cause()?; + Ok(Json(json!({ "success": true }))) +} + +async fn get_ffmpeg_config(State(state): State, _admin: AdminUser) -> Json { + let settings = state.ffmpeg.read().await; + let api_url = settings.api_url.clone().unwrap_or_default(); + let configured = + !api_url.is_empty() && settings.api_key.as_deref().is_some_and(|k| !k.is_empty()); + Json(json!({ "apiUrl": api_url, "configured": configured })) +} + +/// Reconfigure le service FFmpeg pour la durée de vie du serveur, puis le teste. +async fn save_ffmpeg_config( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to save FFmpeg config"; + let api_url = body.get("apiUrl"); + if !is_truthy(api_url) { + return Err(AppError::bad_request("URL de l'API FFmpeg requise")); + } + let Some(api_url) = api_url.and_then(Value::as_str).map(str::to_string) else { + return Err(AppError::internal(FAILED)); + }; + + { + let mut settings = state.ffmpeg.write().await; + let api_key = match body.get("apiKey").filter(|k| is_truthy(Some(k))) { + Some(Value::String(key)) => key.clone(), + Some(_) => return Err(AppError::internal(FAILED)), + // Clé déjà configurée : on la garde + None => match settings.api_key.clone().filter(|k| !k.is_empty()) { + Some(key) => key, + None => { + return Err(AppError::bad_request( + "Clé API FFmpeg requise pour la première configuration", + )) + } + }, + }; + let url = api_url.strip_suffix('/').unwrap_or(&api_url).to_string(); + tracing::info!("🎬 FFmpeg Service configured: {url}"); + settings.api_url = Some(url); + settings.api_key = Some(api_key); + } + + let healthy = ffmpeg::health_check(&state).await; + Ok(Json(json!({ + "success": true, + "apiUrl": api_url, + "configured": true, + "healthy": healthy, + }))) +} diff --git a/server/src/routes/core/media.rs b/server/src/routes/core/media.rs new file mode 100644 index 0000000..fac8d25 --- /dev/null +++ b/server/src/routes/core/media.rs @@ -0,0 +1,247 @@ +//! Médiathèque : envoi, liste paginée, suppression. + +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::http::HeaderValue; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::db::{MediaType, NewMedia}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::QueryMap; +use crate::services::media_store::{self, MediaSource}; +use crate::services::thumbnail::{self, ThumbnailSource}; +use crate::state::AppState; +use crate::upload::{self, UploadRules, UploadedFile}; + +/// Types MIME autorisés pour les médias. +const ALLOWED_MIME_TYPES: [&str; 7] = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + "video/quicktime", + "video/webm", +]; + +/// 4 Go par fichier. +const MAX_MEDIA_SIZE: u64 = 4 * 1024 * 1024 * 1024; + +pub fn router() -> Router { + Router::new() + .route( + "/api/media/upload", + post(upload_media).layer(DefaultBodyLimit::disable()), + ) + .route("/api/media/fix-urls", post(fix_urls)) + .route("/api/media", get(list_media)) + .route("/api/media/{id}", delete(delete_media)) +} + +/// Filtre des médias (iOS envoie parfois `application/octet-stream`). +fn media_filter(mime: &str, _name: &str) -> Result<(), String> { + if ALLOWED_MIME_TYPES.contains(&mime) || mime == "application/octet-stream" { + Ok(()) + } else { + Err(format!("Type de fichier non autorisé: {mime}")) + } +} + +/// Réception d'un fichier média ou logo dans le dossier temporaire du système +/// (4 Go, 10 fichiers au plus) ; erreurs au format de multer. +pub(super) async fn receive_media( + multipart: Multipart, + field: &str, +) -> Result, AppError> { + let dir = std::env::temp_dir(); + let fields = [field]; + let (files, _) = upload::receive_files( + multipart, + UploadRules { + fields: &fields, + dir: &dir, + max_file_size: MAX_MEDIA_SIZE, + max_files: 10, + filter: &media_filter, + }, + ) + .await + .map_err(super::upload_error)?; + Ok(files) +} + +/// Vidéo d'après le type MIME ou l'extension. +fn is_video(mime: &str, name: &str) -> bool { + static VIDEO_EXT: Lazy = + Lazy::new(|| Regex::new(r"(?i)\.(mp4|mov|avi|mkv|webm)$").unwrap()); + mime.starts_with("video/") || VIDEO_EXT.is_match(name) +} + +async fn upload_media( + State(state): State, + user: AuthUser, + multipart: Multipart, +) -> ApiResult> { + let files = receive_media(multipart, "file").await?; + let Some(file) = files.first().cloned() else { + return Err(AppError::bad_request("No file uploaded")); + }; + + let result = store_upload(&state, &user.id, &file).await; + // Fichier temporaire supprimé dans tous les cas + upload::cleanup(&files).await; + match result { + Ok(media) => Ok(Json(json!(media))), + Err(error) => { + tracing::error!("Error uploading media: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +async fn store_upload( + state: &AppState, + user_id: &str, + file: &UploadedFile, +) -> anyhow::Result { + tracing::info!( + "📂 Processing upload: {} ({:.2} MB)", + file.original_name, + file.size as f64 / 1024.0 / 1024.0 + ); + let stored = + media_store::upload_media(state, MediaSource::Path(&file.path), &file.original_name) + .await?; + let video = is_video(&file.mime, &file.original_name); + + // Vignette extraite tant que la vidéo est sur le disque : elle survit à la + // suppression du fichier après publication (historique illustré) + let thumbnail_url = if video { + thumbnail::create_video_thumbnail(state, ThumbnailSource::Path(&file.path)).await + } else { + None + }; + + let file_size = i32::try_from(file.size) + .map_err(|_| anyhow::anyhow!("value \"{}\" is out of range for type integer", file.size))?; + let media = state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(if video { + MediaType::Video + } else { + MediaType::Image + }), + cloudinary_public_id: Some(stored.public_id), + original_url: stored.original_url, + facebook_landscape_url: None, + facebook_feed_url: stored.facebook_feed_url, + instagram_feed_url: stored.instagram_feed_url, + instagram_story_url: stored.instagram_story_url, + thumbnail_url, + file_name: file.original_name.clone(), + file_size, + }) + .await?; + Ok(media) +} + +/// Migration ponctuelle : URL absolues `http(s)://hôte/uploads/…` → relatives. +async fn fix_urls(State(state): State, user: AuthUser) -> ApiResult> { + if !user.is_admin() { + return Err(AppError::forbidden("Admin only")); + } + let result = sqlx::query( + r#"UPDATE media SET + original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\1'), + facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\1'), + instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\1'), + instagram_story_url = regexp_replace(instagram_story_url, '^https?://[^/]+(/uploads/)', '\1') + WHERE + original_url ~ '^https?://[^/]+/uploads/' + OR facebook_feed_url ~ '^https?://[^/]+/uploads/' + OR instagram_feed_url ~ '^https?://[^/]+/uploads/' + OR instagram_story_url ~ '^https?://[^/]+/uploads/'"#, + ) + .execute(&state.db.pool) + .await + .or_500("Migration failed")?; + Ok(Json(json!({ "updated": result.rows_affected() }))) +} + +/// Sans `limit`, toute la médiathèque est renvoyée (les écrans qui retrouvent +/// un média par son identifiant en dépendent) ; avec, `X-Total-Count` donne le total. +async fn list_media( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult { + const FAILED: &str = "Failed to fetch media"; + let limit = super::parse_positive_int(query.get("limit")); + let offset = super::parse_positive_int(query.get("offset")); + let media = state + .db + .get_media(&user.id, limit, offset) + .await + .or_500(FAILED)?; + + let mut response = Json(json!(media)).into_response(); + if limit.is_some() { + let total = state.db.get_media_count(&user.id).await.or_500(FAILED)?; + if let Ok(value) = HeaderValue::from_str(&total.to_string()) { + response.headers_mut().insert("X-Total-Count", value); + } + } + Ok(response) +} + +async fn delete_media( + State(state): State, + user: AuthUser, + Path(media_id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete media"; + let media = state + .db + .get_media_by_id(&media_id) + .await + .or_500(FAILED)? + .filter(|m| m.user_id == user.id) + .ok_or_else(|| AppError::not_found("Media not found"))?; + + if let Some(public_id) = media + .cloudinary_public_id + .as_deref() + .filter(|p| !p.is_empty()) + { + media_store::delete_media(&state, public_id) + .await + .or_500(FAILED)?; + } + state.db.delete_media(&media_id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn video_detection_and_filter() { + assert!(is_video("video/mp4", "a.bin")); + assert!(is_video("application/octet-stream", "clip.MOV")); + assert!(!is_video("image/png", "a.png")); + assert!(media_filter("image/png", "a.png").is_ok()); + assert!(media_filter("application/octet-stream", "a").is_ok()); + assert_eq!( + media_filter("application/pdf", "a.pdf").unwrap_err(), + "Type de fichier non autorisé: application/pdf" + ); + } +} diff --git a/server/src/routes/core/mod.rs b/server/src/routes/core/mod.rs index dc02efc..223f961 100644 --- a/server/src/routes/core/mod.rs +++ b/server/src/routes/core/mod.rs @@ -1,9 +1,128 @@ -//! STUB — routes principales à porter depuis `server/routes.ts`. +//! Routes principales (portage des routes définies directement dans +//! `server/routes.ts`) : utilisateurs, paramètres, console SQL, statistiques, +//! IA, médias, pistes audio, posts, publications planifiées, pages et +//! configurations. +mod agenda; +mod ai; +mod audio; +mod configs; +mod media; +mod overlays; +mod pages; +mod posts; +mod scheduled; +mod settings; +mod sql; +mod stats; +mod users; +mod validate; + +use axum::http::StatusCode; use axum::Router; +use serde_json::{json, Value}; +use crate::error::AppError; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + .merge(users::router()) + .merge(settings::router()) + .merge(sql::router()) + .merge(stats::router()) + .merge(ai::router()) + .merge(media::router()) + .merge(overlays::router()) + .merge(audio::router()) + .merge(posts::router()) + .merge(scheduled::router()) + .merge(pages::router()) + .merge(configs::router()) +} + +/// `Number.parseInt(value, 10)` limité aux entiers positifs : `None` si la +/// valeur est absente, vide, illisible ou négative. +pub(crate) fn parse_positive_int(value: Option<&str>) -> Option { + let value = value.filter(|v| !v.is_empty())?; + // parseInt ignore les blancs de tête et s'arrête au premier caractère non numérique + let rest = value.trim_start(); + let (negative, digits) = match rest.as_bytes().first() { + Some(b'-') => (true, &rest[1..]), + Some(b'+') => (false, &rest[1..]), + _ => (false, rest), + }; + let end = digits.bytes().take_while(u8::is_ascii_digit).count(); + if end == 0 { + return None; + } + let parsed: i64 = digits[..end].parse().unwrap_or(i64::MAX); + if negative && parsed != 0 { + return None; + } + Some(parsed) +} + +/// Paramètre de date (`new Date(value)`), ignoré s'il est vide ou illisible. +pub(crate) fn parse_date_param(value: Option<&str>) -> Option { + value.filter(|v| !v.is_empty()).and_then(Ts::parse) +} + +/// Erreur de réception multer : elle passait par le gestionnaire d'erreurs +/// d'Express, qui répondait `500 { message }`. +pub(crate) fn upload_error(error: AppError) -> AppError { + let message = error + .body + .get("error") + .and_then(Value::as_str) + .unwrap_or("Internal Server Error"); + // multer : « Unexpected field » sans le nom du champ + let message = if message.starts_with("Unexpected field") { + "Unexpected field".to_string() + } else { + message.to_string() + }; + AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "message": message }), + ) +} + +/// Message d'une erreur sqlx tel que l'aurait donné le pilote `pg` +/// (message PostgreSQL brut pour une erreur de la base). +pub(crate) fn pg_message(error: &sqlx::Error) -> String { + match error { + sqlx::Error::Database(db) => db.message().to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positive_int_like_parse_int() { + assert_eq!(parse_positive_int(None), None); + assert_eq!(parse_positive_int(Some("")), None); + assert_eq!(parse_positive_int(Some("12")), Some(12)); + assert_eq!(parse_positive_int(Some(" 7abc")), Some(7)); + assert_eq!(parse_positive_int(Some("1e3")), Some(1)); + assert_eq!(parse_positive_int(Some("-3")), None); + assert_eq!(parse_positive_int(Some("-0")), Some(0)); + assert_eq!(parse_positive_int(Some("abc")), None); + } + + #[test] + fn date_param() { + assert!(parse_date_param(Some("")).is_none()); + assert!(parse_date_param(Some("nope")).is_none()); + assert_eq!( + parse_date_param(Some("2026-10-01T00:00:00.000Z")) + .unwrap() + .to_iso(), + "2026-10-01T00:00:00.000Z" + ); + } } diff --git a/server/src/routes/core/overlays.rs b/server/src/routes/core/overlays.rs new file mode 100644 index 0000000..f3c5506 --- /dev/null +++ b/server/src/routes/core/overlays.rs @@ -0,0 +1,611 @@ +//! Incrustations sur une image (bandeau d'angle, pastille de prix, logo), +//! calculées côté serveur puis enregistrées comme un nouveau média. +//! +//! Remplace le traitement sharp de l'ancienne route : les éléments vectoriels +//! sont décrits en SVG et rendus avec resvg, puis composés avec `image`. + +use std::io::Cursor; +use std::path::Path; +use std::sync::{Arc, OnceLock}; + +use axum::extract::State; +use axum::routing::post; +use axum::{Json, Router}; +use image::codecs::jpeg::JpegEncoder; +use image::{DynamicImage, ImageDecoder, ImageReader, RgbaImage}; +use resvg::{tiny_skia, usvg}; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::db::{MediaType, NewMedia}; +use crate::error::{ApiResult, AppError}; +use crate::extract::{is_truthy, Body, JsonExt}; +use crate::services::media_store::{self, MediaSource}; +use crate::state::AppState; + +const RED: &str = "#dc2626"; +const YELLOW: &str = "#eab308"; +const RIBBON_SIZE: u32 = 150; +const BADGE_PADDING: f64 = 16.0; +const LOGO_PADDING: i64 = 20; + +pub fn router() -> Router { + Router::new().route("/api/media/apply-overlays", post(apply_overlays)) +} + +async fn apply_overlays( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + if !body.truthy("imageUrl") { + return Err(AppError::bad_request("Image URL required")); + } + match process(&state, &user.id, &body).await { + Ok(media) => Ok(Json(media)), + Err(error) => { + tracing::error!("💥 Error applying overlays: {error:#}"); + Err(AppError::internal(error.to_string())) + } + } +} + +/// Paramètres d'incrustation lus dans la requête. +#[derive(Debug, Clone, Default)] +struct OverlaySpec { + ribbon: Option, + badge: Option, + logo: Option, +} + +#[derive(Debug, Clone)] +struct Ribbon { + text: String, + color: &'static str, + position: String, +} + +#[derive(Debug, Clone)] +struct PriceBadge { + text: String, + font_size: f64, + color: &'static str, + position: String, +} + +#[derive(Debug, Clone)] +struct LogoSpec { + data: Vec, + size: String, + position: String, + /// Opacité en pourcentage ; `None` = logo opaque. + opacity: Option, +} + +fn color_of(value: &Value) -> &'static str { + if value.str_field("color") == Some("red") { + RED + } else { + YELLOW + } +} + +/// Texte affiché d'une valeur JSON (`${value}` en JavaScript). +fn display(value: Option<&Value>) -> String { + match value { + None => "undefined".into(), + Some(Value::String(s)) => s.clone(), + Some(Value::Null) => "null".into(), + Some(other) => other.to_string(), + } +} + +async fn process(state: &AppState, user_id: &str, body: &Value) -> anyhow::Result { + let image_url = display(body.get("imageUrl")); + tracing::info!("🎨 Applying overlays server-side..."); + tracing::info!("📥 Image URL: {image_url}"); + + let image = media_store::read_media_buffer(state, &image_url).await?; + tracing::info!("✅ Image downloaded: {} bytes", image.len()); + + let mut spec = OverlaySpec::default(); + if let Some(ribbon) = body.get("ribbon").filter(|r| is_truthy(Some(r))) { + let Some(text) = ribbon.str_field("text") else { + anyhow::bail!("Cannot read properties of undefined (reading 'length')"); + }; + spec.ribbon = Some(Ribbon { + text: text.to_string(), + color: color_of(ribbon), + position: ribbon.str_field("position").unwrap_or_default().to_string(), + }); + } + if let Some(badge) = body.get("priceBadge").filter(|b| is_truthy(Some(b))) { + spec.badge = Some(PriceBadge { + text: format!("{} €", display(badge.get("price"))), + font_size: badge.get("size").and_then(Value::as_f64).unwrap_or(20.0), + color: color_of(badge), + position: badge.str_field("position").unwrap_or_default().to_string(), + }); + } + if let Some(logo) = body.get("logo").filter(|l| l.truthy("enabled")) { + spec.logo = load_logo(state, logo).await; + } + + let fonts_dir = state.config.fonts_dir.clone(); + let output = tokio::task::spawn_blocking(move || render(&image, &spec, &fonts_dir)).await??; + + tracing::info!("✅ Overlays applied, uploading to local storage..."); + let file_name = format!("edited_{}.jpg", chrono::Utc::now().timestamp_millis()); + let stored = media_store::upload_media(state, MediaSource::Bytes(&output), &file_name).await?; + + // Image retouchée : toutes les déclinaisons pointent sur l'original incrusté + let media = state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(MediaType::Image), + cloudinary_public_id: Some(stored.public_id), + original_url: stored.original_url.clone(), + facebook_landscape_url: None, + facebook_feed_url: Some(stored.original_url.clone()), + instagram_feed_url: Some(stored.original_url.clone()), + instagram_story_url: Some(stored.original_url), + thumbnail_url: None, + file_name, + file_size: i32::try_from(output.len()).unwrap_or(i32::MAX), + }) + .await?; + tracing::info!("✅ Image saved with ID: {}", media.id); + Ok(json!(media)) +} + +/// Logo de l'entreprise configuré dans les paramètres ; `None` (sans faire +/// échouer la requête) s'il est absent ou illisible. +async fn load_logo(state: &AppState, logo: &Value) -> Option { + tracing::info!("🏢 Adding logo overlay..."); + let config = match state.db.get_cloudinary_config().await { + Ok(config) => config, + Err(error) => { + tracing::error!("⚠️ Failed to apply logo: {error}"); + return None; + } + }; + let Some(public_id) = config + .and_then(|c| c.logo_public_id) + .filter(|p| !p.is_empty()) + else { + tracing::info!("⚠️ No logo configured in settings"); + return None; + }; + let url = media_store::build_url(&public_id); + tracing::info!("📥 Downloading logo from: {url}"); + match media_store::read_media_buffer(state, &url).await { + Ok(data) => Some(LogoSpec { + data: data.to_vec(), + size: logo.str_field("size").unwrap_or("medium").to_string(), + position: logo.str_field("position").unwrap_or_default().to_string(), + opacity: logo.get("opacity").and_then(Value::as_f64), + }), + Err(error) => { + tracing::error!("⚠️ Failed to apply logo: {error}"); + None + } + } +} + +/// Décode une image en appliquant son orientation EXIF (`sharp().rotate()`). +fn decode_oriented(data: &[u8]) -> anyhow::Result { + let mut decoder = ImageReader::new(Cursor::new(data)) + .with_guessed_format()? + .into_decoder()?; + let orientation = decoder.orientation().ok(); + let mut image = DynamicImage::from_decoder(decoder)?; + if let Some(orientation) = orientation { + image.apply_orientation(orientation); + } + Ok(image) +} + +fn render(data: &[u8], spec: &OverlaySpec, fonts_dir: &Path) -> anyhow::Result> { + let mut base: RgbaImage = decode_oriented(data)?.to_rgba8(); + let (width, height) = base.dimensions(); + tracing::info!("📐 Image dimensions: {width}x{height}"); + + if let Some(ribbon) = &spec.ribbon { + let geometry = ribbon_geometry(&ribbon.position, width, height); + let svg = ribbon_svg(ribbon, &geometry); + let layer = rasterize(&svg, fonts_dir)?; + image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y); + } + + if let Some(badge) = &spec.badge { + let geometry = badge_geometry(&badge.text, badge.font_size, &badge.position, width, height); + let svg = badge_svg(badge, &geometry); + let layer = rasterize(&svg, fonts_dir)?; + image::imageops::overlay(&mut base, &layer, geometry.x, geometry.y); + } + + if let Some(logo) = &spec.logo { + match logo_layer(logo, width, height) { + Ok((layer, x, y)) => { + image::imageops::overlay(&mut base, &layer, x, y); + tracing::info!( + "✅ Logo added at position {} with {}% opacity", + logo.position, + logo.opacity.unwrap_or(100.0) + ); + } + Err(error) => tracing::error!("⚠️ Failed to apply logo: {error}"), + } + } + + let rgb = DynamicImage::ImageRgba8(base).to_rgb8(); + let mut output = Vec::new(); + JpegEncoder::new_with_quality(&mut output, 95).encode_image(&rgb)?; + Ok(output) +} + +/// Bandeau triangulaire d'angle (150 px) et son texte incliné. +#[derive(Debug, Clone, PartialEq)] +struct RibbonGeometry { + x: i64, + y: i64, + polygon: String, + text_x: f64, + text_y: f64, + rotation: i32, +} + +fn ribbon_geometry(position: &str, width: u32, height: u32) -> RibbonGeometry { + let s = RIBBON_SIZE as f64; + let right = width as i64 - RIBBON_SIZE as i64; + let bottom = height as i64 - RIBBON_SIZE as i64; + let size = RIBBON_SIZE; + let (x, y, polygon, text_x, text_y, rotation) = match position { + "north_east" => ( + right, + 0, + format!("0,0 {size},0 {size},{size}"), + s * 0.7, + s * 0.3, + 45, + ), + "south_west" => ( + 0, + bottom, + format!("0,0 0,{size} {size},{size}"), + s * 0.3, + s * 0.7, + -135, + ), + "south_east" => ( + right, + bottom, + format!("{size},0 0,{size} {size},{size}"), + s * 0.7, + s * 0.7, + 135, + ), + // north_west et toute valeur inconnue + _ => ( + 0, + 0, + format!("0,0 {size},0 0,{size}"), + s * 0.3, + s * 0.3, + -45, + ), + }; + RibbonGeometry { + x, + y, + polygon, + text_x, + text_y, + rotation, + } +} + +/// Taille du texte du bandeau selon sa longueur (JavaScript). +fn ribbon_font_size(text: &str) -> u32 { + match text.encode_utf16().count() { + 0..=5 => 22, + 6..=8 => 18, + 9..=11 => 15, + _ => 12, + } +} + +/// Échappe un texte pour l'insérer dans du XML. +fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +fn ribbon_svg(ribbon: &Ribbon, g: &RibbonGeometry) -> String { + format!( + r#"{text}"#, + size = RIBBON_SIZE, + polygon = g.polygon, + color = ribbon.color, + tx = g.text_x, + ty = g.text_y, + font = ribbon_font_size(&ribbon.text), + rot = g.rotation, + text = xml_escape(&ribbon.text), + ) +} + +/// Pastille de prix : largeur estimée d'après le nombre de caractères. +#[derive(Debug, Clone, PartialEq)] +struct BadgeGeometry { + width: f64, + height: f64, + x: i64, + y: i64, +} + +fn badge_geometry( + text: &str, + font_size: f64, + position: &str, + width: u32, + height: u32, +) -> BadgeGeometry { + let text_width = text.encode_utf16().count() as f64 * font_size * 0.6; + let badge_width = text_width + BADGE_PADDING * 2.0; + let badge_height = font_size + BADGE_PADDING; + let right = width as f64 - badge_width - BADGE_PADDING; + let bottom = height as f64 - badge_height - BADGE_PADDING; + let (x, y) = match position { + "north_east" => (right, BADGE_PADDING), + "south_west" => (BADGE_PADDING, bottom), + "south_east" => (right, bottom), + _ => (BADGE_PADDING, BADGE_PADDING), + }; + BadgeGeometry { + width: badge_width, + height: badge_height, + x: x.round() as i64, + y: y.round() as i64, + } +} + +fn badge_svg(badge: &PriceBadge, g: &BadgeGeometry) -> String { + format!( + r#"{text}"#, + w = g.width, + h = g.height, + r = g.height / 2.0, + cx = g.width / 2.0, + color = badge.color, + font = badge.font_size, + text = xml_escape(&badge.text), + ) +} + +/// Base de polices partagée : polices du système et dossier `fonts/`. +fn font_database(fonts_dir: &Path) -> Arc { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_system_fonts(); + db.load_fonts_dir(fonts_dir); + let has = |family: &str| { + db.faces() + .any(|face| face.families.iter().any(|(name, _)| name == family)) + }; + // « Arial, sans-serif » : repli sur une police réellement disponible + let fallback = ["Arial", "Liberation Sans", "DejaVu Sans", "FreeSans"] + .into_iter() + .find(|f| has(f)) + .map(str::to_string); + if let Some(family) = fallback { + db.set_sans_serif_family(family); + } + Arc::new(db) + }) + .clone() +} + +/// Rend un SVG en image RGBA (alpha non prémultiplié). +fn rasterize(svg: &str, fonts_dir: &Path) -> anyhow::Result { + let options = usvg::Options { + fontdb: font_database(fonts_dir), + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_str(svg, &options)?; + let size = tree.size().to_int_size(); + let mut pixmap = tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| anyhow::anyhow!("dimensions SVG invalides"))?; + resvg::render( + &tree, + tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + + let mut data = Vec::with_capacity(pixmap.data().len()); + for pixel in pixmap.pixels() { + let c = pixel.demultiply(); + data.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]); + } + RgbaImage::from_raw(size.width(), size.height(), data) + .ok_or_else(|| anyhow::anyhow!("rendu SVG invalide")) +} + +/// Largeur du logo : part de la largeur de l'image, sans dépasser les marges. +fn logo_width(size: &str, width: u32) -> i64 { + let ratio = match size { + "small" => 0.35, + "large" => 0.70, + _ => 0.50, + }; + let requested = (width as f64 * ratio).round() as i64; + requested.min(width as i64 - LOGO_PADDING * 2) +} + +fn logo_position(position: &str, width: u32, height: u32, logo_w: i64, logo_h: i64) -> (i64, i64) { + let (w, h) = (width as i64, height as i64); + let right = w - logo_w - LOGO_PADDING; + let bottom = h - logo_h - LOGO_PADDING; + match position { + "north_east" => (right, LOGO_PADDING), + "south_west" => (LOGO_PADDING, bottom), + "south_east" => (right, bottom), + "center" => ( + ((w - logo_w) as f64 / 2.0).round() as i64, + ((h - logo_h) as f64 / 2.0).round() as i64, + ), + _ => (LOGO_PADDING, LOGO_PADDING), + } +} + +fn logo_layer(logo: &LogoSpec, width: u32, height: u32) -> anyhow::Result<(RgbaImage, i64, i64)> { + let source = decode_oriented(&logo.data)?.to_rgba8(); + let target_w = logo_width(&logo.size, width); + if target_w <= 0 || source.width() == 0 { + anyhow::bail!("Expected positive integer for width but received {target_w}"); + } + let target_h = + ((source.height() as f64 * target_w as f64 / source.width() as f64).round() as u32).max(1); + let mut resized = image::imageops::resize( + &source, + target_w as u32, + target_h, + image::imageops::FilterType::Lanczos3, + ); + + if target_h as i64 > height as i64 - LOGO_PADDING * 2 { + tracing::warn!( + "⚠️ Logo height {target_h}px exceeds max {}px, would be clipped", + height as i64 - LOGO_PADDING * 2 + ); + } + + if let Some(opacity) = logo.opacity.filter(|o| *o < 100.0) { + let factor = (opacity / 100.0).clamp(0.0, 1.0); + for pixel in resized.pixels_mut() { + pixel[3] = (pixel[3] as f64 * factor).round() as u8; + } + } + + let (x, y) = logo_position(&logo.position, width, height, target_w, target_h as i64); + Ok((resized, x, y)) +} + +/// Chemin de polices utilisé par défaut dans les tests. +#[cfg(test)] +fn test_fonts_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ribbon_positions() { + let g = ribbon_geometry("south_east", 800, 600); + assert_eq!((g.x, g.y, g.rotation), (650, 450, 135)); + assert_eq!(g.polygon, "150,0 0,150 150,150"); + assert!((g.text_x - 105.0).abs() < 1e-9); + let g = ribbon_geometry("inconnu", 800, 600); + assert_eq!((g.x, g.y, g.rotation), (0, 0, -45)); + assert_eq!(ribbon_font_size("Promo"), 22); + assert_eq!(ribbon_font_size("Soldes!!"), 18); + assert_eq!(ribbon_font_size("Nouveauté!!"), 15); + assert_eq!(ribbon_font_size("Très grosse promo"), 12); + } + + #[test] + fn badge_positions() { + let g = badge_geometry("12 €", 20.0, "north_east", 800, 600); + assert_eq!((g.width, g.height), (80.0, 36.0)); + assert_eq!((g.x, g.y), (704, 16)); + let g = badge_geometry("12 €", 20.0, "south_west", 800, 600); + assert_eq!((g.x, g.y), (16, 548)); + } + + #[test] + fn logo_sizes_and_positions() { + assert_eq!(logo_width("small", 1000), 350); + assert_eq!(logo_width("large", 1000), 700); + assert_eq!(logo_width("medium", 1000), 500); + // Plafonné aux marges + assert_eq!(logo_width("large", 100), 60); + assert_eq!(logo_position("south_east", 1000, 800, 500, 100), (480, 680)); + assert_eq!(logo_position("center", 1000, 800, 500, 101), (250, 350)); + assert_eq!(logo_position("north_west", 1000, 800, 500, 100), (20, 20)); + } + + #[test] + fn escapes_text() { + assert_eq!(xml_escape("A&B <\"c\">"), "A&B <"c">"); + let ribbon = Ribbon { + text: "".into(), + color: RED, + position: "north_west".into(), + }; + let svg = ribbon_svg(&ribbon, &ribbon_geometry("north_west", 300, 300)); + assert!(svg.contains("<b>")); + assert!(rasterize(&svg, &test_fonts_dir()).is_ok()); + } + + #[test] + fn renders_overlays_on_jpeg() { + let base = RgbaImage::from_pixel(400, 300, image::Rgba([0, 0, 255, 255])); + let mut jpeg = Vec::new(); + JpegEncoder::new_with_quality(&mut jpeg, 90) + .encode_image(&DynamicImage::ImageRgba8(base).to_rgb8()) + .unwrap(); + let mut png_logo = Vec::new(); + DynamicImage::ImageRgba8(RgbaImage::from_pixel( + 10, + 5, + image::Rgba([255, 255, 255, 255]), + )) + .write_to(&mut Cursor::new(&mut png_logo), image::ImageFormat::Png) + .unwrap(); + let spec = OverlaySpec { + ribbon: Some(Ribbon { + text: "Promo".into(), + color: RED, + position: "north_west".into(), + }), + badge: Some(PriceBadge { + text: "9,99 €".into(), + font_size: 20.0, + color: YELLOW, + position: "south_east".into(), + }), + logo: Some(LogoSpec { + data: png_logo, + size: "small".into(), + position: "center".into(), + opacity: Some(50.0), + }), + }; + let output = render(&jpeg, &spec, &test_fonts_dir()).unwrap(); + let out = image::load_from_memory(&output).unwrap().to_rgb8(); + assert_eq!(out.dimensions(), (400, 300)); + // Coin du bandeau rouge + let corner = out.get_pixel(5, 5); + assert!(corner[0] > 180 && corner[2] < 80, "{corner:?}"); + // Logo blanc à 50 % au centre : mélange bleu/blanc + let center = out.get_pixel(200, 150); + assert!( + center[0] > 90 && center[0] < 170 && center[2] > 200, + "{center:?}" + ); + } +} diff --git a/server/src/routes/core/pages.rs b/server/src/routes/core/pages.rs new file mode 100644 index 0000000..7940573 --- /dev/null +++ b/server/src/routes/core/pages.rs @@ -0,0 +1,240 @@ +//! Pages et comptes connectés (saisie manuelle d'un jeton Facebook/Instagram). + +use axum::extract::{Path, State}; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use chrono::Duration; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{NewSocialPage, Patch, Platform, TokenStatus, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::state::AppState; +use crate::time::Ts; + +/// Durée de validité supposée d'un jeton de page saisi à la main. +const TOKEN_LIFETIME_DAYS: i64 = 60; + +pub fn router() -> Router { + Router::new() + .route("/api/pages", get(list_pages).post(create_page)) + .route("/api/pages/{id}", put(update_page).delete(delete_page)) +} + +/// Les jetons restent au serveur : seule la vue client est renvoyée. +async fn list_pages(State(state): State, user: AuthUser) -> ApiResult> { + let pages = state + .db + .get_visible_pages(&user) + .await + .or_500("Failed to fetch pages")?; + Ok(Json(Value::Array( + pages.iter().map(|p| p.to_client()).collect(), + ))) +} + +const PLATFORMS: [&str; 3] = ["facebook", "instagram", "tiktok"]; + +/// Champs du schéma `insertSocialPageSchema` (hors `userId`), dans l'ordre +/// des colonnes ; `partial` rend tout facultatif (mise à jour). +struct PageFields { + user_id: Field, + platform: Field, + page_id: Field, + page_name: Field, + access_token: Field, + followers_count: Field, + is_active: Field, + user_access_token: Field, + refresh_token: Field, + scopes: Field, + avatar_url: Field, +} + +fn parse_page_fields(body: &Value, partial: bool, issues: &mut Issues) -> PageFields { + let required = if partial { + Presence::Optional + } else { + Presence::Required + }; + let user_id = if partial { + issues.string(body, "userId", Presence::Optional) + } else { + Field::Absent + }; + let platform = issues.enumeration(body, "platform", required, &PLATFORMS); + let page_id = issues.string(body, "pageId", required); + let page_name = issues.string(body, "pageName", required); + let access_token = issues.string(body, "accessToken", required); + if partial { + issues.date(body, "tokenExpiresAt", Presence::Nullable); + } + let followers_count = issues.int(body, "followersCount", Presence::Nullable); + let is_active = issues.string(body, "isActive", Presence::Optional); + let user_access_token = issues.string(body, "userAccessToken", Presence::Nullable); + issues.date(body, "userTokenExpiresAt", Presence::Nullable); + let refresh_token = issues.string(body, "refreshToken", Presence::Nullable); + issues.date(body, "refreshTokenExpiresAt", Presence::Nullable); + let scopes = issues.string(body, "scopes", Presence::Nullable); + let avatar_url = issues.string(body, "avatarUrl", Presence::Nullable); + PageFields { + user_id, + platform, + page_id, + page_name, + access_token, + followers_count, + is_active, + user_access_token, + refresh_token, + scopes, + avatar_url, + } +} + +async fn create_page( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to create page"; + + // Un compte TikTok passe obligatoirement par le flux OAuth (/api/tiktok/connect) + if body.str_field("platform") == Some("tiktok") { + return Err(AppError::bad_request( + "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.", + )); + } + + let mut issues = Issues::new(); + let fields = parse_page_fields(&body, false, &mut issues); + if !issues.is_empty() { + tracing::error!("Error creating page: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let page = state + .db + .create_social_page(NewSocialPage { + user_id: user.id.clone(), + platform: fields + .platform + .into_option() + .and_then(|p| Platform::parse(&p)), + page_id: fields.page_id.into_option().unwrap_or_default(), + page_name: fields.page_name.into_option().unwrap_or_default(), + access_token: fields.access_token.into_option().unwrap_or_default(), + token_expires_at: Some(Ts::now().add(Duration::days(TOKEN_LIFETIME_DAYS))), + followers_count: fields.followers_count.into_option(), + is_active: fields.is_active.into_option(), + user_access_token: fields.user_access_token.into_option(), + user_token_expires_at: None, + refresh_token: fields.refresh_token.into_option(), + refresh_token_expires_at: None, + scopes: fields.scopes.into_option(), + avatar_url: fields.avatar_url.into_option(), + }) + .await + .or_500(FAILED)?; + Ok(Json(page.to_client())) +} + +/// Ajoute un champ texte au patch s'il est présent (`null` compris). +fn set_text(patch: Patch, column: &'static str, field: Field) -> Patch { + match field { + Field::Absent => patch, + Field::Null => patch.set(column, Val::Text(None)), + Field::Set(value) => patch.set(column, value), + } +} + +async fn update_page( + State(state): State, + user: AuthUser, + Path(page_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update page"; + + let existing = state + .db + .get_social_page(&page_id) + .await + .or_500(FAILED)? + .filter(|p| p.user_id == user.id) + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + + let mut issues = Issues::new(); + let fields = parse_page_fields(&body, true, &mut issues); + if !issues.is_empty() { + tracing::error!("Error updating page: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let new_token = matches!(&fields.access_token, Field::Set(t) if !t.is_empty()); + let mut patch = Patch::new(); + patch = set_text(patch, "user_id", fields.user_id); + if let Field::Set(platform) = fields.platform.clone() { + if let Some(platform) = Platform::parse(&platform) { + patch = patch.set("platform", platform); + } + } + patch = set_text(patch, "page_id", fields.page_id); + patch = set_text(patch, "page_name", fields.page_name); + patch = set_text(patch, "access_token", fields.access_token); + match fields.followers_count { + Field::Absent => {} + Field::Null => patch = patch.set("followers_count", Val::Int(None)), + Field::Set(count) => patch = patch.set("followers_count", count), + } + patch = set_text(patch, "is_active", fields.is_active); + patch = set_text(patch, "user_access_token", fields.user_access_token); + patch = set_text(patch, "refresh_token", fields.refresh_token); + patch = set_text(patch, "scopes", fields.scopes); + patch = set_text(patch, "avatar_url", fields.avatar_url); + + // Nouveau jeton : échéance recalculée et statut remis à « valide » pour que + // l'interface se mette à jour (les jetons TikTok ont leur propre cycle) + if new_token && existing.platform != Platform::Tiktok { + let now = Ts::now(); + patch = patch + .set( + "token_expires_at", + now.add(Duration::days(TOKEN_LIFETIME_DAYS)), + ) + .set("token_status", TokenStatus::Valid) + .set("last_token_check", now); + } + + // Aucune valeur à écrire : l'ancienne mise à jour échouait + if patch.is_empty() { + tracing::error!("Error updating page: No values to set"); + return Err(AppError::internal(FAILED)); + } + let updated = state + .db + .update_social_page(&page_id, patch) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::internal(FAILED))?; + Ok(Json(updated.to_client())) +} + +async fn delete_page( + State(state): State, + user: AuthUser, + Path(page_id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete page"; + state + .db + .get_social_page(&page_id) + .await + .or_500(FAILED)? + .filter(|p| p.user_id == user.id) + .ok_or_else(|| AppError::not_found("Page non trouvée"))?; + state.db.delete_social_page(&page_id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} diff --git a/server/src/routes/core/posts.rs b/server/src/routes/core/posts.rs new file mode 100644 index 0000000..e37472f --- /dev/null +++ b/server/src/routes/core/posts.rs @@ -0,0 +1,488 @@ +//! Publications : création (avec médias et programmation par page), lecture, +//! modification du texte et des médias. + +use axum::extract::{Path, State}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Field, Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{ + MediaType, NewPost, NewScheduledPost, Patch, Platform, Post, PostStatus, PostType, User, +}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, JsonExt}; +use crate::state::AppState; +use crate::time::Ts; + +/// Nombre maximal de médias par publication. +const MAX_MEDIA: usize = 10; + +pub fn router() -> Router { + Router::new() + .route("/api/posts", get(list_posts).post(create_post)) + .route("/api/posts/{id}", get(get_post).patch(update_post)) + .route("/api/posts/{id}/media", patch(update_post_media)) +} + +async fn list_posts(State(state): State, user: AuthUser) -> ApiResult> { + let posts = state + .db + .get_posts(&user.id) + .await + .or_500("Failed to fetch posts")?; + Ok(Json(json!(posts))) +} + +/// Texte d'une valeur JSON utilisée comme identifiant. +fn id_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Médias d'une publication, au format `(mediaId, displayOrder)` : +/// `mediaIds` (chaînes ou objets `{ mediaId, displayOrder }`) ou, à défaut, +/// l'ancien champ unique `mediaId`. +fn normalize_media_items( + media_ids: Option<&Value>, + media_id: Option<&Value>, +) -> Result, String> { + if let Some(Value::Array(items)) = media_ids { + return items + .iter() + .enumerate() + .map(|(index, item)| { + let index = index as i32; + match item { + Value::String(id) => Ok((id.clone(), index)), + Value::Object(_) if item.truthy("mediaId") => { + let order = match item.get("displayOrder") { + None | Some(Value::Null) => index, + Some(Value::Number(n)) => n.as_f64().map(|f| f as i32).unwrap_or(index), + Some(Value::String(s)) => s.trim().parse().unwrap_or(index), + Some(_) => index, + }; + Ok((id_text(&item["mediaId"]), order)) + } + _ => Err("Invalid media format".to_string()), + } + }) + .collect(); + } + match media_id { + Some(id) if is_truthy(Some(id)) => Ok(vec![(id_text(id), 0)]), + _ => Ok(Vec::new()), + } +} + +/// Champs du post validés (schéma `insertPostSchema`). +struct PostFields { + content: String, + ai_generated: Option, + product_info: Option, + status: PostStatus, + scheduled_for: Option, + generation_status: Option, + generation_progress: Option, + generation_error: Option, +} + +/// Valide les champs du post ; l'erreur est le message de `ZodError`. +fn parse_post_fields(body: &Value) -> Result { + let scheduled_raw = body.get("scheduledFor"); + let scheduled = is_truthy(scheduled_raw); + + let mut issues = Issues::new(); + let content = issues.string(body, "content", Presence::Required); + let ai_generated = issues.string(body, "aiGenerated", Presence::Optional); + let product_info = issues.json(body, "productInfo"); + // Une date de publication fait passer le post en « scheduled » + let status = if scheduled { + Field::Set("scheduled".to_string()) + } else { + issues.enumeration( + body, + "status", + Presence::Optional, + &["draft", "scheduled", "published", "failed"], + ) + }; + let scheduled_for = match scheduled_raw { + // `new Date(chaîne)` avant validation + Some(Value::String(s)) if !s.is_empty() => { + issues.converted_date("scheduledFor", Ts::parse(s)) + } + _ => issues + .date(body, "scheduledFor", Presence::Nullable) + .into_option(), + }; + let generation_status = issues.string(body, "generationStatus", Presence::Nullable); + let generation_progress = issues.int(body, "generationProgress", Presence::Nullable); + let generation_error = issues.string(body, "generationError", Presence::Nullable); + + if !issues.is_empty() { + return Err(issues.zod_message()); + } + Ok(PostFields { + content: content.into_option().unwrap_or_default(), + ai_generated: ai_generated.into_option(), + product_info: product_info.into_option(), + status: status + .into_option() + .and_then(|s| PostStatus::parse(&s)) + .unwrap_or(PostStatus::Draft), + scheduled_for, + generation_status: generation_status.into_option(), + generation_progress: generation_progress.into_option(), + generation_error: generation_error.into_option(), + }) +} + +async fn create_post( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + match create_post_inner(&state, &user, &body).await { + Ok(Ok(post)) => Ok(Json(json!(post))), + Ok(Err(response)) => Err(response), + Err(message) => { + tracing::error!("Error creating post: {message}"); + Err(AppError::internal(message)) + } + } +} + +/// `Err(message)` : erreur inattendue (500 avec le message) ; +/// `Ok(Err(réponse))` : refus explicite (400/403). +async fn create_post_inner( + state: &AppState, + user: &User, + body: &Value, +) -> Result, String> { + let db_error = |e: sqlx::Error| super::pg_message(&e); + + let media_items = normalize_media_items(body.get("mediaIds"), body.get("mediaId"))?; + let post_type = body.get("postType"); + let post_type_str = post_type.and_then(Value::as_str); + let page_ids: Option<&Vec> = body.get("pageIds").and_then(Value::as_array); + let has_pages = page_ids.is_some_and(|p| !p.is_empty()); + + if media_items.len() > MAX_MEDIA { + return Ok(Err(AppError::bad_request( + "Maximum 10 photos autorisées par publication", + ))); + } + if matches!(post_type_str, Some("story") | Some("both")) && media_items.is_empty() { + return Ok(Err(AppError::bad_request( + "Les stories nécessitent au moins un média (image ou vidéo)", + ))); + } + + // Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok + if post_type_str == Some("reel") { + if media_items.len() != 1 { + return Ok(Err(AppError::bad_request( + "Un Reel nécessite exactement une vidéo", + ))); + } + let media = state + .db + .get_media_by_id(&media_items[0].0) + .await + .map_err(db_error)?; + let valid = media.is_some_and(|m| { + m.media_type == MediaType::Video && (user.is_admin() || m.user_id == user.id) + }); + if !valid { + return Ok(Err(AppError::bad_request( + "Le média d'un Reel doit être une de vos vidéos", + ))); + } + for page_id in page_ids.into_iter().flatten() { + let page = state + .db + .get_social_page(&id_text(page_id)) + .await + .map_err(db_error)?; + if !page.is_some_and(|p| matches!(p.platform, Platform::Facebook | Platform::Tiktok)) { + return Ok(Err(AppError::bad_request( + "Les Reels ne peuvent être publiés que sur Facebook ou TikTok", + ))); + } + } + } + + // Un utilisateur ne publie que sur les pages qui lui sont attribuées + if !user.is_admin() && has_pages { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .map_err(db_error)?; + let all_accessible = page_ids.into_iter().flatten().all(|id| { + id.as_str() + .is_some_and(|id| accessible.iter().any(|p| p.id == id)) + }); + if !all_accessible { + return Ok(Err(AppError::forbidden( + "Vous n'avez pas accès à certaines pages sélectionnées", + ))); + } + } + + if is_truthy(body.get("scheduledFor")) && !has_pages { + return Ok(Err(AppError::bad_request( + "Les posts programmés nécessitent au moins une page cible", + ))); + } + + let fields = parse_post_fields(body)?; + let scheduled_for = fields.scheduled_for; + let mut new_post = NewPost::new(user.id.clone(), fields.content); + if let Some(ai) = fields.ai_generated { + new_post.ai_generated = ai; + } + new_post.product_info = fields.product_info; + new_post.status = fields.status; + new_post.scheduled_for = scheduled_for; + new_post.generation_status = fields.generation_status; + new_post.generation_progress = fields.generation_progress; + new_post.generation_error = fields.generation_error; + let post = state.db.create_post(new_post).await.map_err(db_error)?; + + if !media_items.is_empty() { + state + .db + .set_post_media(&post.id, &media_items, false) + .await + .map_err(db_error)?; + } + + if let Some(page_ids) = page_ids.filter(|p| !p.is_empty()) { + // Sans date : publication immédiate + let scheduled_at = scheduled_for.unwrap_or_else(Ts::now); + let type_text = match post_type { + Some(v) if is_truthy(Some(v)) => id_text(v), + _ => "feed".to_string(), + }; + let final_type = PostType::parse(&type_text) + .ok_or_else(|| format!("invalid input value for enum post_type: \"{type_text}\""))?; + let also_story = body.get("alsoStory") == Some(&Value::Bool(true)); + + for page_id in page_ids { + let page_id = id_text(page_id); + let create = |post_type| { + NewScheduledPost::new(post.id.clone(), page_id.clone(), post_type, scheduled_at) + }; + if final_type == PostType::Both { + // « both » : une story et un post de fil, indépendants + state + .db + .create_scheduled_post(create(PostType::Story)) + .await + .map_err(db_error)?; + state + .db + .create_scheduled_post(create(PostType::Feed)) + .await + .map_err(db_error)?; + } else { + state + .db + .create_scheduled_post(create(final_type)) + .await + .map_err(db_error)?; + // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas + if final_type == PostType::Reel && also_story { + let page = state.db.get_social_page(&page_id).await.map_err(db_error)?; + if page.is_some_and(|p| p.platform == Platform::Facebook) { + state + .db + .create_scheduled_post(create(PostType::Story)) + .await + .map_err(db_error)?; + } + } + } + } + } + + Ok(Ok(post)) +} + +fn post_with_media((post, media): (Post, Vec)) -> Value { + json!({ "post": post, "media": media }) +} + +async fn get_post( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch post"; + let found = state + .db + .get_post_with_media(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + + // Admin : tout ; propriétaire : son post + if user.is_admin() || found.0.user_id == user.id { + return Ok(Json(post_with_media(found))); + } + + // Utilisateur : les posts programmés sur une de ses pages + let scheduled = state + .db + .get_scheduled_posts_by_post(&id) + .await + .or_500(FAILED)?; + if !scheduled.is_empty() { + let accessible = state + .db + .get_user_accessible_pages(&user.id) + .await + .or_500(FAILED)?; + if scheduled + .iter() + .any(|sp| accessible.iter().any(|p| p.id == sp.page_id)) + { + return Ok(Json(post_with_media(found))); + } + } + Err(AppError::forbidden("Unauthorized")) +} + +async fn update_post( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update post"; + let post = state + .db + .get_post(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + + let mut patch = Patch::new(); + match body.get("content") { + None => {} + Some(Value::String(content)) => patch = patch.set("content", content.as_str()), + // `null` ou autre type : refusé par la base (colonne texte non nulle) + Some(_) => return Err(AppError::internal(FAILED)), + } + let updated = state.db.update_post(&id, patch).await.or_500(FAILED)?; + Ok(Json(json!(updated))) +} + +async fn update_post_media( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update post media"; + let post = state + .db + .get_post(&id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + + let Some(media_ids) = body.get("mediaIds").and_then(Value::as_array) else { + return Err(AppError::bad_request("mediaIds must be an array")); + }; + if media_ids.len() > MAX_MEDIA { + return Err(AppError::bad_request( + "Maximum 10 photos autorisées par publication", + )); + } + let media_ids: Vec = media_ids.iter().map(id_text).collect(); + + state + .db + .update_post_media(&id, &media_ids) + .await + .or_500(FAILED)?; + let updated = state.db.get_post_with_media(&id).await.or_500(FAILED)?; + Ok(Json(updated.map(post_with_media).unwrap_or(Value::Null))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_list_normalization() { + let ids = json!(["a", { "mediaId": "b", "displayOrder": 5 }, { "mediaId": "c" }]); + assert_eq!( + normalize_media_items(Some(&ids), None).unwrap(), + vec![("a".into(), 0), ("b".into(), 5), ("c".into(), 2)] + ); + // Ancien champ unique + assert_eq!( + normalize_media_items(None, Some(&json!("m"))).unwrap(), + vec![("m".into(), 0)] + ); + // Un tableau prime sur mediaId ; un non-tableau est ignoré + assert_eq!( + normalize_media_items(Some(&json!([])), Some(&json!("m"))).unwrap(), + vec![] + ); + assert_eq!( + normalize_media_items(Some(&json!("x")), Some(&json!("m"))).unwrap(), + vec![("m".into(), 0)] + ); + assert_eq!( + normalize_media_items(None, Some(&json!(""))).unwrap(), + vec![] + ); + assert_eq!( + normalize_media_items(Some(&json!([{ "id": "x" }])), None).unwrap_err(), + "Invalid media format" + ); + assert!(normalize_media_items(Some(&json!([null])), None).is_err()); + } + + #[test] + fn post_fields_validation() { + let fields = parse_post_fields( + &json!({ "content": "Bonjour", "scheduledFor": "2026-10-20T10:00:00Z" }), + ) + .unwrap(); + assert_eq!(fields.status, PostStatus::Scheduled); + assert_eq!( + fields.scheduled_for.unwrap().to_iso(), + "2026-10-20T10:00:00.000Z" + ); + + let fields = + parse_post_fields(&json!({ "content": "x", "status": "draft", "aiGenerated": "true" })) + .unwrap(); + assert_eq!(fields.status, PostStatus::Draft); + assert_eq!(fields.ai_generated.as_deref(), Some("true")); + + let error = parse_post_fields(&json!({})).err().unwrap(); + assert!(error.contains("\"path\": [\n \"content\"\n ]")); + assert!(error.contains("\"message\": \"Required\"")); + + let error = parse_post_fields(&json!({ "content": "x", "scheduledFor": "pas une date" })) + .err() + .unwrap(); + assert!(error.contains("Invalid date")); + } +} diff --git a/server/src/routes/core/scheduled.rs b/server/src/routes/core/scheduled.rs new file mode 100644 index 0000000..7033dc5 --- /dev/null +++ b/server/src/routes/core/scheduled.rs @@ -0,0 +1,253 @@ +//! Publications planifiées (calendrier) et agenda par magasin. + +use std::collections::{HashMap, HashSet}; + +use axum::extract::{Path, State}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::agenda::{ + agenda_item_from_scheduled_post, agenda_items_from_reel_job, AgendaItem, AgendaPage, +}; +use super::validate::{Issues, Presence}; +use crate::auth::AuthUser; +use crate::db::{NewScheduledPost, Patch, PostType, ScheduledPost}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, QueryMap}; +use crate::state::AppState; +use crate::time::Ts; + +pub fn router() -> Router { + Router::new() + .route("/api/scheduled-posts/agenda", get(agenda)) + .route( + "/api/scheduled-posts", + get(list_scheduled).post(create_scheduled), + ) + .route( + "/api/scheduled-posts/{id}", + patch(update_scheduled).delete(delete_scheduled), + ) +} + +/// Ce qui est déjà prévu par jour et par magasin, Reels en montage compris. +async fn agenda( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch publication agenda"; + let start = super::parse_date_param(query.get("startDate")); + let end = super::parse_date_param(query.get("endDate")); + + let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?; + if pages.is_empty() { + return Ok(Json(json!([]))); + } + let page_ids: Vec = pages.iter().map(|p| p.id.clone()).collect(); + let pages_by_id: HashMap = pages + .into_iter() + .map(|p| { + let page = AgendaPage { + id: p.id.clone(), + page_name: p.page_name, + platform: p.platform.as_str().to_string(), + avatar_url: p.avatar_url, + }; + (p.id, page) + }) + .collect(); + + let (scheduled, jobs) = tokio::try_join!( + state.db.get_scheduled_posts_by_pages(&page_ids, start, end), + state.db.get_rendering_reel_jobs(), + ) + .or_500(FAILED)?; + + let mut items: Vec = scheduled + .iter() + .map(agenda_item_from_scheduled_post) + .collect(); + // Un rendu qui vient de finir peut avoir déjà créé ses publications + let planned: HashSet = items.iter().map(|i| i.post_id.clone()).collect(); + for job in &jobs { + if job.post_id.as_ref().is_some_and(|id| planned.contains(id)) { + continue; + } + items.extend(agenda_items_from_reel_job(job, &pages_by_id, start, end)); + } + items.sort_by(|a, b| a.scheduled_at.cmp(&b.scheduled_at)); + Ok(Json(json!(items))) +} + +/// Admin : toutes les pages ; utilisateur : les pages qui lui sont attribuées. +async fn list_scheduled( + State(state): State, + user: AuthUser, + query: QueryMap, +) -> ApiResult> { + const FAILED: &str = "Failed to fetch scheduled posts"; + // Une date illisible est ignorée plutôt que transmise à SQL + let start = super::parse_date_param(query.get("startDate")); + let end = super::parse_date_param(query.get("endDate")); + + let pages = state.db.get_visible_pages(&user).await.or_500(FAILED)?; + let page_ids: Vec = pages.into_iter().map(|p| p.id).collect(); + let scheduled = state + .db + .get_scheduled_posts_by_pages(&page_ids, start, end) + .await + .or_500(FAILED)?; + Ok(Json(json!(scheduled))) +} + +/// Publication planifiée et droit de la modifier (admin ou auteur du post). +async fn owned_scheduled_post( + state: &AppState, + user: &AuthUser, + id: &str, + failed: &str, +) -> Result { + let scheduled = state + .db + .get_scheduled_post(id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Scheduled post not found"))?; + let post = state + .db + .get_post(&scheduled.post_id) + .await + .or_500(failed)? + .ok_or_else(|| AppError::not_found("Post not found"))?; + if !user.is_admin() && post.user_id != user.id { + return Err(AppError::forbidden("Unauthorized")); + } + Ok(scheduled) +} + +async fn delete_scheduled( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const FAILED: &str = "Failed to delete scheduled post"; + owned_scheduled_post(&state, &user, &id, FAILED).await?; + state.db.delete_scheduled_post(&id).await.or_500(FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +/// `new Date(valeur)` pour une chaîne ou un nombre de millisecondes. +fn js_date(value: &Value) -> Option { + match value { + Value::String(s) => Ts::parse(s), + Value::Number(n) => n.as_f64().and_then(|f| Ts::from_millis(f as i64)), + _ => None, + } +} + +/// Seules la date et la page peuvent changer ; la date est recopiée sur le post. +async fn update_scheduled( + State(state): State, + user: AuthUser, + Path(id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to update scheduled post"; + let scheduled = owned_scheduled_post(&state, &user, &id, FAILED).await?; + + let mut patch = Patch::new(); + if let Some(value) = body.get("scheduledAt").filter(|v| is_truthy(Some(v))) { + let date = js_date(value).ok_or_else(|| AppError::internal(FAILED))?; + patch = patch.set("scheduled_at", date); + state + .db + .update_post(&scheduled.post_id, Patch::new().set("scheduled_for", date)) + .await + .or_500(FAILED)?; + } + if let Some(page_id) = body.get("pageId").filter(|v| is_truthy(Some(v))) { + let Some(page_id) = page_id.as_str() else { + return Err(AppError::internal(FAILED)); + }; + patch = patch.set("page_id", page_id); + } + // Rien à modifier : l'ancienne requête de mise à jour échouait + if patch.is_empty() { + return Err(AppError::internal(FAILED)); + } + let updated = state + .db + .update_scheduled_post(&id, patch) + .await + .or_500(FAILED)?; + Ok(Json(json!(updated))) +} + +/// Création directe (schéma `insertScheduledPostSchema`). « both » crée deux +/// publications indépendantes (story + fil), renvoyées sous forme de tableau. +async fn create_scheduled( + State(state): State, + _user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Failed to create scheduled post"; + + let mut issues = Issues::new(); + let post_id = issues + .string(&body, "postId", Presence::Required) + .into_option(); + let page_id = issues + .string(&body, "pageId", Presence::Required) + .into_option(); + let post_type = issues + .enumeration( + &body, + "postType", + Presence::Required, + &["feed", "story", "both", "reel"], + ) + .into_option() + .and_then(|t| PostType::parse(&t)); + // Plus tolérant que l'ancien `z.date()` (qui refusait toute date JSON) : + // une date ISO ou un nombre de millisecondes est accepté. + let scheduled_at = body.get("scheduledAt").and_then(js_date); + let publish_id = issues + .string(&body, "publishId", Presence::Nullable) + .into_option(); + let publish_status = issues + .string(&body, "publishStatus", Presence::Nullable) + .into_option(); + + let (Some(post_id), Some(page_id), Some(post_type), Some(scheduled_at)) = + (post_id, page_id, post_type, scheduled_at) + else { + tracing::error!("Error creating scheduled post: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + }; + if !issues.is_empty() { + tracing::error!("Error creating scheduled post: {}", issues.zod_message()); + return Err(AppError::internal(FAILED)); + } + + let new = |post_type| NewScheduledPost { + publish_id: publish_id.clone(), + publish_status: publish_status.clone(), + ..NewScheduledPost::new(post_id.clone(), page_id.clone(), post_type, scheduled_at) + }; + if post_type == PostType::Both { + let (story, feed) = tokio::try_join!( + state.db.create_scheduled_post(new(PostType::Story)), + state.db.create_scheduled_post(new(PostType::Feed)), + ) + .or_500(FAILED)?; + return Ok(Json(json!([story, feed]))); + } + let created = state + .db + .create_scheduled_post(new(post_type)) + .await + .or_500(FAILED)?; + Ok(Json(json!(created))) +} diff --git a/server/src/routes/core/settings.rs b/server/src/routes/core/settings.rs new file mode 100644 index 0000000..d66efef --- /dev/null +++ b/server/src/routes/core/settings.rs @@ -0,0 +1,207 @@ +//! Paramètres globaux : clé de l'API externe, clé Gemini, service Qwen TTS. + +use axum::extract::State; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use crate::auth::AdminUser; +use crate::db::{Patch, Val}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::services::ffmpeg::{self, QwenTarget}; +use crate::services::reels::normalize_qwen_url; +use crate::state::AppState; + +const READ_FAILED: &str = "Erreur lors de la récupération de la configuration"; +const SAVE_FAILED: &str = "Erreur lors de la sauvegarde de la configuration"; +const DELETE_KEY_FAILED: &str = "Erreur lors de la suppression de la clé"; + +pub fn router() -> Router { + Router::new() + .route( + "/api/settings/external-api", + get(get_external_api) + .post(save_external_api) + .delete(delete_external_api), + ) + .route( + "/api/settings/gemini", + get(get_gemini).post(save_gemini).delete(delete_gemini), + ) + .route( + "/api/settings/qwen", + get(get_qwen).post(save_qwen).delete(delete_qwen), + ) + .route("/api/settings/qwen/test", post(test_qwen)) +} + +fn is_set(value: &Option) -> bool { + value.as_deref().is_some_and(|v| !v.is_empty()) +} + +/// Clé API obligatoire et non vide (`apiKey.trim() !== ""`). +fn required_api_key(body: &Value) -> Result { + body.trimmed("apiKey") + .ok_or_else(|| AppError::bad_request("La clé API est requise")) +} + +async fn get_external_api( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + Ok(Json( + json!({ "configured": config.is_some_and(|c| is_set(&c.external_api_key)) }), + )) +} + +async fn save_external_api( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let key = required_api_key(&body)?; + state + .db + .upsert_app_config(Patch::new().set("external_api_key", key)) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true, "configured": true }))) +} + +async fn delete_external_api( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + state + .db + .upsert_app_config(Patch::new().set_null("external_api_key")) + .await + .or_500(DELETE_KEY_FAILED)?; + Ok(Json(json!({ "success": true, "configured": false }))) +} + +async fn get_gemini(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + Ok(Json( + json!({ "configured": config.is_some_and(|c| is_set(&c.gemini_api_key)) }), + )) +} + +async fn save_gemini( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let key = required_api_key(&body)?; + state + .db + .upsert_app_config(Patch::new().set("gemini_api_key", key)) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true, "configured": true }))) +} + +async fn delete_gemini(State(state): State, _admin: AdminUser) -> ApiResult> { + state + .db + .upsert_app_config(Patch::new().set_null("gemini_api_key")) + .await + .or_500(DELETE_KEY_FAILED)?; + Ok(Json(json!({ "success": true, "configured": false }))) +} + +async fn get_qwen(State(state): State, _admin: AdminUser) -> ApiResult> { + let config = state.db.get_app_config().await.or_500(READ_FAILED)?; + let url = config + .as_ref() + .and_then(|c| c.qwen_tts_url.clone()) + .unwrap_or_default(); + let has_key = config.is_some_and(|c| is_set(&c.qwen_tts_api_key)); + Ok(Json(json!({ "url": url, "hasApiKey": has_key }))) +} + +async fn save_qwen( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let Some(url) = normalize_qwen_url(body.get("url")) else { + return Err(AppError::bad_request( + "Adresse invalide (ex. http://192.168.1.20:8001)", + )); + }; + let mut patch = Patch::new().set("qwen_tts_url", url); + // Clé laissée vide : on garde celle déjà enregistrée + if let Some(key) = body.trimmed("apiKey") { + patch = patch.set("qwen_tts_api_key", key); + } + state + .db + .upsert_app_config(patch) + .await + .or_500(SAVE_FAILED)?; + Ok(Json(json!({ "success": true }))) +} + +async fn delete_qwen(State(state): State, _admin: AdminUser) -> ApiResult> { + state + .db + .upsert_app_config( + Patch::new() + .set("qwen_tts_url", Val::Text(None)) + .set("qwen_tts_api_key", Val::Text(None)), + ) + .await + .or_500("Erreur lors de la suppression de la configuration")?; + Ok(Json(json!({ "success": true }))) +} + +/// Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen). +/// Répond toujours 200 : `{ ok: false, error }` en cas d'échec. +async fn test_qwen( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> Json { + match run_qwen_test(&state, &body).await { + Ok(value) => Json(value), + Err(error) => { + tracing::error!("Error testing Qwen config: {error:#}"); + Json(json!({ "ok": false, "error": error.to_string() })) + } + } +} + +async fn run_qwen_test(state: &AppState, body: &Value) -> anyhow::Result { + let config = state.db.get_app_config().await?; + let url = normalize_qwen_url(body.get("url")) + .or_else(|| config.as_ref().and_then(|c| c.qwen_tts_url.clone())) + .filter(|u| !u.is_empty()); + let api_key = body + .trimmed("apiKey") + .or_else(|| config.as_ref().and_then(|c| c.qwen_tts_api_key.clone())) + .filter(|k| !k.is_empty()); + + let catalog = ffmpeg::list_voices(state, url.map(|url| QwenTarget { url, api_key })).await?; + let status = catalog + .qwen_status + .as_ref() + .filter(|s| crate::extract::is_truthy(Some(s))); + let Some(status) = status.filter(|_| catalog.qwen_available) else { + return Ok( + json!({ "ok": false, "error": "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }), + ); + }; + + let mut result = serde_json::Map::new(); + result.insert("ok".into(), Value::Bool(true)); + if let Value::Object(fields) = status { + for (key, value) in fields { + result.insert(key.clone(), value.clone()); + } + } + result.insert("voices".into(), json!(catalog.qwen.len())); + Ok(Value::Object(result)) +} diff --git a/server/src/routes/core/sql.rs b/server/src/routes/core/sql.rs new file mode 100644 index 0000000..4bb4155 --- /dev/null +++ b/server/src/routes/core/sql.rs @@ -0,0 +1,645 @@ +//! Console SQL (administrateurs). +//! +//! L'ancienne route passait la requête telle quelle à `db.execute` (pilote +//! `pg`, protocole simple) et renvoyait l'objet `QueryResult` de `pg` : +//! `{ command, rowCount, oid, rows, fields, … }`, ou un tableau de ces objets +//! quand la requête contenait plusieurs instructions. Les valeurs sont +//! converties comme le faisait `pg-types`, avec les exceptions de Drizzle +//! (dates, horodatages et intervalles laissés en texte brut). + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures::TryStreamExt; +use serde_json::{json, Map, Number, Value}; +use sqlx::postgres::{PgRow, PgValueRef}; +use sqlx::{Column, Either, Row, TypeInfo, ValueRef}; + +use super::validate::{Issues, Presence}; +use crate::auth::AdminUser; +use crate::error::{ApiResult, AppError}; +use crate::extract::Body; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/sql/execute", post(execute)) + .route("/api/sql/tables", get(tables)) +} + +async fn execute( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + let mut issues = Issues::new(); + let query = issues.string_min( + &body, + "query", + Presence::Required, + 1, + "La requête SQL ne peut pas être vide", + ); + let Some(query) = query.into_option().filter(|_| issues.is_empty()) else { + return Err(AppError::json( + StatusCode::BAD_REQUEST, + json!({ "success": false, "error": issues.first_message().unwrap_or_else(|| "Données invalides".into()) }), + )); + }; + + match run_query(&state, &query).await { + Ok(result) => Ok(Json(json!({ "success": true, "result": result }))), + Err(error) => { + tracing::error!("Error executing SQL: {error}"); + let message = super::pg_message(&error); + let message = if message.is_empty() { + "Erreur lors de l'exécution de la requête SQL".to_string() + } else { + message + }; + Err(AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "success": false, "error": message }), + )) + } + } +} + +async fn tables(State(state): State, _admin: AdminUser) -> ApiResult> { + let names: Result, sqlx::Error> = sqlx::query_scalar( + "SELECT tablename::text FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename", + ) + .fetch_all(&state.db.pool) + .await; + match names { + Ok(names) => Ok(Json(json!({ + "tables": names.into_iter().map(|t| json!({ "tablename": t })).collect::>(), + }))), + Err(error) => { + tracing::error!("Error fetching tables: {error}"); + Err(AppError::internal(super::pg_message(&error))) + } + } +} + +/// Résultat d'une instruction en cours de lecture. +#[derive(Default)] +struct StatementResult { + rows: Vec, + fields: Vec, +} + +/// Exécute la requête par le protocole simple (plusieurs instructions +/// possibles, comme avec `pg`) et assemble les résultats au format `pg`. +async fn run_query(state: &AppState, query: &str) -> Result { + let commands = statement_commands(query); + let mut results: Vec = Vec::new(); + let mut current = StatementResult::default(); + + let mut stream = sqlx::raw_sql(query).fetch_many(&state.db.pool); + while let Some(item) = stream.try_next().await? { + match item { + Either::Right(row) => { + if current.fields.is_empty() { + current.fields = row_fields(&row); + } + current.rows.push(row_to_json(&row)); + } + Either::Left(done) => { + let command = commands.get(results.len()).cloned().unwrap_or_default(); + let finished = std::mem::take(&mut current); + results.push(query_result(&command, done.rows_affected(), finished)); + } + } + } + + Ok(match results.len() { + // Requête vide (commentaire seul…) : objet résultat vierge + 0 => json!({ + "command": null, "rowCount": null, "oid": null, "rows": [], "fields": [], + "_types": {}, "RowCtor": null, "rowAsArray": false, "_prebuiltEmptyResultObject": null, + }), + 1 => results.pop().unwrap_or(Value::Null), + _ => Value::Array(results), + }) +} + +/// Objet `Result` de `pg` pour une instruction terminée. +fn query_result(command: &str, rows_affected: u64, result: StatementResult) -> Value { + // `pg` lit l'étiquette « COMMANDE [OID] LIGNES » envoyée par PostgreSQL + let (row_count, oid) = match command { + "INSERT" => (json!(rows_affected), json!(0)), + "SELECT" | "UPDATE" | "DELETE" | "MERGE" | "MOVE" | "FETCH" | "COPY" => { + (json!(rows_affected), Value::Null) + } + _ => (Value::Null, Value::Null), + }; + let empty_row: Map = result + .fields + .iter() + .filter_map(|f| f.get("name").and_then(Value::as_str)) + .map(|name| (name.to_string(), Value::Null)) + .collect(); + let has_fields = !result.fields.is_empty(); + let field_count = result.fields.len(); + // Même ordre de clés que l'objet `Result` de `pg` + let mut object = Map::new(); + object.insert( + "command".into(), + if command.is_empty() { + Value::Null + } else { + json!(command) + }, + ); + object.insert("rowCount".into(), row_count); + object.insert("oid".into(), oid); + object.insert("rows".into(), Value::Array(result.rows)); + object.insert("fields".into(), Value::Array(result.fields)); + if has_fields { + // Les analyseurs de `pg` sont des fonctions : `null` une fois sérialisés + object.insert( + "_parsers".into(), + Value::Array(vec![Value::Null; field_count]), + ); + } + object.insert("_types".into(), json!({})); + object.insert("RowCtor".into(), Value::Null); + object.insert("rowAsArray".into(), Value::Bool(false)); + object.insert( + "_prebuiltEmptyResultObject".into(), + if has_fields { + Value::Object(empty_row) + } else { + Value::Null + }, + ); + Value::Object(object) +} + +/// Description des colonnes (`fields` de `pg`). +fn row_fields(row: &PgRow) -> Vec { + row.columns() + .iter() + .map(|column| { + let type_info = column.type_info(); + json!({ + "name": column.name(), + "tableID": column.relation_id().map(|o| o.0).unwrap_or(0), + "columnID": column.relation_attribute_no().unwrap_or(0), + "dataTypeID": type_info.oid().map(|o| o.0).unwrap_or(0), + "format": "text", + }) + }) + .collect() +} + +fn row_to_json(row: &PgRow) -> Value { + let mut object = Map::new(); + for (index, column) in row.columns().iter().enumerate() { + let oid = column.type_info().oid().map(|o| o.0).unwrap_or(0); + let value = match row.try_get_raw(index) { + Ok(raw) => raw_to_json(oid, column.type_info().name(), raw), + Err(_) => Value::Null, + }; + // Colonnes homonymes : la dernière l'emporte, à la place de la première + object.insert(column.name().to_string(), value); + } + Value::Object(object) +} + +fn raw_to_json(oid: u32, type_name: &str, raw: PgValueRef<'_>) -> Value { + if raw.is_null() { + return Value::Null; + } + match raw.as_str() { + Ok(text) => text_to_json(oid, text), + // Valeur binaire (jamais en protocole simple) : nom du type en repli + Err(_) => Value::String(format!("<{type_name}>")), + } +} + +/// Conversion d'une valeur texte PostgreSQL, comme les analyseurs de `pg-types`. +pub(crate) fn text_to_json(oid: u32, text: &str) -> Value { + match oid { + // int2, int4, oid + 21 | 23 | 26 => parse_int(text), + // float4, float8 + 700 | 701 => parse_float(text), + 16 => Value::Bool(matches!( + text, + "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1" + )), + 114 | 3802 => serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into())), + 17 => parse_bytea(text), + 600 => parse_point(text), + // Tableaux + 1000 => parse_array(text, &|e| { + Value::Bool(matches!( + e, + "TRUE" | "t" | "true" | "y" | "yes" | "on" | "1" + )) + }), + 1005 | 1007 | 1028 => parse_array(text, &parse_int), + 1016 => parse_array(text, &|e| Value::String(e.trim().into())), + 1021 | 1022 | 1231 => parse_array(text, &parse_float), + 199 | 3807 => parse_array(text, &|e| { + serde_json::from_str(e).unwrap_or_else(|_| Value::String(e.into())) + }), + 1115 | 1182 | 1185 => parse_array(text, &parse_date), + 1001 => parse_array(text, &parse_bytea), + 651 | 1014 | 1015 | 1008 | 1009 | 1040 | 1041 | 3907 | 2951 | 791 | 1183 | 1270 => { + parse_array(text, &|e| Value::String(e.into())) + } + // int8 et numeric restent du texte ; date, timestamp(tz), interval + // aussi (Drizzle désactive leur conversion) ; uuid, enums… : texte + _ => Value::String(text.into()), + } +} + +/// `parseInt(value, 10)` : `null` si illisible (NaN sérialisé par JSON). +fn parse_int(text: &str) -> Value { + let trimmed = text.trim(); + let (sign, digits) = match trimmed.strip_prefix('-') { + Some(rest) => (-1i64, rest), + None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + let end = digits.bytes().take_while(u8::is_ascii_digit).count(); + match digits[..end].parse::() { + Ok(n) => json!(sign * n), + Err(_) => Value::Null, + } +} + +/// `parseFloat` : `NaN` et `Infinity` deviennent `null` en JSON. +fn parse_float(text: &str) -> Value { + match text.trim().parse::() { + Ok(f) if f.is_finite() => { + if f.fract() == 0.0 && f.abs() < 9e15 { + json!(f as i64) + } else { + Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } + } + _ => Value::Null, + } +} + +/// Élément de tableau date/horodatage : objet `Date` JavaScript, donc ISO 8601. +fn parse_date(text: &str) -> Value { + let mut value = text.to_string(); + // Décalage « +02 » sans minutes, refusé par chrono + if let Some(pos) = value.rfind(['+', '-']) { + if pos > 10 && value.len() - pos == 3 { + value.push_str(":00"); + } + } + match crate::time::parse_js_date(&value) { + Some(ts) => Value::String(ts.to_iso()), + None => Value::Null, + } +} + +/// `bytea` (format hexadécimal) : objet `Buffer` sérialisé. +fn parse_bytea(text: &str) -> Value { + match text + .strip_prefix("\\x") + .and_then(|hex_text| hex::decode(hex_text).ok()) + { + Some(bytes) => json!({ "type": "Buffer", "data": bytes }), + None => Value::String(text.into()), + } +} + +fn parse_point(text: &str) -> Value { + let inner = text.trim().trim_start_matches('(').trim_end_matches(')'); + let mut parts = inner.split(',').map(|p| p.trim().parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(x), Some(y)) => json!({ "x": x, "y": y }), + _ => Value::String(text.into()), + } +} + +/// Tableau PostgreSQL (`{1,2,NULL}`, `{"a b","c\"d"}`, imbriqué) en JSON. +fn parse_array(text: &str, element: &dyn Fn(&str) -> Value) -> Value { + let text = text.trim(); + // Bornes explicites (« [0:1]={…} ») : on les ignore + let text = match (text.starts_with('['), text.find('=')) { + (true, Some(pos)) => &text[pos + 1..], + _ => text, + }; + let chars: Vec = text.chars().collect(); + let mut pos = 0; + match parse_array_level(&chars, &mut pos, element) { + Some(value) => value, + None => Value::String(text.into()), + } +} + +fn parse_array_level( + chars: &[char], + pos: &mut usize, + element: &dyn Fn(&str) -> Value, +) -> Option { + if chars.get(*pos) != Some(&'{') { + return None; + } + *pos += 1; + let mut items = Vec::new(); + loop { + match chars.get(*pos)? { + '}' => { + *pos += 1; + return Some(Value::Array(items)); + } + ',' => { + *pos += 1; + } + '{' => items.push(parse_array_level(chars, pos, element)?), + '"' => { + *pos += 1; + let mut buf = String::new(); + loop { + match chars.get(*pos)? { + '\\' => { + buf.push(*chars.get(*pos + 1)?); + *pos += 2; + } + '"' => { + *pos += 1; + break; + } + c => { + buf.push(*c); + *pos += 1; + } + } + } + items.push(element(&buf)); + } + _ => { + let start = *pos; + while !matches!(chars.get(*pos), Some(',') | Some('}') | None) { + *pos += 1; + } + let raw: String = chars[start..*pos].iter().collect(); + let raw = raw.trim(); + items.push(if raw.eq_ignore_ascii_case("NULL") { + Value::Null + } else { + element(raw) + }); + } + } + } +} + +/// Commande de chaque instruction (« SELECT », « INSERT »…), dans l'ordre : +/// l'étiquette envoyée par PostgreSQL n'est pas exposée par sqlx. +fn statement_commands(query: &str) -> Vec { + split_statements(query) + .iter() + .filter_map(|statement| { + let word: String = statement + .trim_start() + .chars() + .take_while(|c| c.is_ascii_alphabetic()) + .collect::() + .to_ascii_uppercase(); + if word.is_empty() { + return None; + } + Some(match word.as_str() { + // VALUES et TABLE renvoient l'étiquette SELECT + "VALUES" | "TABLE" => "SELECT".to_string(), + "WITH" => with_command(statement), + "START" => "START".to_string(), + _ => word, + }) + }) + .collect() +} + +/// `WITH … SELECT/INSERT/UPDATE/DELETE` : premier mot-clé principal hors parenthèses. +fn with_command(statement: &str) -> String { + let upper = statement.to_ascii_uppercase(); + let mut depth = 0i32; + let mut word = String::new(); + for c in upper.chars().chain(std::iter::once(' ')) { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + if c.is_ascii_alphabetic() { + word.push(c); + } else { + if depth == 0 + && matches!( + word.as_str(), + "SELECT" | "INSERT" | "UPDATE" | "DELETE" | "MERGE" + ) + { + return word; + } + word.clear(); + } + } + "SELECT".to_string() +} + +/// Découpe une requête en instructions sur les « ; » hors chaînes, +/// identifiants entre guillemets, chaînes dollar et commentaires. +/// Les commentaires sont retirés du texte des instructions. +fn split_statements(query: &str) -> Vec { + let chars: Vec = query.chars().collect(); + let mut statements = Vec::new(); + let mut current = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + match c { + '\'' | '"' => { + let quote = c; + current.push(c); + i += 1; + while i < chars.len() { + current.push(chars[i]); + if chars[i] == quote { + if chars.get(i + 1) == Some("e) { + current.push(quote); + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + '-' if chars.get(i + 1) == Some(&'-') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + current.push(' '); + } + '/' if chars.get(i + 1) == Some(&'*') => { + let mut depth = 0; + while i < chars.len() { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + if depth == 0 { + break; + } + } else { + i += 1; + } + } + current.push(' '); + } + '$' => { + // Étiquette de chaîne dollar : $$ ou $tag$ + let mut j = i + 1; + while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') { + j += 1; + } + let is_tag = chars.get(j) == Some(&'$') + && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) + && !current + .chars() + .last() + .is_some_and(|p| p.is_alphanumeric() || p == '_'); + if is_tag { + let tag: Vec = chars[i..=j].to_vec(); + current.extend(&tag); + i = j + 1; + while i < chars.len() { + if chars[i..].starts_with(&tag) { + current.extend(&tag); + i += tag.len(); + break; + } + current.push(chars[i]); + i += 1; + } + } else { + current.push(c); + i += 1; + } + } + ';' => { + statements.push(std::mem::take(&mut current)); + i += 1; + } + _ => { + current.push(c); + i += 1; + } + } + } + statements.push(current); + statements + .into_iter() + .filter(|s| !s.trim().is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_conversions_follow_pg_types() { + assert_eq!(text_to_json(23, "42"), json!(42)); + assert_eq!( + text_to_json(20, "9007199254740993"), + json!("9007199254740993") + ); + assert_eq!(text_to_json(1700, "12.50"), json!("12.50")); + assert_eq!(text_to_json(701, "1.5"), json!(1.5)); + assert_eq!(text_to_json(701, "3"), json!(3)); + assert_eq!(text_to_json(701, "NaN"), Value::Null); + assert_eq!(text_to_json(16, "t"), json!(true)); + assert_eq!(text_to_json(16, "f"), json!(false)); + assert_eq!( + text_to_json(3802, r#"{"a": [1, 2]}"#), + json!({ "a": [1, 2] }) + ); + // Drizzle laisse les dates en texte brut + assert_eq!( + text_to_json(1114, "2024-05-01 08:30:00.123"), + json!("2024-05-01 08:30:00.123") + ); + assert_eq!(text_to_json(2950, "a0ee-…"), json!("a0ee-…")); + assert_eq!( + text_to_json(17, "\\x0102"), + json!({ "type": "Buffer", "data": [1, 2] }) + ); + assert_eq!(text_to_json(600, "(1.5,2)"), json!({ "x": 1.5, "y": 2.0 })); + } + + #[test] + fn array_conversions() { + assert_eq!(text_to_json(1007, "{1,2,NULL}"), json!([1, 2, null])); + assert_eq!( + text_to_json(1009, r#"{"a b","c\"d",e}"#), + json!(["a b", "c\"d", "e"]) + ); + assert_eq!(text_to_json(1007, "{{1,2},{3,4}}"), json!([[1, 2], [3, 4]])); + assert_eq!(text_to_json(1016, "{10,20}"), json!(["10", "20"])); + assert_eq!(text_to_json(1009, "{}"), json!([])); + assert_eq!( + text_to_json(1185, r#"{"2024-05-01 10:30:00+02"}"#), + json!(["2024-05-01T08:30:00.000Z"]) + ); + assert_eq!( + text_to_json(1115, r#"{"2024-05-01 08:30:00"}"#), + json!(["2024-05-01T08:30:00.000Z"]) + ); + } + + #[test] + fn statement_splitting() { + let commands = statement_commands( + "select 'a;b'; -- commentaire ; ici\nINSERT INTO t VALUES ($$x;y$$); /* ; */ ; with x as (select 1) delete from t; values (1)", + ); + assert_eq!(commands, vec!["SELECT", "INSERT", "DELETE", "SELECT"]); + assert_eq!(statement_commands(" "), Vec::::new()); + assert_eq!( + statement_commands("create table \"a;b\" (id int)"), + vec!["CREATE"] + ); + } + + #[test] + fn result_shape() { + let result = query_result( + "SELECT", + 1, + StatementResult { + rows: vec![json!({ "a": 1 })], + fields: vec![json!({ "name": "a" })], + }, + ); + let keys: Vec<&String> = result.as_object().unwrap().keys().collect(); + assert_eq!( + keys[..6], + ["command", "rowCount", "oid", "rows", "fields", "_parsers"] + ); + assert_eq!(result["rowCount"], 1); + assert_eq!(result["_prebuiltEmptyResultObject"], json!({ "a": null })); + let create = query_result("CREATE", 0, StatementResult::default()); + assert_eq!(create["rowCount"], Value::Null); + assert_eq!( + query_result("INSERT", 2, StatementResult::default())["oid"], + 0 + ); + } +} diff --git a/server/src/routes/core/stats.rs b/server/src/routes/core/stats.rs new file mode 100644 index 0000000..d228cb9 --- /dev/null +++ b/server/src/routes/core/stats.rs @@ -0,0 +1,122 @@ +//! Compteurs du tableau de bord. + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime}; +use serde_json::{json, Value}; + +use crate::auth::AuthUser; +use crate::error::{ApiResult, OrApiError}; +use crate::state::AppState; +use crate::time::Ts; + +pub fn router() -> Router { + Router::new().route("/api/stats", get(stats)) +} + +async fn stats(State(state): State, user: AuthUser) -> ApiResult> { + let now = Ts::now(); + let (yesterday_start, yesterday_end, last_month) = periods(now.0); + let stats = state + .db + .get_dashboard_stats( + &user.id, + Ts(yesterday_start), + Ts(yesterday_end), + Ts(last_month), + ) + .await + .or_500("Failed to fetch stats")?; + + let ai_change = change_percent(stats.ai_texts_generated, stats.ai_texts_yesterday); + let scheduled_change = change_percent(stats.scheduled_posts, stats.scheduled_posts_last_month); + + Ok(Json(json!({ + "scheduledPosts": stats.scheduled_posts, + "scheduledPostsChange": format_change(scheduled_change), + "scheduledPostsTrending": if scheduled_change >= 0 { "up" } else { "down" }, + "connectedPages": stats.connected_pages, + "aiTextsGenerated": stats.ai_texts_generated, + "aiTextsChange": format_change(ai_change), + "aiTextsTrending": if ai_change >= 0 { "up" } else { "down" }, + "mediaStored": stats.media_stored, + }))) +} + +/// Début d'hier, début d'aujourd'hui (minuit UTC, l'heure du conteneur) et +/// même instant le mois dernier (`setMonth(getMonth() - 1)`). +fn periods(now: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime, NaiveDateTime) { + let yesterday = (now - Duration::days(1)) + .date() + .and_hms_opt(0, 0, 0) + .unwrap_or(now); + let today = yesterday + Duration::days(1); + (yesterday, today, previous_month(now)) +} + +/// `Date.setMonth(mois - 1)` : un jour qui n'existe pas déborde sur le mois +/// suivant (31 mars → 3 mars, ou 2 mars une année bissextile). +fn previous_month(now: NaiveDateTime) -> NaiveDateTime { + let (year, month) = if now.month() == 1 { + (now.year() - 1, 12) + } else { + (now.year(), now.month() - 1) + }; + let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(now.date()); + (first + Duration::days(i64::from(now.day()) - 1)).and_time(now.time()) +} + +/// Variation en pourcentage, arrondie comme `Math.round`. +fn change_percent(current: i64, previous: i64) -> i64 { + if previous > 0 { + let ratio = (current - previous) as f64 / previous as f64 * 100.0; + (ratio + 0.5).floor() as i64 + } else if current > 0 { + 100 + } else { + 0 + } +} + +fn format_change(change: i64) -> String { + if change > 0 { + format!("+{change}%") + } else { + format!("{change}%") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(s: &str) -> NaiveDateTime { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").unwrap() + } + + #[test] + fn percentages() { + assert_eq!(format_change(change_percent(5, 0)), "+100%"); + assert_eq!(format_change(change_percent(0, 0)), "0%"); + assert_eq!(format_change(change_percent(3, 2)), "+50%"); + assert_eq!(format_change(change_percent(1, 2)), "-50%"); + assert_eq!(format_change(change_percent(2, 2)), "0%"); + // Math.round(-2.5) = -2, Math.round(2.5) = 3 + assert_eq!(change_percent(195, 200), -2); + assert_eq!(change_percent(41, 40), 3); + // -0,4 % arrondi donne 0 (et non « -0 ») + assert_eq!(format_change(change_percent(996, 1000)), "0%"); + } + + #[test] + fn utc_periods() { + let (start, end, month) = periods(at("2026-03-31 15:20:00")); + assert_eq!(start, at("2026-03-30 00:00:00")); + assert_eq!(end, at("2026-03-31 00:00:00")); + assert_eq!(month, at("2026-03-03 15:20:00")); + let (start, _, month) = periods(at("2026-01-01 00:10:00")); + assert_eq!(start, at("2025-12-31 00:00:00")); + assert_eq!(month, at("2025-12-01 00:10:00")); + } +} diff --git a/server/src/routes/core/users.rs b/server/src/routes/core/users.rs new file mode 100644 index 0000000..f2536fd --- /dev/null +++ b/server/src/routes/core/users.rs @@ -0,0 +1,254 @@ +//! Utilisateurs et permissions sur les pages (administrateurs). + +use axum::extract::{Path, State}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use super::validate::{Issues, Presence}; +use crate::auth::{hash_password, user_json, AdminUser}; +use crate::db::{Patch, UserRole}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{Body, JsonExt}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/users", get(list_users).post(create_user)) + .route("/api/users/{id}", patch(update_user).delete(delete_user)) + .route( + "/api/users/{id}/page-permissions", + get(get_permissions).post(set_permissions), + ) + .route("/api/admin/migrate-permissions", post(migrate_permissions)) +} + +async fn list_users(State(state): State, _admin: AdminUser) -> ApiResult> { + let users = state + .db + .get_all_users() + .await + .or_500("Erreur lors de la récupération des utilisateurs")?; + Ok(Json(Value::Array(users.iter().map(user_json).collect()))) +} + +async fn create_user( + State(state): State, + _admin: AdminUser, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la création de l'utilisateur"; + + let mut issues = Issues::new(); + let username = issues.string_min( + &body, + "username", + Presence::Required, + 3, + "Le nom d'utilisateur doit contenir au moins 3 caractères", + ); + let password = issues.string_min( + &body, + "password", + Presence::Required, + 4, + "Le mot de passe doit contenir au moins 4 caractères", + ); + let role = issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]); + if !issues.is_empty() { + tracing::error!("Error creating user: {}", issues.zod_message()); + return Err(AppError::bad_request( + issues + .first_message() + .unwrap_or_else(|| "Données invalides".into()), + )); + } + let (Some(username), Some(password)) = (username.into_option(), password.into_option()) else { + return Err(AppError::bad_request("Données invalides")); + }; + + if state + .db + .get_user_by_username(&username) + .await + .or_500(FAILED)? + .is_some() + { + return Err(AppError::conflict("Ce nom d'utilisateur existe déjà")); + } + + let hash = hash_password(&password).await.or_500(FAILED)?; + let role = role + .into_option() + .and_then(|r| UserRole::parse(&r)) + .unwrap_or(UserRole::User); + let user = state + .db + .create_user(&username, &hash, role) + .await + .or_500(FAILED)?; + Ok(Json(user_json(&user))) +} + +async fn update_user( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la modification de l'utilisateur"; + + let existing = state + .db + .get_user(&user_id) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::not_found("Utilisateur non trouvé"))?; + + let mut patch = Patch::new(); + + if let Some(username) = body.str_field("username").filter(|u| !u.is_empty()) { + if username != existing.username { + if let Some(other) = state + .db + .get_user_by_username(username) + .await + .or_500(FAILED)? + { + if other.id != user_id { + return Err(AppError::conflict("Ce nom d'utilisateur est déjà utilisé")); + } + } + patch = patch.set("username", username); + } + } else if body.truthy("username") { + // Valeur non textuelle : l'ancienne route échouait à l'écriture + return Err(AppError::internal(FAILED)); + } + + if body.truthy("password") { + let Some(password) = body.str_field("password") else { + return Err(AppError::internal(FAILED)); + }; + let hash = hash_password(password).await.or_500(FAILED)?; + patch = patch.set("password", hash); + } + + if let Some(role) = body.str_field("role").and_then(UserRole::parse) { + patch = patch.set("role", role); + } + + if patch.is_empty() { + return Err(AppError::bad_request("Aucune modification fournie")); + } + + let updated = state + .db + .update_user(&user_id, patch) + .await + .or_500(FAILED)? + .ok_or_else(|| AppError::internal(FAILED))?; + Ok(Json(user_json(&updated))) +} + +async fn delete_user( + State(state): State, + admin: AdminUser, + Path(user_id): Path, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la suppression de l'utilisateur"; + + if user_id == admin.id { + return Err(AppError::bad_request( + "Vous ne pouvez pas supprimer votre propre compte", + )); + } + if state.db.get_user(&user_id).await.or_500(FAILED)?.is_none() { + return Err(AppError::not_found("Utilisateur non trouvé")); + } + state.db.delete_user(&user_id).await.or_500(FAILED)?; + Ok(Json( + json!({ "success": true, "message": "Utilisateur supprimé avec succès" }), + )) +} + +async fn get_permissions( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, +) -> ApiResult> { + let permissions = state + .db + .get_user_page_permissions(&user_id) + .await + .or_500("Erreur lors de la récupération des permissions")?; + Ok(Json(json!(permissions))) +} + +async fn set_permissions( + State(state): State, + _admin: AdminUser, + Path(user_id): Path, + Body(body): Body, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la mise à jour des permissions"; + + let Some(page_ids) = body.get("pageIds").and_then(Value::as_array) else { + return Err(AppError::bad_request("pageIds doit être un tableau")); + }; + // Identifiants non textuels : l'insertion échouait côté base + let page_ids: Vec<&str> = page_ids + .iter() + .map(Value::as_str) + .collect::>() + .ok_or_else(|| AppError::internal(FAILED))?; + + state + .db + .delete_all_user_page_permissions(&user_id) + .await + .or_500(FAILED)?; + let mut permissions = Vec::with_capacity(page_ids.len()); + for page_id in page_ids { + permissions.push( + state + .db + .create_page_permission(&user_id, page_id) + .await + .or_500(FAILED)?, + ); + } + Ok(Json(json!(permissions))) +} + +/// Donne à chaque utilisateur l'accès aux pages qu'il a lui-même connectées. +async fn migrate_permissions( + State(state): State, + _admin: AdminUser, +) -> ApiResult> { + const FAILED: &str = "Erreur lors de la migration des permissions"; + + let mut migrated = 0; + for user in state.db.get_all_users().await.or_500(FAILED)? { + for page in state.db.get_social_pages(&user.id).await.or_500(FAILED)? { + let existing = state + .db + .get_user_page_permissions(&user.id) + .await + .or_500(FAILED)?; + if !existing.iter().any(|p| p.page_id == page.id) { + state + .db + .create_page_permission(&user.id, &page.id) + .await + .or_500(FAILED)?; + migrated += 1; + } + } + } + Ok(Json(json!({ + "success": true, + "message": format!("{migrated} permissions migrées avec succès"), + "migratedCount": migrated, + }))) +} diff --git a/server/src/routes/core/validate.rs b/server/src/routes/core/validate.rs new file mode 100644 index 0000000..76fb9b4 --- /dev/null +++ b/server/src/routes/core/validate.rs @@ -0,0 +1,304 @@ +//! Validation des corps de requête à la manière des schémas zod +//! (`createInsertSchema` de drizzle-zod) : mêmes champs requis, mêmes types et +//! mêmes messages d'erreur (« Required », « Expected string, received number »…). + +use serde_json::{json, Value}; + +use crate::time::Ts; + +/// Champ absent (`undefined`), `null` ou présent. +#[derive(Debug, Clone, PartialEq)] +pub enum Field { + Absent, + Null, + Set(T), +} + +impl Field { + pub fn into_option(self) -> Option { + match self { + Field::Set(v) => Some(v), + _ => None, + } + } + + pub fn is_absent(&self) -> bool { + matches!(self, Field::Absent) + } +} + +/// Le champ peut-il manquer, être `null` ? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presence { + Required, + Optional, + Nullable, +} + +/// Liste des problèmes relevés, dans l'ordre du schéma. +#[derive(Debug, Default)] +pub struct Issues(Vec); + +/// Type reçu, au sens de zod (`getParsedType`). +fn received(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::String(_)) => "string", + Some(Value::Number(_)) => "number", + Some(Value::Bool(_)) => "boolean", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +impl Issues { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Message du premier problème (`error.errors[0].message`). + pub fn first_message(&self) -> Option { + self.0 + .first() + .and_then(|i| i.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + } + + /// `ZodError.message` : la liste des problèmes en JSON indenté. + pub fn zod_message(&self) -> String { + serde_json::to_string_pretty(&self.0).unwrap_or_default() + } + + pub fn push_invalid_type(&mut self, key: &str, expected: &str, value: Option<&Value>) { + let received = received(value); + let message = if value.is_none() { + "Required".to_string() + } else { + format!("Expected {expected}, received {received}") + }; + self.0.push(json!({ + "code": "invalid_type", + "expected": expected, + "received": received, + "path": [key], + "message": message, + })); + } + + /// Gère absence et `null` ; `Some(valeur)` s'il faut encore la vérifier. + fn presence<'a>( + &mut self, + body: &'a Value, + key: &str, + expected: &str, + presence: Presence, + ) -> Result<&'a Value, Option>> { + match body.get(key) { + None if presence == Presence::Required => { + self.push_invalid_type(key, expected, None); + Err(None) + } + None => Err(Some(Field::Absent)), + Some(Value::Null) if presence == Presence::Nullable => Err(Some(Field::Null)), + Some(value) => Ok(value), + } + } + + fn convert(outcome: Option>) -> Field { + match outcome { + Some(Field::Null) => Field::Null, + _ => Field::Absent, + } + } + + pub fn string(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "string", presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::String(s)) => Field::Set(s.clone()), + Ok(other) => { + self.push_invalid_type(key, "string", Some(other)); + Field::Absent + } + } + } + + /// Chaîne d'au moins `min` caractères (longueur JavaScript). + pub fn string_min( + &mut self, + body: &Value, + key: &str, + presence: Presence, + min: usize, + message: &str, + ) -> Field { + let field = self.string(body, key, presence); + if let Field::Set(s) = &field { + if s.encode_utf16().count() < min { + self.0.push(json!({ + "code": "too_small", + "minimum": min, + "type": "string", + "inclusive": true, + "exact": false, + "message": message, + "path": [key], + })); + } + } + field + } + + pub fn enumeration( + &mut self, + body: &Value, + key: &str, + presence: Presence, + options: &[&str], + ) -> Field { + let expected = options + .iter() + .map(|o| format!("'{o}'")) + .collect::>() + .join(" | "); + match self.presence(body, key, &expected, presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::String(s)) if options.contains(&s.as_str()) => Field::Set(s.clone()), + Ok(Value::String(s)) => { + self.0.push(json!({ + "received": s, + "code": "invalid_enum_value", + "options": options, + "path": [key], + "message": format!("Invalid enum value. Expected {expected}, received '{s}'"), + })); + Field::Absent + } + Ok(other) => { + self.push_invalid_type(key, &expected, Some(other)); + Field::Absent + } + } + } + + /// Entier 32 bits (`integer` PostgreSQL). + pub fn int(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "number", presence) { + Err(outcome) => Self::convert(outcome), + Ok(Value::Number(n)) => { + let f = n.as_f64().unwrap_or(f64::NAN); + if f.fract() != 0.0 { + self.0.push(json!({ + "code": "invalid_type", + "expected": "integer", + "received": "float", + "path": [key], + "message": "Expected integer, received float", + })); + return Field::Absent; + } + if f < i32::MIN as f64 { + self.0.push(json!({ + "code": "too_small", "minimum": i32::MIN, "type": "number", "inclusive": true, "exact": false, + "path": [key], "message": format!("Number must be greater than or equal to {}", i32::MIN), + })); + return Field::Absent; + } + if f > i32::MAX as f64 { + self.0.push(json!({ + "code": "too_big", "maximum": i32::MAX, "type": "number", "inclusive": true, "exact": false, + "path": [key], "message": format!("Number must be less than or equal to {}", i32::MAX), + })); + return Field::Absent; + } + Field::Set(f as i32) + } + Ok(other) => { + self.push_invalid_type(key, "number", Some(other)); + Field::Absent + } + } + } + + /// Champ `z.date()` : un corps JSON ne contient jamais d'objet Date, donc + /// toute valeur présente est refusée (seuls absence et `null` passent). + pub fn date(&mut self, body: &Value, key: &str, presence: Presence) -> Field { + match self.presence(body, key, "date", presence) { + Err(outcome) => Self::convert(outcome), + Ok(other) => { + self.push_invalid_type(key, "date", Some(other)); + Field::Absent + } + } + } + + /// Date déjà convertie par la route (`new Date(chaîne)`) : invalide si illisible. + pub fn converted_date(&mut self, key: &str, value: Option) -> Option { + if value.is_none() { + self.0 + .push(json!({ "code": "invalid_date", "path": [key], "message": "Invalid date" })); + } + value + } + + /// Champ JSON libre (`jsonb`) : toute valeur JSON est acceptée. + pub fn json(&mut self, body: &Value, key: &str) -> Field { + match body.get(key) { + None => Field::Absent, + Some(Value::Null) => Field::Null, + Some(value) => Field::Set(value.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_and_type_messages() { + let body = json!({ "password": 12 }); + let mut issues = Issues::new(); + issues.string(&body, "username", Presence::Required); + issues.string(&body, "password", Presence::Required); + assert_eq!(issues.first_message().as_deref(), Some("Required")); + assert!(issues + .zod_message() + .contains("\"message\": \"Expected string, received number\"")); + } + + #[test] + fn min_length_and_enum() { + let body = json!({ "username": "ab", "role": "boss" }); + let mut issues = Issues::new(); + issues.string_min(&body, "username", Presence::Required, 3, "trop court"); + issues.enumeration(&body, "role", Presence::Optional, &["admin", "user"]); + assert_eq!(issues.first_message().as_deref(), Some("trop court")); + assert!(issues + .zod_message() + .contains("Invalid enum value. Expected 'admin' | 'user', received 'boss'")); + } + + #[test] + fn nullable_and_dates() { + let body = json!({ "a": null, "d": "2024-01-01", "n": 1.5 }); + let mut issues = Issues::new(); + assert_eq!(issues.string(&body, "a", Presence::Nullable), Field::Null); + assert_eq!(issues.string(&body, "b", Presence::Nullable), Field::Absent); + assert!(issues.is_empty()); + issues.date(&body, "d", Presence::Optional); + issues.int(&body, "n", Presence::Optional); + assert_eq!( + issues.first_message().as_deref(), + Some("Expected date, received string") + ); + assert!(issues + .zod_message() + .contains("Expected integer, received float")); + } +} diff --git a/server/src/services/openrouter.rs b/server/src/services/openrouter.rs index 34aa97b..821cc83 100644 --- a/server/src/services/openrouter.rs +++ b/server/src/services/openrouter.rs @@ -1,13 +1,20 @@ //! Génération de textes par IA (OpenRouter). //! -//! STUB — à porter depuis `server/services/openrouter.ts`. -#![allow(unused_variables)] +//! Portage de `server/services/openrouter.ts` : trois variantes de texte sont +//! demandées au modèle, puis extraites de sa réponse en essayant plusieurs +//! formats d'en-têtes (« VERSION 1 - DYNAMIQUE », en gras Markdown ou non). use axum::http::StatusCode; -use serde_json::Value; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use crate::state::AppState; +const CHAT_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +const MODELS_URL: &str = "https://openrouter.ai/api/v1/models"; + /// Génère les variantes de texte (`{ variant, text, characterCount }`). pub async fn generate_post_text( state: &AppState, @@ -15,10 +22,478 @@ pub async fn generate_post_text( user_id: &str, model_override: Option<&str>, ) -> anyhow::Result> { - anyhow::bail!("Configuration OpenRouter non trouvée") + // Configuration de l'utilisateur, sinon la dernière configuration enregistrée + let config = match state.db.get_openrouter_config(user_id).await? { + Some(config) => Some(config), + None => state.db.get_any_openrouter_config().await?, + }; + let Some(config) = config else { + anyhow::bail!( + "Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres." + ); + }; + if config.api_key.trim().is_empty() { + anyhow::bail!( + "Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres." + ); + } + + let prompt = build_prompt(product_info, &config.system_prompt); + let model = model_override + .filter(|m| !m.is_empty()) + .unwrap_or(&config.model) + .to_string(); + + let fingerprint = hex::encode(Sha256::digest(config.api_key.as_bytes())); + tracing::info!( + "[OpenRouter] Generating with model=\"{model}\" keyFingerprint={} keyLength={} configUserId={}", + &fingerprint[..8], + config.api_key.chars().count(), + config.user_id + ); + + let referer = state + .config + .app_url + .clone() + .unwrap_or_else(|| "http://localhost:5555".into()); + let response = state + .http + .post(CHAT_URL) + .bearer_auth(config.api_key.trim()) + .header("HTTP-Referer", referer) + .header("X-Title", "Social Flow") + .json(&json!({ + "model": model, + "messages": [{ "role": "user", "content": prompt }], + "temperature": 0.8, + "max_tokens": 1000, + })) + .send() + .await + .inspect_err(|e| tracing::error!("Error generating text with OpenRouter: {e}"))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + tracing::error!("OpenRouter API Error Response: {body}"); + // Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle + // inconnu…) est la seule information exploitable pour l'utilisateur. + anyhow::bail!( + "OpenRouter a refusé la requête ({} {}) avec le modèle \"{model}\" : {}", + status.as_u16(), + status.canonical_reason().unwrap_or(""), + extract_upstream_message(&body) + ); + } + + let data: Value = response.json().await?; + let content = match data.get("choices").and_then(|c| c.get(0)) { + Some(choice) if !choice.is_null() => choice + .pointer("/message/content") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + _ => { + tracing::error!( + "Invalid OpenRouter response - no choices (hasChoices: {}, model: {model})", + data.get("choices").is_some() + ); + anyhow::bail!( + "Réponse invalide de l'API OpenRouter. Vérifiez votre clé API et votre modèle." + ); + } + }; + + Ok(parse_generated_text(&content)) } /// Statut HTTP et message à renvoyer pour une erreur de génération. pub fn describe_generation_error(error: &anyhow::Error) -> (StatusCode, String) { - (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) + let message = error.to_string(); + // Problème de configuration : l'administrateur peut le corriger lui-même + if message.contains("Configuration OpenRouter non trouvée") + || message.contains("Clé API OpenRouter manquante") + { + return (StatusCode::BAD_REQUEST, message); + } + // Refus du service tiers + if message.contains("OpenRouter a refusé la requête") + || message.contains("Réponse invalide de l'API OpenRouter") + { + return (StatusCode::BAD_GATEWAY, message); + } + (StatusCode::INTERNAL_SERVER_ERROR, message) +} + +/// Modèles proposés par OpenRouter (champ `data` de leur API). +pub async fn get_available_models(state: &AppState) -> anyhow::Result> { + let response = state.http.get(MODELS_URL).send().await?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!( + "Failed to fetch models: {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ); + } + let data: Value = response.json().await?; + Ok(match data.get("data") { + Some(Value::Array(models)) => models.clone(), + _ => Vec::new(), + }) +} + +/// Message d'erreur d'une réponse OpenRouter (JSON `{ error: { message } }` ou +/// texte brut), tronqué pour ne pas déverser une page HTML dans l'interface. +fn extract_upstream_message(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return "aucun détail fourni".into(); + } + if let Ok(parsed) = serde_json::from_str::(trimmed) { + let candidates = [ + parsed.pointer("/error/message"), + parsed.get("message"), + parsed.get("error"), + ]; + // Premier champ « truthy », comme `a || b || c` + let message = candidates + .into_iter() + .flatten() + .find(|v| crate::extract::is_truthy(Some(v))); + if let Some(Value::String(message)) = message { + if !message.trim().is_empty() { + return truncate_utf16(message.trim(), 300); + } + } + } + static SPACES: Lazy = Lazy::new(|| Regex::new(r"\s+").unwrap()); + truncate_utf16(&SPACES.replace_all(trimmed, " "), 300) +} + +/// `text.slice(0, max)` en unités UTF-16, sans couper un caractère. +fn truncate_utf16(text: &str, max: usize) -> String { + let mut units = 0; + let mut out = String::new(); + for c in text.chars() { + units += c.len_utf16(); + if units > max { + break; + } + out.push(c); + } + out +} + +/// Longueur JavaScript (`string.length`) : nombre d'unités UTF-16. +fn js_length(text: &str) -> usize { + text.encode_utf16().count() +} + +/// Conversion d'une valeur en texte comme un gabarit JavaScript (`${value}`). +fn js_string(value: Option<&Value>) -> String { + match value { + None => "undefined".into(), + Some(Value::Null) => "null".into(), + Some(Value::String(s)) => s.clone(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::Number(n)) => js_number(n), + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + other => js_string(Some(other)), + }) + .collect::>() + .join(","), + Some(Value::Object(_)) => "[object Object]".into(), + } +} + +fn js_number(n: &serde_json::Number) -> String { + if let Some(i) = n.as_i64() { + return i.to_string(); + } + match n.as_f64() { + Some(f) if f.fract() == 0.0 && f.abs() < 1e21 => format!("{f:.0}"), + Some(f) => f.to_string(), + None => n.to_string(), + } +} + +fn build_prompt(product_info: &Value, system_prompt: &str) -> String { + let truthy = |key: &str| crate::extract::is_truthy(product_info.get(key)); + // `features?.join(", ")` + let features = match product_info.get("features") { + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Null => String::new(), + other => js_string(Some(other)), + }) + .collect::>() + .join(", "), + _ => String::new(), + }; + + let description = if truthy("description") { + format!( + "Description: {}", + js_string(product_info.get("description")) + ) + } else { + String::new() + }; + let price = if truthy("price") { + format!("Prix: {}", js_string(product_info.get("price"))) + } else { + String::new() + }; + let features = if features.is_empty() { + String::new() + } else { + format!("Caractéristiques: {features}") + }; + let product_details = format!( + "Nom: {}\n{description}\n{price}\n{features}", + js_string(product_info.get("name")) + ); + + format!( + "{system_prompt}\n\nInformations produit:\n{product_details}\n\nFormat de réponse:\nVERSION 1 - DYNAMIQUE:\n[texte]\n\nVERSION 2 - INFORMATIVE:\n[texte]\n\nVERSION 3 - ÉMOTIONNELLE:\n[texte]" + ) +} + +fn variant(name: &str, text: &str) -> Value { + json!({ "variant": name, "text": text, "characterCount": js_length(text) }) +} + +fn parse_generated_text(content: &str) -> Vec { + let normalized = content.replace("\r\n", "\n"); + let normalized = normalized.trim(); + tracing::info!( + "🔍 Parsing AI response (length: {} )", + js_length(normalized) + ); + + let variants = try_multiple_parsing_strategies(normalized); + if !variants.is_empty() { + tracing::info!("✅ Parsed successfully: {} variants", variants.len()); + return variants; + } + + tracing::info!("⚠️ Parsing failed, using fallback"); + vec![variant("Généré par IA", normalized)] +} + +fn try_multiple_parsing_strategies(content: &str) -> Vec { + let strategy1 = parse_by_version_headers(content); + if strategy1.len() >= 3 { + return strategy1; + } + let strategy2 = parse_with_headers(content, true); + if strategy2.len() >= 3 { + return strategy2; + } + let strategy3 = parse_with_headers(content, false); + if strategy3.len() >= 3 { + return strategy3; + } + if !strategy1.is_empty() { + strategy1 + } else if !strategy2.is_empty() { + strategy2 + } else { + strategy3 + } +} + +fn parse_by_version_headers(content: &str) -> Vec { + static HEADER: Lazy = Lazy::new(|| { + Regex::new(r"(?i)(?:\*{2})?VERSION\s+([0-9]+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?") + .unwrap() + }); + static SEPARATORS: Lazy = Lazy::new(|| Regex::new(r"(?m)^[-*]{3,}\s*").unwrap()); + static NOTES: Lazy = Lazy::new(|| Regex::new(r"(?m)^\([^)]+\)\s*").unwrap()); + + let matches: Vec = HEADER.captures_iter(content).collect(); + let mut variants = Vec::new(); + if matches.len() < 3 { + return variants; + } + for (i, caps) in matches.iter().enumerate() { + let whole = caps.get(0).expect("groupe 0"); + let end = matches + .get(i + 1) + .and_then(|next| next.get(0)) + .map(|m| m.start()) + .unwrap_or(content.len()); + let text = content[whole.end()..end].trim(); + let text = SEPARATORS.replace_all(text, ""); + let text = NOTES.replace_all(&text, ""); + let text = text.trim(); + if !text.is_empty() { + let style = capitalize_first(&caps[2].to_lowercase()); + variants.push(variant(&format!("Version {} - {style}", &caps[1]), text)); + } + } + variants +} + +/// Stratégies 2 et 3 : en-têtes attendus un par un, en gras Markdown ou non. +/// Les regex JavaScript utilisaient un lookahead `(?=VERSION 2|$)`, que l'on +/// reproduit en cherchant l'en-tête suivant à partir de la fin du courant. +fn parse_with_headers(content: &str, markdown: bool) -> Vec { + static MD: Lazy<[Regex; 5]> = Lazy::new(|| { + [ + Regex::new(r"(?i)\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+2").unwrap(), + Regex::new(r"(?i)\*{2}VERSION\s+3").unwrap(), + ] + }); + static PLAIN: Lazy<[Regex; 5]> = Lazy::new(|| { + [ + Regex::new(r"(?i)VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n").unwrap(), + Regex::new(r"(?i)VERSION\s+2").unwrap(), + Regex::new(r"(?i)VERSION\s+3").unwrap(), + ] + }); + let [v1, v2, v3, stop2, stop3] = if markdown { &*MD } else { &*PLAIN }; + + let section = |header: &Regex, stop: Option<&Regex>| -> Option { + let found = header.find(content)?; + let end = stop + .and_then(|s| s.find_at(content, found.end())) + .map(|m| m.start()) + .unwrap_or(content.len()); + Some(content[found.end()..end].trim().to_string()) + }; + + let mut variants = Vec::new(); + if let Some(text) = section(v1, Some(stop2)) { + variants.push(variant("Version 1 - Dynamique", &text)); + } + if let Some(text) = section(v2, Some(stop3)) { + variants.push(variant("Version 2 - Informative", &text)); + } + if let Some(text) = section(v3, None) { + variants.push(variant("Version 3 - Émotionnelle", &text)); + } + variants +} + +fn capitalize_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + &chars.as_str().to_lowercase(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn texts(variants: &[Value]) -> Vec<(String, String)> { + variants + .iter() + .map(|v| { + ( + v["variant"].as_str().unwrap().to_string(), + v["text"].as_str().unwrap().to_string(), + ) + }) + .collect() + } + + #[test] + fn parses_plain_version_headers() { + let content = "VERSION 1 - DYNAMIQUE:\nSuper promo !\n\nVERSION 2 - INFORMATIVE:\n(note)\nProduit de qualité.\n---\nVERSION 3 - ÉMOTIONNELLE:\nUn coup de cœur 😍"; + let parsed = parse_generated_text(content); + assert_eq!( + texts(&parsed), + vec![ + ("Version 1 - Dynamique".into(), "Super promo !".into()), + ( + "Version 2 - Informative".into(), + "Produit de qualité.".into() + ), + ( + "Version 3 - Émotionnelle".into(), + "Un coup de cœur 😍".into() + ), + ] + ); + // Longueur JavaScript : l'émoji compte pour deux + assert_eq!(parsed[2]["characterCount"], 18); + } + + #[test] + fn parses_markdown_headers() { + let content = "**VERSION 1 – DYNAMIQUE**\nA\n\n**VERSION 2 – INFORMATIVE**\nB\n\n**VERSION 3 – émotionnelle**\nC"; + let parsed = parse_generated_text(content); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0]["text"], "A"); + assert_eq!(parsed[2]["variant"], "Version 3 - Émotionnelle"); + } + + #[test] + fn fallback_returns_whole_text() { + let parsed = parse_generated_text(" Juste un texte\r\n "); + assert_eq!( + texts(&parsed), + vec![("Généré par IA".into(), "Juste un texte".into())] + ); + } + + #[test] + fn markdown_lookahead_strategy() { + // Une seule version reconnue par la stratégie 1 : la stratégie 2 prend le relais + let content = "**VERSION 1 - DYNAMIQUE:**\nUn\n**VERSION 2 - INFORMATIV:**\nDeux\n**VERSION 3 - EMOTIONNELL:**\nTrois"; + let parsed = parse_with_headers(content, true); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[1]["text"], "Deux"); + } + + #[test] + fn prompt_matches_template() { + let prompt = build_prompt( + &json!({ "name": "Vase", "price": 12, "features": ["bleu", "verre"] }), + "SYS", + ); + assert!(prompt.starts_with("SYS\n\nInformations produit:\nNom: Vase\n\nPrix: 12\nCaractéristiques: bleu, verre\n\nFormat de réponse:")); + let missing = build_prompt(&json!({}), "S"); + assert!(missing.contains("Nom: undefined\n\n\n\n")); + } + + #[test] + fn upstream_messages() { + assert_eq!(extract_upstream_message(""), "aucun détail fourni"); + assert_eq!( + extract_upstream_message(r#"{"error":{"message":" Invalid key "}}"#), + "Invalid key" + ); + assert_eq!( + extract_upstream_message("\n oops "), + " oops " + ); + } + + #[test] + fn generation_error_statuses() { + let e = anyhow::anyhow!("Configuration OpenRouter non trouvée. Veuillez…"); + assert_eq!(describe_generation_error(&e).0, StatusCode::BAD_REQUEST); + let e = anyhow::anyhow!("OpenRouter a refusé la requête (401 Unauthorized)"); + assert_eq!(describe_generation_error(&e).0, StatusCode::BAD_GATEWAY); + let e = anyhow::anyhow!("boom"); + assert_eq!( + describe_generation_error(&e), + (StatusCode::INTERNAL_SERVER_ERROR, "boom".into()) + ); + } }