mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 12:46:43 +02:00
Merge commit 'e221940' into claude/intelligent-wright-pr07e9
This commit is contained in:
16 files changed
+4913
-6
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ribbon_svg(ribbon: &Ribbon, g: &RibbonGeometry) -> String {
|
||||
format!(
|
||||
r#"<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&B <"c">");
|
||||
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("<b></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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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("e) {
|
||||
current.push(quote);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
'-' if chars.get(i + 1) == Some(&'-') => {
|
||||
while i < chars.len() && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
current.push(' ');
|
||||
}
|
||||
'/' if chars.get(i + 1) == Some(&'*') => {
|
||||
let mut depth = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '/' && chars.get(i + 1) == Some(&'*') {
|
||||
depth += 1;
|
||||
i += 2;
|
||||
} else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') {
|
||||
depth -= 1;
|
||||
i += 2;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
current.push(' ');
|
||||
}
|
||||
'$' => {
|
||||
// Étiquette de chaîne dollar : $$ ou $tag$
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
|
||||
j += 1;
|
||||
}
|
||||
let is_tag = chars.get(j) == Some(&'$')
|
||||
&& !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit())
|
||||
&& !current
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|p| p.is_alphanumeric() || p == '_');
|
||||
if is_tag {
|
||||
let tag: Vec<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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user