Merge commit 'e221940' into claude/intelligent-wright-pr07e9

This commit is contained in:
Claude committed 2026-10-12 17:41:26 +00:00
commit a0f800101c
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())
);
}
}