Serveur Rust : routes principales et service OpenRouter

Portage des routes définies dans server/routes.ts (hors authentification
et routeurs montés à part) : utilisateurs et permissions, migration des
permissions, paramètres (API externe, Gemini, Qwen et test), console SQL
(résultat au format pg, plusieurs instructions), statistiques, IA
(modèles, génération, historique), médias (envoi, liste paginée avec
X-Total-Count, correction des URL, incrustations, suppression), pistes
audio, posts, publications planifiées et agenda, pages, stockage et logo,
OpenRouter et FFmpeg.

Les incrustations (bandeau, prix, logo) sont rendues en SVG avec resvg et
composées avec image ; l'orientation EXIF est appliquée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:41:04 +00:00
1 parent f9828d4887
commit e221940ee4
16 files changed
+4913 -6

No files matched your search

+334
View File
@@ -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<String>,
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<String>,
}
/// 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::<Vec<_>>()
.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<Ts>, end: Option<Ts>) -> 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<Ts>, 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<String, AgendaPage>,
start: Option<Ts>,
end: Option<Ts>,
) -> Vec<AgendaItem> {
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<String, AgendaPage> {
[
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<Ts>, 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::<Vec<_>>(),
["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));
}
}
+187
View File
@@ -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<AppState> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
// `{ 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<AppState>, admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
_user: AuthUser,
) -> ApiResult<Json<Value>> {
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::<Value>().await?)
}
.await;
result.or_500("Failed to fetch OpenRouter models").map(Json)
}
async fn get_config(State(state): State<AppState>, admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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)))
}
+168
View File
@@ -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<AppState> {
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<Regex> = 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<AppState>,
admin: AdminUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>, _user: AuthUser) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
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"
);
}
}
+253
View File
@@ -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<AppState> {
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<AppState>,
_user: AuthUser,
) -> ApiResult<Json<Value>> {
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<String>) -> 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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
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<Value> {
// 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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> Json<Value> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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,
})))
}
+247
View File
@@ -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<AppState> {
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<Vec<UploadedFile>, 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<Regex> =
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<AppState>,
user: AuthUser,
multipart: Multipart,
) -> ApiResult<Json<Value>> {
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<crate::db::Media> {
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<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
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<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Response> {
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<AppState>,
user: AuthUser,
Path(media_id): Path<String>,
) -> ApiResult<Json<Value>> {
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"
);
}
}
+120 -1
View File
@@ -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<AppState> {
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<i64> {
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<Ts> {
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"
);
}
}
+611
View File
@@ -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<AppState> {
Router::new().route("/api/media/apply-overlays", post(apply_overlays))
}
async fn apply_overlays(
State(state): State<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<Ribbon>,
badge: Option<PriceBadge>,
logo: Option<LogoSpec>,
}
#[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<u8>,
size: String,
position: String,
/// Opacité en pourcentage ; `None` = logo opaque.
opacity: Option<f64>,
}
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<Value> {
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<LogoSpec> {
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<DynamicImage> {
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<Vec<u8>> {
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
c => out.push(c),
}
}
out
}
fn ribbon_svg(ribbon: &Ribbon, g: &RibbonGeometry) -> String {
format!(
r#"<svg width="{size}" height="{size}" xmlns="http://www.w3.org/2000/svg"><polygon points="{polygon}" fill="{color}"/><text x="{tx}" y="{ty}" font-family="Arial, sans-serif" font-size="{font}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle" transform="rotate({rot}, {tx}, {ty})">{text}</text></svg>"#,
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#"<svg width="{w}" height="{h}" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="0" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{color}"/><text x="{cx}" y="{r}" font-family="Arial, sans-serif" font-size="{font}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="middle">{text}</text></svg>"#,
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<usvg::fontdb::Database> {
static DB: OnceLock<Arc<usvg::fontdb::Database>> = 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<RgbaImage> {
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&amp;B &lt;&quot;c&quot;&gt;");
let ribbon = Ribbon {
text: "<b>".into(),
color: RED,
position: "north_west".into(),
};
let svg = ribbon_svg(&ribbon, &ribbon_geometry("north_west", 300, 300));
assert!(svg.contains("&lt;b&gt;</text>"));
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:?}"
);
}
}
+240
View File
@@ -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<AppState> {
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<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
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<String>,
platform: Field<String>,
page_id: Field<String>,
page_name: Field<String>,
access_token: Field<String>,
followers_count: Field<i32>,
is_active: Field<String>,
user_access_token: Field<String>,
refresh_token: Field<String>,
scopes: Field<String>,
avatar_url: Field<String>,
}
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<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<String>) -> 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<AppState>,
user: AuthUser,
Path(page_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
user: AuthUser,
Path(page_id): Path<String>,
) -> ApiResult<Json<Value>> {
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 })))
}
+488
View File
@@ -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<AppState> {
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<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
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<Vec<(String, i32)>, 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<String>,
product_info: Option<Value>,
status: PostStatus,
scheduled_for: Option<Ts>,
generation_status: Option<String>,
generation_progress: Option<i32>,
generation_error: Option<String>,
}
/// Valide les champs du post ; l'erreur est le message de `ZodError`.
fn parse_post_fields(body: &Value) -> Result<PostFields, String> {
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<AppState>,
user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<Result<Post, AppError>, 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<Value>> = 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<crate::db::Media>)) -> Value {
json!({ "post": post, "media": media })
}
async fn get_post(
State(state): State<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<String> = 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"));
}
}
+253
View File
@@ -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<AppState> {
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<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Json<Value>> {
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<String> = pages.iter().map(|p| p.id.clone()).collect();
let pages_by_id: HashMap<String, AgendaPage> = 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<AgendaItem> = 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<String> = 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<AppState>,
user: AuthUser,
query: QueryMap,
) -> ApiResult<Json<Value>> {
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<String> = 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<ScheduledPost, AppError> {
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<AppState>,
user: AuthUser,
Path(id): Path<String>,
) -> ApiResult<Json<Value>> {
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<Ts> {
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<AppState>,
user: AuthUser,
Path(id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_user: AuthUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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)))
}
+207
View File
@@ -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<AppState> {
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<String>) -> 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<String, AppError> {
body.trimmed("apiKey")
.ok_or_else(|| AppError::bad_request("La clé API est requise"))
}
async fn get_external_api(
State(state): State<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> Json<Value> {
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<Value> {
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))
}
+645
View File
@@ -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<AppState> {
Router::new()
.route("/api/sql/execute", post(execute))
.route("/api/sql/tables", get(tables))
}
async fn execute(
State(state): State<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
let names: Result<Vec<String>, 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::<Vec<_>>(),
}))),
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<Value>,
fields: Vec<Value>,
}
/// 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<Value, sqlx::Error> {
let commands = statement_commands(query);
let mut results: Vec<Value> = 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<String, Value> = 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<Value> {
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::<i64>() {
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::<f64>() {
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::<f64>().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<char> = 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<Value> {
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<String> {
split_statements(query)
.iter()
.filter_map(|statement| {
let word: String = statement
.trim_start()
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect::<String>()
.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<String> {
let chars: Vec<char> = 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(&quote) {
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<char> = 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::<String>::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
);
}
}
+122
View File
@@ -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<AppState> {
Router::new().route("/api/stats", get(stats))
}
async fn stats(State(state): State<AppState>, user: AuthUser) -> ApiResult<Json<Value>> {
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"));
}
}
+254
View File
@@ -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<AppState> {
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<AppState>, _admin: AdminUser) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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<AppState>,
admin: AdminUser,
Path(user_id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
) -> ApiResult<Json<Value>> {
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<AppState>,
_admin: AdminUser,
Path(user_id): Path<String>,
Body(body): Body<Value>,
) -> ApiResult<Json<Value>> {
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::<Option<_>>()
.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<AppState>,
_admin: AdminUser,
) -> ApiResult<Json<Value>> {
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,
})))
}
+304
View File
@@ -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<T> {
Absent,
Null,
Set(T),
}
impl<T> Field<T> {
pub fn into_option(self) -> Option<T> {
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<Value>);
/// 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<String> {
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<Field<()>>> {
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<T>(outcome: Option<Field<()>>) -> Field<T> {
match outcome {
Some(Field::Null) => Field::Null,
_ => Field::Absent,
}
}
pub fn string(&mut self, body: &Value, key: &str, presence: Presence) -> Field<String> {
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<String> {
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<String> {
let expected = options
.iter()
.map(|o| format!("'{o}'"))
.collect::<Vec<_>>()
.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<i32> {
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<Ts> {
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<Ts>) -> Option<Ts> {
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<Value> {
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"));
}
}
+480 -5
View File
@@ -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<Vec<Value>> {
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<Vec<Value>> {
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::<Value>(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<Regex> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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<Value> {
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<Value> {
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<Value> {
static HEADER: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:\*{2})?VERSION\s+([0-9]+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?")
.unwrap()
});
static SEPARATORS: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^[-*]{3,}\s*").unwrap());
static NOTES: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\([^)]+\)\s*").unwrap());
let matches: Vec<regex::Captures> = 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<Value> {
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<String> {
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::<String>() + &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("<html>\n oops </html>"),
"<html> oops </html>"
);
}
#[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())
);
}
}