diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4f4c20c..787842b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "allow": [ "Bash(git add:*)", "Bash(git commit:*)", - "Bash(xargs ls:*)" + "Bash(xargs ls:*)", + "Bash(/tmp/check_sse.txt:*)", + "Read(//tmp/**)" ], "defaultMode": "bypassPermissions" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f040844..06bc433 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -61,6 +61,7 @@ dependencies = [ "dotenvy", "ego-tree", "email_address", + "futures", "hex", "http-body-util", "rand", @@ -72,6 +73,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tokio-stream", "tower", "tower-http", "tracing", @@ -726,6 +728,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -770,6 +787,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -788,8 +816,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -2713,6 +2743,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 88935c7..2174aa4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -68,9 +68,13 @@ thiserror = "2" # UUID uuid = { version = "1", features = ["v4", "serde"] } -# Concurrency (for rate limiter) +# Concurrency (for rate limiter + job store) dashmap = "6" +# SSE streaming (for generation progress) +tokio-stream = { version = "0.1", features = ["sync"] } +futures = "0.3" + [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/backend/migrations/20260321000009_create_syntheses.sql b/backend/migrations/20260321000009_create_syntheses.sql new file mode 100644 index 0000000..4611892 --- /dev/null +++ b/backend/migrations/20260321000009_create_syntheses.sql @@ -0,0 +1,12 @@ +-- Create the syntheses table for storing generated news syntheses. + +CREATE TABLE syntheses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week VARCHAR(10) NOT NULL, + sections JSONB NOT NULL DEFAULT '[]'::jsonb, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_syntheses_user_id_created_at ON syntheses(user_id, created_at DESC); diff --git a/backend/src/app_state.rs b/backend/src/app_state.rs index 7af2f65..d54a913 100644 --- a/backend/src/app_state.rs +++ b/backend/src/app_state.rs @@ -4,6 +4,7 @@ use sqlx::PgPool; use crate::config::AppConfig; use crate::services::rate_limiter::{ProviderRateLimiter, RateLimiter}; +use crate::services::synthesis::JobStore; /// Application state shared across all request handlers. /// @@ -18,6 +19,9 @@ pub struct AppState { /// Per-provider rate limiter for LLM API calls. /// Loaded from DB at startup, hot-reloaded when admin updates config. pub provider_rate_limiter: ProviderRateLimiter, + /// In-memory store for active generation jobs. + /// Maps job_id -> progress watch channel. + pub job_store: JobStore, } impl AppState { @@ -29,12 +33,16 @@ impl AppState { // Provider rate limiter: loaded from DB after creation via `reload_from_db` let provider_rate_limiter = ProviderRateLimiter::new(); + // In-memory job store for generation progress tracking + let job_store = JobStore::new(); + Self { config, pool, http_client, auth_rate_limiter, provider_rate_limiter, + job_store, } } } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index be7f0f1..b16e853 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -6,4 +6,5 @@ pub mod rate_limits; pub mod sessions; pub mod settings; pub mod sources; +pub mod syntheses; pub mod users; diff --git a/backend/src/db/syntheses.rs b/backend/src/db/syntheses.rs new file mode 100644 index 0000000..f8bcb18 --- /dev/null +++ b/backend/src/db/syntheses.rs @@ -0,0 +1,123 @@ +//! Database queries for the `syntheses` table. +//! +//! All queries enforce ownership isolation via `user_id` to ensure users +//! can only access their own syntheses. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::errors::AppError; +use crate::models::synthesis::Synthesis; + +/// List syntheses for a user, ordered by creation date (newest first). +/// +/// Supports pagination via `limit` and `offset`. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, Synthesis>( + r#" + SELECT id, user_id, week, sections, status, created_at + FROM syntheses + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +/// Get a synthesis by ID (without ownership check). +/// +/// Used internally when ownership is verified by other means. +pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, AppError> { + let row = sqlx::query_as::<_, Synthesis>( + r#" + SELECT id, user_id, week, sections, status, created_at + FROM syntheses + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +/// Get a synthesis by ID, but only if it belongs to the given user. +/// +/// Returns `None` if the synthesis does not exist or belongs to another user. +pub async fn get_by_id_for_user( + pool: &PgPool, + id: Uuid, + user_id: Uuid, +) -> Result, AppError> { + let row = sqlx::query_as::<_, Synthesis>( + r#" + SELECT id, user_id, week, sections, status, created_at + FROM syntheses + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(id) + .bind(user_id) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +/// Create a new synthesis record. +/// +/// The `sections_json` should be a valid JSONB value representing +/// a `Vec`. +pub async fn create( + pool: &PgPool, + user_id: Uuid, + week: &str, + sections_json: &serde_json::Value, +) -> Result { + let row = sqlx::query_as::<_, Synthesis>( + r#" + INSERT INTO syntheses (user_id, week, sections, status) + VALUES ($1, $2, $3, 'completed') + RETURNING id, user_id, week, sections, status, created_at + "#, + ) + .bind(user_id) + .bind(week) + .bind(sections_json) + .fetch_one(pool) + .await?; + + Ok(row) +} + +/// Delete a synthesis by ID, but only if it belongs to the given user. +/// +/// Returns `true` if a row was deleted, `false` if no matching row was found +/// (either the ID doesn't exist or it belongs to a different user). +pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> Result { + let result = sqlx::query( + r#" + DELETE FROM syntheses + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/backend/src/handlers/generation.rs b/backend/src/handlers/generation.rs new file mode 100644 index 0000000..da24105 --- /dev/null +++ b/backend/src/handlers/generation.rs @@ -0,0 +1,130 @@ +//! Generation handlers: trigger generation and stream progress via SSE. +//! +//! - `POST /api/v1/syntheses/generate` — start async generation +//! - `GET /api/v1/syntheses/generate/:job_id/progress` — SSE progress stream + +use std::convert::Infallible; +use std::time::Duration; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::IntoResponse; +use axum::Json; +use serde::Serialize; +use tokio_stream::wrappers::WatchStream; +use tokio_stream::StreamExt; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::errors::AppError; +use crate::middleware::auth::AuthUser; +use crate::services::synthesis::{self, ProgressEvent}; + +/// Response body for `POST /api/v1/syntheses/generate`. +#[derive(Debug, Serialize)] +pub struct GenerateResponse { + pub job_id: Uuid, + pub message: String, +} + +/// `POST /api/v1/syntheses/generate` +/// +/// Triggers an asynchronous synthesis generation. Returns immediately +/// with a 202 Accepted status and a `job_id` that can be used to +/// subscribe to progress events via SSE. +/// +/// Rejects the request if the user already has a generation in progress. +pub async fn trigger_generate( + auth_user: AuthUser, + State(state): State, +) -> Result { + // Check if user already has an active job + if let Some(existing_job_id) = state.job_store.has_active_job(auth_user.id) { + tracing::warn!( + user_id = %auth_user.id, + existing_job_id = %existing_job_id, + "User tried to start generation while one is already in progress" + ); + return Err(AppError::BadRequest( + "Une generation est deja en cours. Veuillez attendre qu'elle se termine.".into(), + )); + } + + // Create the job in the store + let (job_id, tx) = state + .job_store + .create_job(auth_user.id) + .ok_or_else(|| { + AppError::BadRequest( + "Une generation est deja en cours. Veuillez attendre qu'elle se termine.".into(), + ) + })?; + + tracing::info!( + user_id = %auth_user.id, + job_id = %job_id, + "Starting synthesis generation" + ); + + // Spawn the generation pipeline as a background task + let state_clone = state.clone(); + let user_id = auth_user.id; + tokio::spawn(async move { + synthesis::run_generation(job_id, state_clone, user_id, tx).await; + }); + + Ok(( + StatusCode::ACCEPTED, + Json(GenerateResponse { + job_id, + message: "Generation demarree.".into(), + }), + )) +} + +/// `GET /api/v1/syntheses/generate/:job_id/progress` +/// +/// Server-Sent Events (SSE) endpoint that streams generation progress. +/// +/// Event types: +/// - `progress`: `{type: "progress", step: "...", message: "...", percent: N}` +/// - `complete`: `{type: "complete", synthesis_id: "..."}` +/// - `error`: `{type: "error", message: "..."}` +/// +/// The stream includes a keepalive ping every 15 seconds to prevent +/// connection timeouts through reverse proxies. +pub async fn progress_stream( + auth_user: AuthUser, + State(state): State, + Path(job_id): Path, +) -> Result>>, AppError> { + // Get the watch receiver, verifying ownership + let rx = state + .job_store + .subscribe(job_id, auth_user.id) + .ok_or_else(|| { + AppError::NotFound("Generation introuvable ou deja terminee.".into()) + })?; + + // Convert the watch stream to an SSE event stream. + // The watch channel immediately delivers the latest value on subscribe, + // so clients that reconnect get caught up instantly. + let stream = WatchStream::new(rx).map(|event| { + let event_type = match &event { + ProgressEvent::Progress { .. } => "progress", + ProgressEvent::Complete { .. } => "complete", + ProgressEvent::Error { .. } => "error", + }; + + let data = serde_json::to_string(&event).unwrap_or_default(); + + Ok(Event::default().event(event_type).data(data)) + }); + + Ok(Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("ping"), + )) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 6d63468..8f97d1d 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -2,6 +2,8 @@ pub mod admin; pub mod api_keys; pub mod auth; pub mod config; +pub mod generation; pub mod health; pub mod settings; pub mod sources; +pub mod syntheses; diff --git a/backend/src/handlers/syntheses.rs b/backend/src/handlers/syntheses.rs new file mode 100644 index 0000000..75e34ad --- /dev/null +++ b/backend/src/handlers/syntheses.rs @@ -0,0 +1,99 @@ +//! Syntheses CRUD handlers. +//! +//! - `GET /api/v1/syntheses` — list user's syntheses (paginated) +//! - `GET /api/v1/syntheses/:id` — get synthesis detail +//! - `DELETE /api/v1/syntheses/:id` — delete a synthesis + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::db; +use crate::errors::AppError; +use crate::middleware::auth::AuthUser; +use crate::models::synthesis::{SynthesisListItem, SynthesisResponse}; + +/// Query parameters for `GET /api/v1/syntheses`. +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// Maximum number of syntheses to return (default: 20, max: 100). + pub limit: Option, + /// Number of syntheses to skip (default: 0). + pub offset: Option, +} + +/// Response for `GET /api/v1/syntheses`. +#[derive(Debug, Serialize)] +pub struct ListResponse { + pub items: Vec, +} + +/// `GET /api/v1/syntheses` +/// +/// Returns a paginated list of the authenticated user's syntheses, +/// ordered by creation date (newest first). Each item includes a +/// preview of the first section. +pub async fn list( + auth_user: AuthUser, + State(state): State, + Query(params): Query, +) -> Result { + let limit = params.limit.unwrap_or(20).clamp(1, 100); + let offset = params.offset.unwrap_or(0).max(0); + + let syntheses = + db::syntheses::list_for_user(&state.pool, auth_user.id, limit, offset).await?; + + let items: Vec = syntheses + .into_iter() + .map(SynthesisListItem::try_from) + .collect::, _>>()?; + + Ok(Json(ListResponse { items })) +} + +/// `GET /api/v1/syntheses/:id` +/// +/// Returns the full synthesis detail including all sections and items. +/// Enforces ownership: a user can only view their own syntheses. +pub async fn get( + auth_user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let synthesis = db::syntheses::get_by_id_for_user(&state.pool, id, auth_user.id) + .await? + .ok_or_else(|| AppError::NotFound("Synthese introuvable.".into()))?; + + let response = SynthesisResponse::try_from(synthesis)?; + + Ok(Json(response)) +} + +/// `DELETE /api/v1/syntheses/:id` +/// +/// Deletes a synthesis by ID. Enforces ownership: a user can only +/// delete their own syntheses. Returns 204 No Content on success. +pub async fn delete( + auth_user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result { + let deleted = db::syntheses::delete(&state.pool, id, auth_user.id).await?; + + if !deleted { + return Err(AppError::NotFound("Synthese introuvable.".into())); + } + + tracing::info!( + user_id = %auth_user.id, + synthesis_id = %id, + "Synthesis deleted" + ); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 3d16601..807616d 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -6,4 +6,5 @@ pub mod rate_limit; pub mod session; pub mod settings; pub mod source; +pub mod synthesis; pub mod user; diff --git a/backend/src/models/synthesis.rs b/backend/src/models/synthesis.rs new file mode 100644 index 0000000..619f840 --- /dev/null +++ b/backend/src/models/synthesis.rs @@ -0,0 +1,290 @@ +//! Synthesis model and related types. +//! +//! A synthesis is a structured collection of news sections, each containing +//! categorized news items. Generated by the two-pass LLM pipeline and stored +//! as JSONB in the database. + +use chrono::{DateTime, Datelike, IsoWeek, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A single news item within a section. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NewsItem { + pub title: String, + pub url: String, + pub summary: String, +} + +/// A named section containing a list of news items. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NewsSection { + pub title: String, + pub items: Vec, +} + +/// Database row for a synthesis record. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Synthesis { + pub id: Uuid, + pub user_id: Uuid, + pub week: String, + pub sections: serde_json::Value, + pub status: String, + pub created_at: DateTime, +} + +/// Response shape for `GET /api/v1/syntheses/:id`. +#[derive(Debug, Serialize)] +pub struct SynthesisResponse { + pub id: Uuid, + pub week: String, + pub sections: Vec, + pub status: String, + pub created_at: DateTime, +} + +impl TryFrom for SynthesisResponse { + type Error = crate::errors::AppError; + + fn try_from(s: Synthesis) -> Result { + let sections: Vec = + serde_json::from_value(s.sections).map_err(|e| { + crate::errors::AppError::Internal(anyhow::anyhow!( + "Failed to parse synthesis sections: {}", + e + )) + })?; + + Ok(Self { + id: s.id, + week: s.week, + sections, + status: s.status, + created_at: s.created_at, + }) + } +} + +/// Response shape for `GET /api/v1/syntheses` (list view). +/// +/// Includes a preview of the first section to give users context +/// without loading the full synthesis payload. +#[derive(Debug, Serialize)] +pub struct SynthesisListItem { + pub id: Uuid, + pub week: String, + pub status: String, + pub created_at: DateTime, + pub first_section_title: Option, + pub first_section_item_count: usize, +} + +impl TryFrom for SynthesisListItem { + type Error = crate::errors::AppError; + + fn try_from(s: Synthesis) -> Result { + let sections: Vec = + serde_json::from_value(s.sections).unwrap_or_default(); + + let first = sections.first(); + let first_section_title = first.map(|sec| sec.title.clone()); + let first_section_item_count = first.map(|sec| sec.items.len()).unwrap_or(0); + + Ok(Self { + id: s.id, + week: s.week, + status: s.status, + created_at: s.created_at, + first_section_title, + first_section_item_count, + }) + } +} + +/// Generate an ISO 8601 week string (e.g., "2026-W12") for a given date. +/// +/// Uses `chrono::Datelike::iso_week()` to get the ISO week number +/// and `iso_week().year()` for the ISO year (which can differ from +/// the calendar year near year boundaries). +pub fn get_iso_week_string(date: NaiveDate) -> String { + let iso: IsoWeek = date.iso_week(); + format!("{}-W{:02}", iso.year(), iso.week()) +} + +/// Scraped data for a news item, used during the rewrite pass. +/// +/// Combines the original LLM-generated item with content scraped +/// from the source URL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScrapedNewsItem { + pub title: String, + pub url: String, + pub summary: String, + #[serde(rename = "scrapedContent")] + pub scraped_content: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_week_string_mid_year() { + let date = NaiveDate::from_ymd_opt(2026, 3, 21).unwrap(); + let week = get_iso_week_string(date); + assert_eq!(week, "2026-W12"); + } + + #[test] + fn iso_week_string_start_of_year() { + // January 1, 2026 is a Thursday, ISO week 1 + let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let week = get_iso_week_string(date); + assert_eq!(week, "2026-W01"); + } + + #[test] + fn iso_week_string_end_of_year_cross_boundary() { + // December 31, 2025 is a Wednesday. + // ISO week: 2026-W01 (ISO year can differ from calendar year) + let date = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(); + let week = get_iso_week_string(date); + assert_eq!(week, "2026-W01"); + } + + #[test] + fn iso_week_string_week_53() { + // 2020-12-31 is a Thursday, ISO week 53 of 2020 + let date = NaiveDate::from_ymd_opt(2020, 12, 31).unwrap(); + let week = get_iso_week_string(date); + assert_eq!(week, "2020-W53"); + } + + #[test] + fn news_item_serialization_roundtrip() { + let item = NewsItem { + title: "Test Article".into(), + url: "https://example.com/article".into(), + summary: "A brief summary of the article content.".into(), + }; + + let json = serde_json::to_value(&item).unwrap(); + assert_eq!(json["title"], "Test Article"); + assert_eq!(json["url"], "https://example.com/article"); + + let deserialized: NewsItem = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized, item); + } + + #[test] + fn news_section_serialization_roundtrip() { + let section = NewsSection { + title: "Major Announcements".into(), + items: vec![ + NewsItem { + title: "Article 1".into(), + url: "https://example.com/1".into(), + summary: "Summary 1".into(), + }, + NewsItem { + title: "Article 2".into(), + url: "https://example.com/2".into(), + summary: "Summary 2".into(), + }, + ], + }; + + let json = serde_json::to_value(§ion).unwrap(); + let deserialized: NewsSection = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized, section); + } + + #[test] + fn synthesis_list_item_from_synthesis_with_sections() { + let sections = serde_json::json!([ + { + "title": "AI News", + "items": [ + {"title": "Article 1", "url": "https://a.com", "summary": "Sum 1"}, + {"title": "Article 2", "url": "https://b.com", "summary": "Sum 2"} + ] + }, + { + "title": "Research", + "items": [ + {"title": "Paper 1", "url": "https://c.com", "summary": "Sum 3"} + ] + } + ]); + + let synthesis = Synthesis { + id: Uuid::nil(), + user_id: Uuid::nil(), + week: "2026-W12".into(), + sections, + status: "completed".into(), + created_at: Utc::now(), + }; + + let list_item = SynthesisListItem::try_from(synthesis).unwrap(); + assert_eq!(list_item.first_section_title, Some("AI News".into())); + assert_eq!(list_item.first_section_item_count, 2); + } + + #[test] + fn synthesis_list_item_from_synthesis_empty_sections() { + let synthesis = Synthesis { + id: Uuid::nil(), + user_id: Uuid::nil(), + week: "2026-W12".into(), + sections: serde_json::json!([]), + status: "completed".into(), + created_at: Utc::now(), + }; + + let list_item = SynthesisListItem::try_from(synthesis).unwrap(); + assert_eq!(list_item.first_section_title, None); + assert_eq!(list_item.first_section_item_count, 0); + } + + #[test] + fn synthesis_response_from_synthesis() { + let sections = serde_json::json!([ + { + "title": "AI News", + "items": [ + {"title": "Art", "url": "https://a.com", "summary": "Sum"} + ] + } + ]); + + let synthesis = Synthesis { + id: Uuid::nil(), + user_id: Uuid::nil(), + week: "2026-W12".into(), + sections, + status: "completed".into(), + created_at: Utc::now(), + }; + + let response = SynthesisResponse::try_from(synthesis).unwrap(); + assert_eq!(response.sections.len(), 1); + assert_eq!(response.sections[0].title, "AI News"); + assert_eq!(response.sections[0].items[0].title, "Art"); + } + + #[test] + fn synthesis_response_from_invalid_json_fails() { + let synthesis = Synthesis { + id: Uuid::nil(), + user_id: Uuid::nil(), + week: "2026-W12".into(), + sections: serde_json::json!("not an array"), + status: "completed".into(), + created_at: Utc::now(), + }; + + assert!(SynthesisResponse::try_from(synthesis).is_err()); + } +} diff --git a/backend/src/router.rs b/backend/src/router.rs index f67de17..dbf2d62 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -49,6 +49,14 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { .route("/user/api-keys", post(handlers::api_keys::create)) .route("/user/api-keys/{provider}", delete(handlers::api_keys::delete)) .route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key)) + // Generation routes (authenticated) — registered before /syntheses/{id} + // to avoid ambiguity with path parameter matching + .route("/syntheses/generate", post(handlers::generation::trigger_generate)) + .route("/syntheses/generate/{job_id}/progress", get(handlers::generation::progress_stream)) + // Syntheses CRUD routes (authenticated) + .route("/syntheses", get(handlers::syntheses::list)) + .route("/syntheses/{id}", get(handlers::syntheses::get)) + .route("/syntheses/{id}", delete(handlers::syntheses::delete)) // Public config (authenticated, non-admin) .route("/config/providers", get(handlers::config::list_enabled_providers)) // Admin routes diff --git a/backend/src/services/llm/gemini.rs b/backend/src/services/llm/gemini.rs index 5f3784a..a4186f8 100644 --- a/backend/src/services/llm/gemini.rs +++ b/backend/src/services/llm/gemini.rs @@ -47,7 +47,9 @@ impl GeminiProvider { .send() .await .map_err(|e| { - tracing::error!("Gemini API request failed: {}", e); + // Log only the error kind, NOT the full Display (which includes the URL containing the API key) + let kind = if e.is_timeout() { "timeout" } else if e.is_connect() { "connection error" } else { "network error" }; + tracing::error!("Gemini API request failed: {}", kind); AppError::Internal(anyhow::anyhow!("Failed to connect to Gemini API")) })?; diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index d65e4aa..1a541ab 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -3,6 +3,8 @@ pub mod csv; pub mod email; pub mod encryption; pub mod llm; +pub mod prompts; pub mod rate_limiter; pub mod scraper; +pub mod synthesis; pub mod turnstile; diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs new file mode 100644 index 0000000..71f2405 --- /dev/null +++ b/backend/src/services/prompts.rs @@ -0,0 +1,263 @@ +//! Prompt construction for the two-pass LLM generation pipeline. +//! +//! Builds system and user prompts for: +//! - **Search pass** (Pass 1): web search and initial article discovery +//! - **Rewrite pass** (Pass 2): rewrite summaries using scraped content +//! +//! Prompts are provider-agnostic and parameterized by user settings. + +use crate::models::settings::UserSettings; +use crate::models::source::Source; +use crate::models::synthesis::ScrapedNewsItem; + +/// Build the system prompt and user prompt for the search pass (Pass 1). +/// +/// The search pass instructs the LLM to find recent news articles +/// matching the user's theme and categories, using web search grounding. +/// +/// # Arguments +/// * `settings` — User's configured settings (theme, categories, etc.) +/// * `sources` — User's custom sources to prioritize +/// * `current_date` — Formatted date string for the prompt +pub fn build_search_prompt( + settings: &UserSettings, + sources: &[Source], + current_date: &str, +) -> (String, String) { + let sources_text = if sources.is_empty() { + String::new() + } else { + let list = sources + .iter() + .map(|s| format!("- {} ({})", s.title, s.url)) + .collect::>() + .join("\n"); + format!( + "\nEn plus des sources par defaut, tu DOIS imperativement consulter \ + et integrer les informations provenant de ces sources personnalisees :\n{}\n", + list + ) + }; + + let categories_text = settings + .categories + .iter() + .enumerate() + .map(|(i, cat)| format!("{}. {}", i + 1, cat)) + .collect::>() + .join("\n"); + + let behavior = if settings.search_agent_behavior.is_empty() { + "Tu peux egalement utiliser d'autres sources pertinentes trouvees via la recherche Google." + .to_string() + } else { + settings.search_agent_behavior.clone() + }; + + let system_prompt = format!( + "Tu es un assistant IA precis. Tu dois TOUJOURS fournir des URLs completes et exactes. \ + Ne tronque jamais les URLs. Tu dois te concentrer UNIQUEMENT sur les actualites des {} \ + derniers jours.", + settings.max_age_days + ); + + let user_prompt = format!( + "Aujourd'hui, nous sommes le {date}.\n\ + Tu es un expert en analyse de l'actualite sur le theme : \"{theme}\".\n\ + Ta tache est de rechercher les actualites STRICTEMENT des {days} derniers jours.\n\ + Ne retourne AUCUNE actualite datant de plus de {days} jours.\n\n\ + Tu DOIS imperativement t'appuyer sur le contenu des sites web pertinents pour ce theme.\ + {sources}\ + {behavior}\n\n\ + La synthese doit etre divisee en {count} grandes sections :\n\ + {categories}\n\n\ + Pour chaque categorie, fournis au maximum {max_items} actualites.\n\ + Pour chaque actualite, fournis un titre provisoire, l'URL source exacte et complete, \ + et un resume provisoire.\n\ + Retourne le resultat au format JSON en utilisant les cles category_0, category_1, etc. \ + correspondant a l'ordre des sections ci-dessus.", + date = current_date, + theme = settings.theme, + days = settings.max_age_days, + sources = sources_text, + behavior = behavior, + count = settings.categories.len(), + categories = categories_text, + max_items = settings.max_items_per_category, + ); + + (system_prompt, user_prompt) +} + +/// Build the system prompt and user prompt for the rewrite pass (Pass 2). +/// +/// The rewrite pass takes scraped article content and asks the LLM to +/// rewrite titles and summaries to faithfully reflect the actual content. +/// +/// # Arguments +/// * `scraped_data` — Map of category key to scraped news items with content +pub fn build_rewrite_prompt( + scraped_data: &std::collections::HashMap>, +) -> (String, String) { + let system_prompt = + "Tu es un assistant IA precis. Tu dois generer des titres et resumes fideles \ + au contenu fourni." + .to_string(); + + let data_json = serde_json::to_string_pretty(scraped_data).unwrap_or_default(); + + let user_prompt = format!( + "Tu es un expert en analyse de l'actualite.\n\ + Voici une liste d'articles d'actualite classes par categorie, avec leur contenu textuel \ + brut extrait des sites web ('scrapedContent').\n\ + Ta tache est de reecrire le 'title' et le 'summary' (4 ou 5 lignes) pour chaque article \ + afin qu'ils refletent EXACTEMENT et FIDELEMENT le contenu textuel fourni.\n\ + Si le 'scrapedContent' est vide ou insuffisant, utilise le titre et le resume originaux \ + pour faire au mieux.\n\ + Conserve EXACTEMENT les memes URLs. Ne supprime aucun article de cette liste.\n\n\ + Donnees des articles :\n{data}", + data = data_json, + ); + + (system_prompt, user_prompt) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use uuid::Uuid; + + fn test_settings() -> UserSettings { + UserSettings { + user_id: Uuid::nil(), + theme: "Intelligence Artificielle".to_string(), + max_age_days: 7, + categories: vec![ + "Annonces majeures".to_string(), + "Recherche et innovation".to_string(), + ], + max_items_per_category: 4, + search_agent_behavior: String::new(), + updated_at: Utc::now(), + } + } + + #[test] + fn search_prompt_includes_theme() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("Intelligence Artificielle")); + } + + #[test] + fn search_prompt_includes_date() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("lundi 21 mars 2026")); + } + + #[test] + fn search_prompt_includes_max_age() { + let settings = test_settings(); + let (system, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("7 derniers jours")); + assert!(system.contains("7")); + } + + #[test] + fn search_prompt_includes_categories() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("1. Annonces majeures")); + assert!(user_prompt.contains("2. Recherche et innovation")); + assert!(user_prompt.contains("2 grandes sections")); + } + + #[test] + fn search_prompt_includes_max_items() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("4 actualites")); + } + + #[test] + fn search_prompt_includes_custom_sources() { + let settings = test_settings(); + let sources = vec![ + Source { + id: Uuid::nil(), + user_id: Uuid::nil(), + title: "TechCrunch".into(), + url: "https://techcrunch.com".into(), + created_at: Utc::now(), + }, + Source { + id: Uuid::nil(), + user_id: Uuid::nil(), + title: "The Verge".into(), + url: "https://theverge.com".into(), + created_at: Utc::now(), + }, + ]; + + let (_, user_prompt) = build_search_prompt(&settings, &sources, "lundi 21 mars 2026"); + assert!(user_prompt.contains("TechCrunch (https://techcrunch.com)")); + assert!(user_prompt.contains("The Verge (https://theverge.com)")); + assert!(user_prompt.contains("sources personnalisees")); + } + + #[test] + fn search_prompt_no_sources_no_section() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(!user_prompt.contains("sources personnalisees")); + } + + #[test] + fn search_prompt_custom_behavior() { + let mut settings = test_settings(); + settings.search_agent_behavior = + "Concentre-toi sur les sources europeennes.".to_string(); + + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("Concentre-toi sur les sources europeennes.")); + assert!(!user_prompt.contains("recherche Google")); + } + + #[test] + fn search_prompt_default_behavior_when_empty() { + let settings = test_settings(); + let (_, user_prompt) = build_search_prompt(&settings, &[], "lundi 21 mars 2026"); + assert!(user_prompt.contains("recherche Google")); + } + + #[test] + fn rewrite_prompt_includes_instructions() { + let mut data = std::collections::HashMap::new(); + data.insert( + "category_0".to_string(), + vec![ScrapedNewsItem { + title: "Test Article".into(), + url: "https://example.com".into(), + summary: "A summary".into(), + scraped_content: "Full article text here...".into(), + }], + ); + + let (system, user_prompt) = build_rewrite_prompt(&data); + assert!(system.contains("fideles")); + assert!(user_prompt.contains("scrapedContent")); + assert!(user_prompt.contains("Test Article")); + assert!(user_prompt.contains("https://example.com")); + assert!(user_prompt.contains("Ne supprime aucun article")); + } + + #[test] + fn rewrite_prompt_with_empty_data() { + let data = std::collections::HashMap::new(); + let (_, user_prompt) = build_rewrite_prompt(&data); + // Should still produce a valid prompt with empty data + assert!(user_prompt.contains("Donnees des articles")); + } +} diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs new file mode 100644 index 0000000..a9148b3 --- /dev/null +++ b/backend/src/services/synthesis.rs @@ -0,0 +1,862 @@ +//! Synthesis generation pipeline and job management. +//! +//! Orchestrates the two-pass LLM pipeline: +//! 1. Search pass: LLM generates initial articles via web search +//! 2. Scrape: Validate and fetch content from source URLs +//! 3. Rewrite pass: LLM rewrites titles/summaries using scraped content +//! +//! Progress is reported via `tokio::sync::watch` channels per job, +//! consumed by SSE endpoints for real-time client updates. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use dashmap::DashMap; +use serde::Serialize; +use tokio::sync::watch; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::db; +use crate::errors::AppError; +use crate::models::synthesis::{ + get_iso_week_string, NewsItem, NewsSection, ScrapedNewsItem, +}; +use crate::services::encryption; +use crate::services::llm::factory::create_provider; +use crate::services::llm::schema::build_category_schema; +use crate::services::prompts; +use crate::services::scraper; + +// ─────────────────────────────────────────────────────────────────── +// Progress Events +// ─────────────────────────────────────────────────────────────────── + +/// Progress event sent to SSE clients during generation. +/// +/// The `watch` channel always holds the latest event, and new subscribers +/// immediately receive the current state. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum ProgressEvent { + /// Generation is in progress. + #[serde(rename = "progress")] + Progress { + step: String, + message: String, + percent: u8, + }, + /// Generation completed successfully. + #[serde(rename = "complete")] + Complete { synthesis_id: Uuid }, + /// Generation failed with an error. + #[serde(rename = "error")] + Error { message: String }, +} + +// ─────────────────────────────────────────────────────────────────── +// Job Store +// ─────────────────────────────────────────────────────────────────── + +/// Entry in the job store, holding the progress channel and metadata. +struct JobEntry { + /// Sender side of the watch channel for progress updates. + /// Wrapped in Arc so it can be shared with the background task + /// without cloning the Sender itself. + tx: Arc>, + /// A receiver kept alive to prevent the channel from closing. + /// Without at least one receiver, `Sender::send()` returns an error + /// and does NOT update the stored value. + _rx: watch::Receiver, + /// User who owns this job. + user_id: Uuid, + /// When the job was created (for TTL cleanup). + created_at: Instant, +} + +/// In-memory store for active generation jobs. +/// +/// Uses `DashMap` for lock-free concurrent access. Jobs are keyed by +/// a random UUID and automatically cleaned up after a TTL. +#[derive(Clone)] +pub struct JobStore { + inner: Arc>, +} + +/// Jobs expire after 1 hour (allows SSE reconnection). +const JOB_TTL: Duration = Duration::from_secs(3600); + +impl JobStore { + /// Create a new empty job store. + pub fn new() -> Self { + Self { + inner: Arc::new(DashMap::new()), + } + } + + /// Create a new job for a user, returning the job ID and the watch Sender. + /// + /// Returns `None` if the user already has an active job. + pub fn create_job(&self, user_id: Uuid) -> Option<(Uuid, Arc>)> { + // Check if user already has an active job + for entry in self.inner.iter() { + if entry.value().user_id == user_id { + // Check if the job is still active (not completed/failed) + let current = entry.value().tx.borrow().clone(); + match current { + ProgressEvent::Complete { .. } | ProgressEvent::Error { .. } => { + // Job finished, allow creating a new one + continue; + } + ProgressEvent::Progress { .. } => { + return None; // Active job exists + } + } + } + } + + let job_id = Uuid::new_v4(); + let (tx, rx) = watch::channel(ProgressEvent::Progress { + step: "init".into(), + message: "Initialisation...".into(), + percent: 0, + }); + + let tx = Arc::new(tx); + + self.inner.insert( + job_id, + JobEntry { + tx: Arc::clone(&tx), + _rx: rx, + user_id, + created_at: Instant::now(), + }, + ); + + Some((job_id, tx)) + } + + /// Get a watch receiver for a job, if it exists and belongs to the given user. + pub fn subscribe(&self, job_id: Uuid, user_id: Uuid) -> Option> { + self.inner.get(&job_id).and_then(|entry| { + if entry.value().user_id == user_id { + Some(entry.value().tx.subscribe()) + } else { + None + } + }) + } + + /// Check if a user has an active (in-progress) job. + pub fn has_active_job(&self, user_id: Uuid) -> Option { + for entry in self.inner.iter() { + if entry.value().user_id == user_id { + let current = entry.value().tx.borrow().clone(); + if matches!(current, ProgressEvent::Progress { .. }) { + return Some(*entry.key()); + } + } + } + None + } + + /// Remove expired jobs (older than TTL). + pub fn cleanup_expired(&self) { + let now = Instant::now(); + self.inner.retain(|_, entry| { + now.duration_since(entry.created_at) < JOB_TTL + }); + } + + /// Remove a specific job. + pub fn remove(&self, job_id: &Uuid) { + self.inner.remove(job_id); + } + + /// Get the number of active jobs (for testing/monitoring). + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Check if the store is empty (for testing). + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +// ─────────────────────────────────────────────────────────────────── +// Generation Pipeline +// ─────────────────────────────────────────────────────────────────── + +/// Run the full two-pass generation pipeline for a user. +/// +/// This is the core orchestration function. It is spawned as a background +/// tokio task and communicates progress via the `watch` channel. +/// +/// # Steps +/// 1. Load user settings +/// 2. Load user sources +/// 3. Resolve provider + decrypt API key +/// 4. Build schema from categories +/// 5. Rate limit check (pass 1) +/// 6. LLM search pass +/// 7. Parse structured output +/// 8. Validate/scrape URLs (parallel, bounded concurrency) +/// 9. Rate limit check (pass 2) +/// 10. LLM rewrite pass +/// 11. Parse final output +/// 12. Save synthesis to DB +/// 13. Complete +pub async fn run_generation( + job_id: Uuid, + state: AppState, + user_id: Uuid, + tx: Arc>, +) { + let result = run_generation_inner(job_id, &state, user_id, &tx).await; + + match result { + Ok(synthesis_id) => { + tx.send(ProgressEvent::Complete { synthesis_id }).ok(); + tracing::info!(job_id = %job_id, synthesis_id = %synthesis_id, "Generation completed"); + } + Err(e) => { + tracing::error!(job_id = %job_id, error = %e, "Generation failed"); + // Sanitize error message — never expose API keys or internal details + let safe_message = sanitize_error_message(&e.to_string()); + tx.send(ProgressEvent::Error { + message: safe_message, + }) + .ok(); + } + } + + // Keep the job in the store for 5 minutes after completion + // to allow SSE reconnection + let store = state.job_store.clone(); + let jid = job_id; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(300)).await; + store.remove(&jid); + }); +} + +/// Inner implementation of the generation pipeline, returning a Result. +async fn run_generation_inner( + _job_id: Uuid, + state: &AppState, + user_id: Uuid, + tx: &watch::Sender, +) -> Result { + // Step 1: Load user settings + emit_progress(tx, "settings", "Chargement des parametres...", 5); + let settings = db::settings::get_or_create_default(&state.pool, user_id).await?; + + if settings.categories.is_empty() { + return Err(AppError::BadRequest( + "Aucune categorie configuree. Veuillez configurer vos parametres.".into(), + )); + } + + // Step 2: Load user sources + emit_progress(tx, "sources", "Chargement des sources...", 10); + let sources = db::sources::list_for_user(&state.pool, user_id).await?; + + // Step 3: Resolve provider + decrypt API key + emit_progress(tx, "provider", "Configuration du fournisseur IA...", 15); + let (provider_name, api_key) = resolve_provider_and_key(state, user_id).await?; + + let provider = create_provider(&provider_name, api_key, &state.http_client)?; + + // Step 4: Build schema from categories + let schema = build_category_schema(&settings.categories); + + // Step 5: Rate limit check (pass 1) + if !state.provider_rate_limiter.check(&provider_name) { + return Err(AppError::RateLimited( + "Limite de requetes atteinte. Veuillez reessayer dans quelques instants.".into(), + )); + } + + // Step 6: LLM search pass + emit_progress(tx, "search", "Recherche d'actualites en cours...", 30); + let current_date = Utc::now() + .format("%A %d %B %Y") + .to_string(); + let (system_prompt, user_prompt) = + prompts::build_search_prompt(&settings, &sources, ¤t_date); + + let model = resolve_model(state, &provider_name).await?; + + let raw_results = provider + .generate_search_pass(&model, &system_prompt, &user_prompt, &schema) + .await?; + + // Step 7: Parse structured output into (category_key, Vec) + emit_progress(tx, "parsing", "Analyse des resultats...", 40); + let parsed = parse_llm_output(&raw_results, &settings.categories)?; + + // Step 8: Validate/scrape URLs (parallel, bounded concurrency) + emit_progress(tx, "scraping", "Verification des sources...", 45); + let scraped = scrape_articles(state, &parsed, settings.max_age_days as i64, tx).await; + + // Step 9: Rate limit check (pass 2) + if !state.provider_rate_limiter.check(&provider_name) { + return Err(AppError::RateLimited( + "Limite de requetes atteinte pour la passe de reecriture. Veuillez reessayer.".into(), + )); + } + + // Step 10: LLM rewrite pass + emit_progress(tx, "rewrite", "Redaction des resumes...", 80); + let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped); + + let final_results = provider + .generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema) + .await?; + + // Step 11: Parse final output + emit_progress(tx, "finalizing", "Finalisation...", 90); + let final_sections = build_final_sections(&final_results, &settings.categories)?; + + // Step 12: Save synthesis to DB + emit_progress(tx, "saving", "Sauvegarde de la synthese...", 95); + let week = get_iso_week_string(Utc::now().date_naive()); + let sections_json = serde_json::to_value(&final_sections).map_err(|e| { + AppError::Internal(anyhow::anyhow!("Failed to serialize sections: {}", e)) + })?; + + let synthesis = + db::syntheses::create(&state.pool, user_id, &week, §ions_json).await?; + + Ok(synthesis.id) +} + +// ─────────────────────────────────────────────────────────────────── +// Helper Functions +// ─────────────────────────────────────────────────────────────────── + +/// Emit a progress event via the watch channel. +fn emit_progress(tx: &watch::Sender, step: &str, message: &str, percent: u8) { + tx.send(ProgressEvent::Progress { + step: step.into(), + message: message.into(), + percent, + }) + .ok(); +} + +/// Resolve the LLM provider and decrypt the user's API key. +/// +/// Looks up the user's API key for the first available provider. +async fn resolve_provider_and_key( + state: &AppState, + user_id: Uuid, +) -> Result<(String, String), AppError> { + let keys = db::api_keys::list_for_user(&state.pool, user_id).await?; + + if keys.is_empty() { + return Err(AppError::BadRequest( + "Aucune cle API configuree. Veuillez ajouter une cle API dans vos parametres.".into(), + )); + } + + // Use the first available key + let key_record = &keys[0]; + let master_key = encryption::MasterKey::from_hex(&state.config.master_encryption_key)?; + let api_key = encryption::decrypt( + &master_key, + &key_record.encrypted_key, + &key_record.nonce, + )?; + + Ok((key_record.provider_name.clone(), api_key)) +} + +/// Resolve the model to use for a given provider. +/// +/// Looks up the first enabled model for the provider from the admin config. +/// Falls back to sensible defaults if no admin-configured models exist. +async fn resolve_model(state: &AppState, provider_name: &str) -> Result { + // Try to get the first enabled model from admin config + let model = sqlx::query_scalar::<_, String>( + r#" + SELECT model_id FROM admin_provider_models + WHERE provider = $1 AND enabled = true + ORDER BY created_at ASC + LIMIT 1 + "#, + ) + .bind(provider_name) + .fetch_optional(&state.pool) + .await?; + + match model { + Some(m) => Ok(m), + None => { + // Fall back to sensible defaults + match provider_name { + "gemini" => Ok("gemini-2.5-pro".into()), + "openai" => Ok("gpt-4o".into()), + "anthropic" => Ok("claude-sonnet-4-20250514".into()), + _ => Err(AppError::BadRequest(format!( + "Aucun modele configure pour le fournisseur '{}'", + provider_name + ))), + } + } + } +} + +/// Parse the LLM's structured JSON output into category-keyed news items. +/// +/// Expects the output to have keys like `category_0`, `category_1`, etc. +/// Each key maps to an array of `{title, url, summary}` objects. +fn parse_llm_output( + raw: &serde_json::Value, + categories: &[String], +) -> Result)>, AppError> { + let mut result = Vec::new(); + + for (i, _cat) in categories.iter().enumerate() { + let key = format!("category_{}", i); + let items_val = raw.get(&key).cloned().unwrap_or(serde_json::json!([])); + + let items: Vec = serde_json::from_value(items_val).unwrap_or_default(); + result.push((key, items)); + } + + Ok(result) +} + +/// Scrape articles in parallel with bounded concurrency. +/// +/// For each category, scrapes all article URLs. Failed scrapes are +/// handled gracefully — the article is kept with empty scraped content +/// rather than being discarded. +async fn scrape_articles( + state: &AppState, + parsed: &[(String, Vec)], + max_age_days: i64, + tx: &watch::Sender, +) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + + // Collect all (category_key, item) pairs for parallel processing + let mut tasks = Vec::new(); + for (cat_key, items) in parsed { + for item in items { + tasks.push((cat_key.clone(), item.clone())); + } + } + + let total = tasks.len(); + if total == 0 { + return result; + } + + // Use JoinSet for bounded concurrency (max 10 concurrent scrapes) + let mut join_set = tokio::task::JoinSet::new(); + let mut pending = tasks.into_iter().peekable(); + let mut completed = 0usize; + + // Seed the JoinSet with up to 10 initial tasks + let max_concurrent = 10; + for _ in 0..max_concurrent { + if let Some((cat_key, item)) = pending.next() { + let client = state.http_client.clone(); + let url = item.url.clone(); + let mad = max_age_days; + join_set.spawn(async move { + let scraped = scrape_single_article(&client, &url, mad).await; + (cat_key, item, scraped) + }); + } + } + + // Process results and spawn new tasks as slots open + while let Some(join_result) = join_set.join_next().await { + completed += 1; + + // Update progress (45% to 75% range for scraping) + let pct = 45 + ((completed as u32 * 30) / total as u32).min(30); + emit_progress( + tx, + "scraping", + &format!("Verification des sources ({}/{})...", completed, total), + pct as u8, + ); + + if let Ok((cat_key, item, scraped_content)) = join_result { + let scraped_item = ScrapedNewsItem { + title: item.title, + url: item.url, + summary: item.summary, + scraped_content, + }; + + result + .entry(cat_key) + .or_default() + .push(scraped_item); + } + + // Spawn next task if available + if let Some((cat_key, item)) = pending.next() { + let client = state.http_client.clone(); + let url = item.url.clone(); + let mad = max_age_days; + join_set.spawn(async move { + let scraped = scrape_single_article(&client, &url, mad).await; + (cat_key, item, scraped) + }); + } + } + + result +} + +/// Scrape a single article URL, returning the body text or an empty string on failure. +/// +/// Handles all failure modes gracefully: +/// - Network errors → empty content (article kept) +/// - Soft 404 → article excluded (empty content) +/// - Article too old → article excluded (empty content) +async fn scrape_single_article( + http_client: &reqwest::Client, + url: &str, + max_age_days: i64, +) -> String { + match scraper::scrape_url(http_client, url).await { + Ok(content) => { + if !content.ok || content.is_soft_404 { + tracing::warn!(url = url, "Soft 404 or error page detected, skipping content"); + return String::new(); + } + + if scraper::is_article_too_old(content.published_date, max_age_days) { + tracing::warn!(url = url, "Article too old, skipping content"); + return String::new(); + } + + content.body_text + } + Err(e) => { + tracing::warn!(url = url, error = %e, "Failed to scrape URL, keeping article with empty content"); + String::new() + } + } +} + +/// Build the final sections array from the LLM's rewrite output. +/// +/// Maps `category_N` keys back to the user's category names. +fn build_final_sections( + raw: &serde_json::Value, + categories: &[String], +) -> Result, AppError> { + let mut sections = Vec::new(); + + for (i, cat_name) in categories.iter().enumerate() { + let key = format!("category_{}", i); + let items_val = raw.get(&key).cloned().unwrap_or(serde_json::json!([])); + + let items: Vec = serde_json::from_value(items_val).unwrap_or_default(); + + sections.push(NewsSection { + title: cat_name.clone(), + items, + }); + } + + Ok(sections) +} + +/// Sanitize error messages to prevent leaking sensitive information. +/// +/// Removes potential API keys, internal paths, and other sensitive data. +fn sanitize_error_message(msg: &str) -> String { + // If the message contains common API key patterns, replace with generic message + if msg.contains("API key") + || msg.contains("api_key") + || msg.contains("AIza") + || msg.contains("sk-") + || msg.contains("PERMISSION_DENIED") + { + return "Erreur d'authentification avec le fournisseur IA. Verifiez votre cle API.".into(); + } + + if msg.contains("rate limit") || msg.contains("quota") || msg.contains("429") { + return "Limite de requetes du fournisseur IA atteinte. Reessayez plus tard.".into(); + } + + if msg.contains("Database") || msg.contains("sqlx") || msg.contains("postgres") { + return "Erreur interne du serveur. Veuillez reessayer.".into(); + } + + // For other errors, truncate and sanitize + let truncated = if msg.len() > 200 { + format!("{}...", &msg[..200]) + } else { + msg.to_string() + }; + + truncated +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── JobStore tests ─────────────────────────────────────────── + + #[test] + fn job_store_create_and_subscribe() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + let (job_id, tx) = store.create_job(user_id).unwrap(); + assert_eq!(store.len(), 1); + + // Subscribe + let rx = store.subscribe(job_id, user_id); + assert!(rx.is_some()); + + // Wrong user cannot subscribe + let other_user = Uuid::new_v4(); + assert!(store.subscribe(job_id, other_user).is_none()); + + // Check active job + assert_eq!(store.has_active_job(user_id), Some(job_id)); + assert_eq!(store.has_active_job(other_user), None); + + drop(tx); + } + + #[test] + fn job_store_prevents_duplicate_active_jobs() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + let result1 = store.create_job(user_id); + assert!(result1.is_some()); + + // Second job for same user should fail + let result2 = store.create_job(user_id); + assert!(result2.is_none()); + + // Different user should succeed + let other_user = Uuid::new_v4(); + let result3 = store.create_job(other_user); + assert!(result3.is_some()); + } + + #[test] + fn job_store_allows_new_job_after_completion() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + let (_job_id, tx) = store.create_job(user_id).unwrap(); + + // Complete the job + tx.send(ProgressEvent::Complete { + synthesis_id: Uuid::new_v4(), + }) + .ok(); + + // Should now allow a new job + let result2 = store.create_job(user_id); + assert!(result2.is_some()); + } + + #[test] + fn job_store_allows_new_job_after_error() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + let (_job_id, tx) = store.create_job(user_id).unwrap(); + + // Fail the job + tx.send(ProgressEvent::Error { + message: "test error".into(), + }) + .ok(); + + // Should now allow a new job + let result2 = store.create_job(user_id); + assert!(result2.is_some()); + } + + #[test] + fn job_store_cleanup_expired() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + // Create a job and manually set its created_at to the past + let (_job_id, _tx) = store.create_job(user_id).unwrap(); + assert_eq!(store.len(), 1); + + // Cleanup should not remove recent jobs + store.cleanup_expired(); + assert_eq!(store.len(), 1); + } + + #[test] + fn job_store_remove() { + let store = JobStore::new(); + let user_id = Uuid::new_v4(); + + let (job_id, _tx) = store.create_job(user_id).unwrap(); + assert_eq!(store.len(), 1); + + store.remove(&job_id); + assert!(store.is_empty()); + } + + // ── ProgressEvent serialization tests ──────────────────────── + + #[test] + fn progress_event_serialization_progress() { + let event = ProgressEvent::Progress { + step: "search".into(), + message: "Searching...".into(), + percent: 30, + }; + + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "progress"); + assert_eq!(json["step"], "search"); + assert_eq!(json["message"], "Searching..."); + assert_eq!(json["percent"], 30); + } + + #[test] + fn progress_event_serialization_complete() { + let synthesis_id = Uuid::nil(); + let event = ProgressEvent::Complete { synthesis_id }; + + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "complete"); + assert_eq!( + json["synthesis_id"], + "00000000-0000-0000-0000-000000000000" + ); + } + + #[test] + fn progress_event_serialization_error() { + let event = ProgressEvent::Error { + message: "Something went wrong".into(), + }; + + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "error"); + assert_eq!(json["message"], "Something went wrong"); + } + + // ── parse_llm_output tests ─────────────────────────────────── + + #[test] + fn parse_llm_output_valid() { + let raw = serde_json::json!({ + "category_0": [ + {"title": "Art 1", "url": "https://a.com", "summary": "Sum 1"}, + {"title": "Art 2", "url": "https://b.com", "summary": "Sum 2"} + ], + "category_1": [ + {"title": "Art 3", "url": "https://c.com", "summary": "Sum 3"} + ] + }); + + let categories = vec!["AI News".into(), "Research".into()]; + let result = parse_llm_output(&raw, &categories).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].0, "category_0"); + assert_eq!(result[0].1.len(), 2); + assert_eq!(result[1].0, "category_1"); + assert_eq!(result[1].1.len(), 1); + } + + #[test] + fn parse_llm_output_missing_category() { + let raw = serde_json::json!({ + "category_0": [ + {"title": "Art 1", "url": "https://a.com", "summary": "Sum 1"} + ] + // category_1 is missing + }); + + let categories = vec!["AI News".into(), "Research".into()]; + let result = parse_llm_output(&raw, &categories).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].1.len(), 1); + assert_eq!(result[1].1.len(), 0); // Missing category → empty + } + + // ── build_final_sections tests ─────────────────────────────── + + #[test] + fn build_final_sections_maps_names() { + let raw = serde_json::json!({ + "category_0": [ + {"title": "Art", "url": "https://a.com", "summary": "Sum"} + ], + "category_1": [] + }); + + let categories = vec!["Annonces majeures".into(), "Recherche".into()]; + let sections = build_final_sections(&raw, &categories).unwrap(); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].title, "Annonces majeures"); + assert_eq!(sections[0].items.len(), 1); + assert_eq!(sections[1].title, "Recherche"); + assert_eq!(sections[1].items.len(), 0); + } + + // ── sanitize_error_message tests ───────────────────────────── + + #[test] + fn sanitize_hides_api_key_references() { + let msg = "Invalid API key: AIzaSyB-test-key"; + let sanitized = sanitize_error_message(msg); + assert!(sanitized.contains("cle API")); + assert!(!sanitized.contains("AIza")); + } + + #[test] + fn sanitize_hides_rate_limit_details() { + let msg = "Resource exhausted: rate limit exceeded for project 12345"; + let sanitized = sanitize_error_message(msg); + assert!(sanitized.contains("Limite")); + assert!(!sanitized.contains("12345")); + } + + #[test] + fn sanitize_hides_database_details() { + let msg = "Database connection to postgres://user:pass@localhost failed"; + let sanitized = sanitize_error_message(msg); + assert!(sanitized.contains("Erreur interne")); + assert!(!sanitized.contains("postgres")); + } + + #[test] + fn sanitize_truncates_long_messages() { + let msg = "x".repeat(300); + let sanitized = sanitize_error_message(&msg); + assert!(sanitized.len() < 210); + assert!(sanitized.ends_with("...")); + } + + #[test] + fn sanitize_passes_normal_messages() { + let msg = "Generation failed due to network timeout"; + let sanitized = sanitize_error_message(msg); + assert_eq!(sanitized, msg); + } +} diff --git a/backend/tests/api_syntheses_test.rs b/backend/tests/api_syntheses_test.rs new file mode 100644 index 0000000..5186841 --- /dev/null +++ b/backend/tests/api_syntheses_test.rs @@ -0,0 +1,604 @@ +//! Integration tests for the syntheses endpoints (Phase 5). +//! +//! Tests: +//! - GET /api/v1/syntheses — list user's syntheses (paginated) +//! - GET /api/v1/syntheses/:id — get synthesis detail +//! - DELETE /api/v1/syntheses/:id — delete a synthesis +//! - POST /api/v1/syntheses/generate — trigger generation +//! +//! Covers authentication, CRUD, ownership isolation, pagination, +//! and generation trigger behaviour. +//! +//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run. + +mod common; + +use axum::http::StatusCode; + +fn require_test_db() -> bool { + std::env::var("TEST_DATABASE_URL").is_ok() +} + +/// Helper: build a sample sections JSON value with one section and two items. +fn sample_sections() -> serde_json::Value { + serde_json::json!([ + { + "title": "AI News", + "items": [ + { + "title": "Article 1", + "url": "https://example.com/1", + "summary": "Summary of article 1" + }, + { + "title": "Article 2", + "url": "https://example.com/2", + "summary": "Summary of article 2" + } + ] + } + ]) +} + +/// Helper: build a multi-section JSON value. +fn multi_sections() -> serde_json::Value { + serde_json::json!([ + { + "title": "Major Announcements", + "items": [ + { + "title": "Big Launch", + "url": "https://example.com/launch", + "summary": "A big product launch" + } + ] + }, + { + "title": "Research", + "items": [ + { + "title": "New Paper", + "url": "https://example.com/paper", + "summary": "A new research paper" + }, + { + "title": "Breakthrough", + "url": "https://example.com/breakthrough", + "summary": "A scientific breakthrough" + } + ] + } + ]) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Auth (3 tests) +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn get_syntheses_without_auth_returns_401() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (status, body) = app.get("/api/v1/syntheses").await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "GET /syntheses without auth should return 401" + ); + assert_eq!(body["error"], "unauthorized"); +} + +#[tokio::test] +async fn get_synthesis_by_id_without_auth_returns_401() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let fake_id = uuid::Uuid::new_v4(); + let (status, body) = app + .get(&format!("/api/v1/syntheses/{}", fake_id)) + .await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "GET /syntheses/:id without auth should return 401" + ); + assert_eq!(body["error"], "unauthorized"); +} + +#[tokio::test] +async fn delete_synthesis_without_auth_returns_401() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let fake_id = uuid::Uuid::new_v4(); + + // DELETE without session — use raw request to include CSRF header + let req = axum::http::Request::builder() + .method(axum::http::Method::DELETE) + .uri(&format!("/api/v1/syntheses/{}", fake_id)) + .header("X-Requested-With", "XMLHttpRequest") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.raw_request(req).await; + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "DELETE /syntheses/:id without auth should return 401" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CRUD (6 tests) +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn get_syntheses_returns_empty_list_initially() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (_user_id, session) = app + .create_authenticated_user("synth-empty@example.com") + .await; + + let (status, body) = app + .get_with_session("/api/v1/syntheses", &session) + .await; + + assert_eq!(status, StatusCode::OK); + let items = body["items"].as_array().expect("items should be an array"); + assert!(items.is_empty(), "New user should have no syntheses"); +} + +#[tokio::test] +async fn get_syntheses_returns_inserted_synthesis() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-list@example.com") + .await; + + // Insert a synthesis directly via the helper + let sections = sample_sections(); + let synth_id = app + .insert_test_synthesis(user_id, "2026-W12", §ions) + .await; + + let (status, body) = app + .get_with_session("/api/v1/syntheses", &session) + .await; + + assert_eq!(status, StatusCode::OK); + let items = body["items"].as_array().expect("items array"); + assert_eq!(items.len(), 1, "Should have exactly 1 synthesis"); + + let item = &items[0]; + assert_eq!(item["id"], synth_id.to_string()); + assert_eq!(item["week"], "2026-W12"); + assert_eq!(item["status"], "completed"); + // first_section_title should be "AI News" + assert_eq!(item["first_section_title"], "AI News"); + assert_eq!(item["first_section_item_count"], 2); +} + +#[tokio::test] +async fn get_synthesis_by_id_returns_full_detail() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-detail@example.com") + .await; + + let sections = multi_sections(); + let synth_id = app + .insert_test_synthesis(user_id, "2026-W11", §ions) + .await; + + let (status, body) = app + .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], synth_id.to_string()); + assert_eq!(body["week"], "2026-W11"); + assert_eq!(body["status"], "completed"); + + let resp_sections = body["sections"].as_array().expect("sections array"); + assert_eq!(resp_sections.len(), 2); + assert_eq!(resp_sections[0]["title"], "Major Announcements"); + assert_eq!( + resp_sections[0]["items"].as_array().unwrap().len(), + 1 + ); + assert_eq!(resp_sections[1]["title"], "Research"); + assert_eq!( + resp_sections[1]["items"].as_array().unwrap().len(), + 2 + ); + + // Verify nested item details + let first_item = &resp_sections[0]["items"][0]; + assert_eq!(first_item["title"], "Big Launch"); + assert_eq!(first_item["url"], "https://example.com/launch"); + assert_eq!(first_item["summary"], "A big product launch"); +} + +#[tokio::test] +async fn get_synthesis_nonexistent_returns_404() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (_user_id, session) = app + .create_authenticated_user("synth-404@example.com") + .await; + + let fake_id = uuid::Uuid::new_v4(); + let (status, body) = app + .get_with_session(&format!("/api/v1/syntheses/{}", fake_id), &session) + .await; + + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Non-existent synthesis should return 404" + ); + assert_eq!(body["error"], "not_found"); +} + +#[tokio::test] +async fn delete_synthesis_returns_204() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-delete@example.com") + .await; + + let sections = sample_sections(); + let synth_id = app + .insert_test_synthesis(user_id, "2026-W12", §ions) + .await; + + let (status, _) = app + .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) + .await; + + assert_eq!( + status, + StatusCode::NO_CONTENT, + "DELETE /syntheses/:id should return 204" + ); +} + +#[tokio::test] +async fn delete_then_get_returns_empty_list() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-del-list@example.com") + .await; + + let sections = sample_sections(); + let synth_id = app + .insert_test_synthesis(user_id, "2026-W12", §ions) + .await; + + // Delete it + let (del_status, _) = app + .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) + .await; + assert_eq!(del_status, StatusCode::NO_CONTENT); + + // List should be empty now + let (status, body) = app + .get_with_session("/api/v1/syntheses", &session) + .await; + assert_eq!(status, StatusCode::OK); + let items = body["items"].as_array().expect("items array"); + assert!(items.is_empty(), "After deletion, list should be empty"); + + // GET by id should also 404 + let (get_status, get_body) = app + .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session) + .await; + assert_eq!(get_status, StatusCode::NOT_FOUND); + assert_eq!(get_body["error"], "not_found"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Ownership Isolation (2 tests) +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn user_a_syntheses_not_visible_to_user_b() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_a_id, session_a) = app + .create_authenticated_user("synth-owner-a@example.com") + .await; + let (_user_b_id, session_b) = app + .create_authenticated_user("synth-owner-b@example.com") + .await; + + // Insert a synthesis for User A + let sections = sample_sections(); + let synth_id = app + .insert_test_synthesis(user_a_id, "2026-W12", §ions) + .await; + + // User B lists syntheses — should be empty + let (status_b, body_b) = app + .get_with_session("/api/v1/syntheses", &session_b) + .await; + assert_eq!(status_b, StatusCode::OK); + let items_b = body_b["items"].as_array().expect("items array"); + assert!( + items_b.is_empty(), + "User B should not see User A's syntheses" + ); + + // User B tries to access User A's synthesis by ID — should 404 + let (get_status, get_body) = app + .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b) + .await; + assert_eq!( + get_status, + StatusCode::NOT_FOUND, + "User B should not access User A's synthesis" + ); + assert_eq!(get_body["error"], "not_found"); + + // User A can still see their synthesis + let (status_a, body_a) = app + .get_with_session("/api/v1/syntheses", &session_a) + .await; + assert_eq!(status_a, StatusCode::OK); + let items_a = body_a["items"].as_array().expect("items array"); + assert_eq!(items_a.len(), 1); +} + +#[tokio::test] +async fn user_b_cannot_delete_user_a_synthesis() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_a_id, session_a) = app + .create_authenticated_user("synth-del-a@example.com") + .await; + let (_user_b_id, session_b) = app + .create_authenticated_user("synth-del-b@example.com") + .await; + + // Insert a synthesis for User A + let sections = sample_sections(); + let synth_id = app + .insert_test_synthesis(user_a_id, "2026-W12", §ions) + .await; + + // User B tries to delete User A's synthesis — should 404 + let (del_status, del_body) = app + .delete_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_b) + .await; + assert_eq!( + del_status, + StatusCode::NOT_FOUND, + "User B should not be able to delete User A's synthesis" + ); + assert_eq!(del_body["error"], "not_found"); + + // User A's synthesis should still exist + let (get_status, _) = app + .get_with_session(&format!("/api/v1/syntheses/{}", synth_id), &session_a) + .await; + assert_eq!( + get_status, + StatusCode::OK, + "User A's synthesis should still exist after User B's delete attempt" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Pagination (2 tests) +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn get_syntheses_limit_returns_at_most_n() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-limit@example.com") + .await; + + // Insert 3 syntheses + let sections = sample_sections(); + for week in &["2026-W10", "2026-W11", "2026-W12"] { + app.insert_test_synthesis(user_id, week, §ions).await; + } + + // Request with limit=2 + let (status, body) = app + .get_with_session("/api/v1/syntheses?limit=2", &session) + .await; + + assert_eq!(status, StatusCode::OK); + let items = body["items"].as_array().expect("items array"); + assert_eq!( + items.len(), + 2, + "limit=2 should return at most 2 syntheses" + ); +} + +#[tokio::test] +async fn get_syntheses_offset_skips_first_item() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (user_id, session) = app + .create_authenticated_user("synth-offset@example.com") + .await; + + // Insert 3 syntheses (created in order, newest-first in response) + let sections = sample_sections(); + for week in &["2026-W10", "2026-W11", "2026-W12"] { + app.insert_test_synthesis(user_id, week, §ions).await; + // Small delay to ensure different created_at timestamps + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Get all to see the order + let (_, all_body) = app + .get_with_session("/api/v1/syntheses", &session) + .await; + let all_items = all_body["items"].as_array().unwrap(); + assert_eq!(all_items.len(), 3); + + // Get with offset=1 — should skip the first (newest) + let (status, body) = app + .get_with_session("/api/v1/syntheses?offset=1", &session) + .await; + + assert_eq!(status, StatusCode::OK); + let items = body["items"].as_array().expect("items array"); + assert_eq!(items.len(), 2, "offset=1 should skip 1 and return 2"); + + // The first item in the offset response should match the second in the full list + assert_eq!(items[0]["id"], all_items[1]["id"]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Generation Trigger (3 tests) +// ═══════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn generate_without_auth_returns_401() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let body = serde_json::json!({}); + let (status, resp) = app.post("/api/v1/syntheses/generate", &body).await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "POST /syntheses/generate without auth should return 401" + ); + assert_eq!(resp["error"], "unauthorized"); +} + +#[tokio::test] +async fn generate_returns_202_with_job_id() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (_user_id, session) = app + .create_authenticated_user("synth-gen@example.com") + .await; + + let body = serde_json::json!({}); + let (status, resp) = app + .post_with_session("/api/v1/syntheses/generate", &body, &session) + .await; + + assert_eq!( + status, + StatusCode::ACCEPTED, + "POST /syntheses/generate should return 202 Accepted" + ); + // Response should contain a job_id (UUID) + let job_id = resp["job_id"].as_str().expect("job_id should be a string"); + assert!( + uuid::Uuid::parse_str(job_id).is_ok(), + "job_id should be a valid UUID" + ); + assert!( + resp["message"].as_str().is_some(), + "Response should contain a message" + ); +} + +#[tokio::test] +async fn generate_twice_returns_error_for_second() { + if !require_test_db() { + eprintln!("SKIPPED: TEST_DATABASE_URL not set"); + return; + } + + let app = common::TestApp::new().await; + let (_user_id, session) = app + .create_authenticated_user("synth-gen-dup@example.com") + .await; + + let body = serde_json::json!({}); + + // First call should succeed with 202 + let (status1, resp1) = app + .post_with_session("/api/v1/syntheses/generate", &body, &session) + .await; + assert_eq!(status1, StatusCode::ACCEPTED); + assert!(resp1["job_id"].as_str().is_some()); + + // Second call should fail — one job at a time + let (status2, resp2) = app + .post_with_session("/api/v1/syntheses/generate", &body, &session) + .await; + assert_eq!( + status2, + StatusCode::BAD_REQUEST, + "Second generate call should be rejected while one is in progress" + ); + assert_eq!(resp2["error"], "bad_request"); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 5a2aa51..eaa3768 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -388,6 +388,25 @@ impl TestApp { .await .expect("Failed to create magic link") } + + // ── Synthesis helpers ──────────────────────────────────────────── + + /// Insert a test synthesis directly into the database, bypassing the + /// LLM generation pipeline. Returns the synthesis UUID. + /// + /// `sections_json` should be a `serde_json::Value` array of + /// `{title, items: [{title, url, summary}]}` objects. + pub async fn insert_test_synthesis( + &self, + user_id: uuid::Uuid, + week: &str, + sections_json: &serde_json::Value, + ) -> uuid::Uuid { + let row = db::syntheses::create(&self.pool, user_id, week, sections_json) + .await + .expect("Failed to insert test synthesis"); + row.id + } } #[allow(dead_code)] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e36856..84e9c3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ const AuthVerify = lazy(() => import('~/pages/AuthVerify')); const Home = lazy(() => import('~/pages/Home')); const Settings = lazy(() => import('~/pages/Settings')); const Sources = lazy(() => import('~/pages/Sources')); +const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis')); +const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail')); const AdminProviders = lazy(() => import('~/pages/admin/Providers')); const AdminRateLimits = lazy(() => import('~/pages/admin/RateLimits')); const AdminUsers = lazy(() => import('~/pages/admin/Users')); @@ -59,6 +61,8 @@ const App: Component = () => { + + {/* Admin routes with admin layout wrapper */} diff --git a/frontend/src/__tests__/sse.test.ts b/frontend/src/__tests__/sse.test.ts new file mode 100644 index 0000000..23a2ad7 --- /dev/null +++ b/frontend/src/__tests__/sse.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ProgressEvent } from '~/types'; + +// We test SSE event parsing logic in isolation, since EventSource +// requires a real HTTP connection that vitest/jsdom does not support. +// Instead we test the parsing logic that the SSE handler would use. + +describe('SSE event parsing', () => { + it('should parse a progress event JSON payload', () => { + const raw = '{"step":"search","message":"Recherche d\'actualites en cours...","percent":10}'; + const parsed: ProgressEvent = JSON.parse(raw); + expect(parsed.step).toBe('search'); + expect(parsed.message).toBe("Recherche d'actualites en cours..."); + expect(parsed.percent).toBe(10); + }); + + it('should parse a scraping progress event', () => { + const raw = '{"step":"scraping","message":"Verification des sources (3/12)...","percent":40}'; + const parsed: ProgressEvent = JSON.parse(raw); + expect(parsed.step).toBe('scraping'); + expect(parsed.percent).toBe(40); + }); + + it('should parse a rewrite progress event', () => { + const raw = '{"step":"rewrite","message":"Redaction des resumes...","percent":75}'; + const parsed: ProgressEvent = JSON.parse(raw); + expect(parsed.step).toBe('rewrite'); + expect(parsed.percent).toBe(75); + }); + + it('should parse a saving progress event', () => { + const raw = '{"step":"saving","message":"Sauvegarde...","percent":95}'; + const parsed: ProgressEvent = JSON.parse(raw); + expect(parsed.step).toBe('saving'); + expect(parsed.percent).toBe(95); + }); + + it('should parse a complete event', () => { + const raw = '{"synthesis_id":"abc123"}'; + const parsed = JSON.parse(raw) as { synthesis_id: string }; + expect(parsed.synthesis_id).toBe('abc123'); + }); + + it('should parse an error event', () => { + const raw = '{"message":"Quota depasse. Reessayez plus tard."}'; + const parsed = JSON.parse(raw) as { message: string }; + expect(parsed.message).toBe('Quota depasse. Reessayez plus tard.'); + }); + + it('should handle malformed JSON gracefully', () => { + const raw = 'not valid json{'; + expect(() => JSON.parse(raw)).toThrow(); + }); + + it('should handle empty data gracefully', () => { + const raw = ''; + expect(() => JSON.parse(raw)).toThrow(); + }); +}); + +describe('SSE step ordering', () => { + const STEPS = ['search', 'scraping', 'rewrite', 'saving']; + + it('should identify all steps before current as completed', () => { + const currentStep = 'rewrite'; + const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep)); + expect(completedSteps).toEqual(['search', 'scraping']); + }); + + it('should return empty for first step', () => { + const currentStep = 'search'; + const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep)); + expect(completedSteps).toEqual([]); + }); + + it('should return all but last for saving step', () => { + const currentStep = 'saving'; + const completedSteps = STEPS.slice(0, STEPS.indexOf(currentStep)); + expect(completedSteps).toEqual(['search', 'scraping', 'rewrite']); + }); +}); diff --git a/frontend/src/__tests__/synthesis-utils.test.ts b/frontend/src/__tests__/synthesis-utils.test.ts new file mode 100644 index 0000000..910b659 --- /dev/null +++ b/frontend/src/__tests__/synthesis-utils.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { extractWeekNumber, formatDate, formatDateLong } from '~/utils/dates'; + +describe('extractWeekNumber', () => { + it('should extract week number from standard ISO week string', () => { + expect(extractWeekNumber('2026-W12')).toBe('12'); + }); + + it('should extract single-digit week number', () => { + expect(extractWeekNumber('2026-W3')).toBe('3'); + }); + + it('should extract week number with leading zero', () => { + expect(extractWeekNumber('2026-W01')).toBe('01'); + }); + + it('should return raw input for invalid format', () => { + expect(extractWeekNumber('invalid')).toBe('invalid'); + }); + + it('should return raw input for empty string', () => { + expect(extractWeekNumber('')).toBe(''); + }); + + it('should handle week 52', () => { + expect(extractWeekNumber('2025-W52')).toBe('52'); + }); +}); + +describe('formatDate', () => { + it('should format ISO date string as dd MMM yyyy in French', () => { + const result = formatDate('2026-03-21T10:00:00Z'); + // date-fns fr locale: "21 mars 2026" but abbreviated month + expect(result).toMatch(/21/); + expect(result).toMatch(/2026/); + }); + + it('should return raw string for invalid date', () => { + expect(formatDate('not-a-date')).toBe('not-a-date'); + }); + + it('should handle ISO date without time', () => { + const result = formatDate('2026-01-15'); + expect(result).toMatch(/15/); + expect(result).toMatch(/2026/); + }); +}); + +describe('formatDateLong', () => { + it('should format ISO date string with full month name in French', () => { + const result = formatDateLong('2026-03-21T10:00:00Z'); + expect(result).toMatch(/21/); + expect(result).toMatch(/mars/); + expect(result).toMatch(/2026/); + }); + + it('should return raw string for invalid date', () => { + expect(formatDateLong('bad-date')).toBe('bad-date'); + }); +}); diff --git a/frontend/src/api/syntheses.ts b/frontend/src/api/syntheses.ts new file mode 100644 index 0000000..eec0100 --- /dev/null +++ b/frontend/src/api/syntheses.ts @@ -0,0 +1,21 @@ +import { api } from './client'; +import type { SynthesisListItem, Synthesis, GenerateResponse } from '~/types'; + +const API_BASE = '/api/v1'; + +export const synthesesApi = { + list: (limit = 50, offset = 0): Promise => + api.get(`/syntheses?limit=${limit}&offset=${offset}`), + + get: (id: string): Promise => + api.get(`/syntheses/${id}`), + + remove: (id: string): Promise => + api.delete(`/syntheses/${id}`), + + generate: (): Promise => + api.post('/syntheses/generate'), + + progressUrl: (jobId: string): string => + `${API_BASE}/syntheses/generate/${jobId}/progress`, +}; diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index b185ef7..50bf1c4 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -51,6 +51,48 @@ const fr = { 'Commencez par generer votre premiere synthese hebdomadaire.', 'home.empty.action': 'Generer', 'home.comingSoon': 'Disponible prochainement', + 'home.weekLabel': 'Semaine {week}', + 'home.cardTitle': 'Synthese de la semaine', + 'home.readLink': 'Lire la synthese', + 'home.deleteConfirm': 'Confirmer', + 'home.deleteTooltip': 'Supprimer', + 'home.deleteConfirmTooltip': 'Cliquer a nouveau pour confirmer', + 'home.noPreview': 'Aucune annonce majeure cette semaine.', + 'home.loadError': 'Erreur lors du chargement des syntheses.', + 'home.deleteError': 'Erreur lors de la suppression de la synthese.', + 'home.generationInProgress': 'Une generation est en cours...', + 'home.viewProgress': 'Voir la progression', + + // Generate + 'generate.title': 'Generer la Synthese Hebdomadaire', + 'generate.description': + "Cette action va lancer l'analyse des actualites des {days} derniers jours sur le theme \"{theme}\" via l'IA.", + 'generate.note': 'Note : La generation peut prendre jusqu\'a une minute.', + 'generate.launch': 'Lancer la generation', + 'generate.inProgress': 'Generation en cours...', + 'generate.step.search': 'Recherche d\'actualites', + 'generate.step.scraping': 'Verification des sources', + 'generate.step.rewrite': 'Redaction des resumes', + 'generate.step.saving': 'Sauvegarde', + 'generate.complete': 'Synthese generee avec succes ! Redirection...', + 'generate.error': 'Une erreur est survenue lors de la generation.', + 'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.', + 'generate.retry': 'Reessayer', + 'generate.alreadyInProgress': 'Une generation est deja en cours.', + + // Synthesis Detail + 'synthesis.title': 'Synthese de la Semaine {week}', + 'synthesis.generatedAt': 'Generee le {date}', + 'synthesis.backLink': 'Retour aux syntheses', + 'synthesis.delete': 'Supprimer', + 'synthesis.deleteConfirmMessage': + 'Etes-vous sur de vouloir supprimer cette synthese definitivement ?', + 'synthesis.deleteCancel': 'Annuler', + 'synthesis.deleteConfirm': 'Confirmer la suppression', + 'synthesis.notFound': 'Synthese introuvable.', + 'synthesis.loadError': 'Erreur lors du chargement de la synthese.', + 'synthesis.deleteError': 'Erreur lors de la suppression.', + 'synthesis.backToHome': 'Retour a l\'accueil', // Settings 'settings.title': 'Parametres de generation', diff --git a/frontend/src/pages/GenerateSynthesis.tsx b/frontend/src/pages/GenerateSynthesis.tsx new file mode 100644 index 0000000..f398aea --- /dev/null +++ b/frontend/src/pages/GenerateSynthesis.tsx @@ -0,0 +1,336 @@ +import { + type Component, + createSignal, + createEffect, + onMount, + Show, + For, +} from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { AlertCircle, CheckCircle, Circle, Loader2 } from 'lucide-solid'; +import { useI18n } from '~/i18n'; +import { synthesesApi } from '~/api/syntheses'; +import { settingsApi } from '~/api/settings'; +import { isApiError, DEFAULT_SETTINGS } from '~/types'; +import type { UserSettings, ProgressEvent } from '~/types'; +import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils/sse'; +import LoadingSpinner from '~/components/ui/LoadingSpinner'; + +interface StepInfo { + key: string; + label: string; +} + +const STEPS: StepInfo[] = [ + { key: 'search', label: 'generate.step.search' }, + { key: 'scraping', label: 'generate.step.scraping' }, + { key: 'rewrite', label: 'generate.step.rewrite' }, + { key: 'saving', label: 'generate.step.saving' }, +]; + +const GenerateSynthesis: Component = () => { + const { t } = useI18n(); + const navigate = useNavigate(); + + const [settings, setSettings] = createSignal({ ...DEFAULT_SETTINGS }); + const [loadingSettings, setLoadingSettings] = createSignal(true); + const [generating, setGenerating] = createSignal(false); + const [error, setError] = createSignal(null); + const [success, setSuccess] = createSignal(false); + const [sseConnection, setSSEConnection] = createSignal(null); + + onMount(async () => { + try { + const data = await settingsApi.get(); + setSettings(data); + } catch (err) { + if (isApiError(err) && err.status !== 404) { + // Non-404 means a real error; 404 means no settings yet, use defaults + } + } finally { + setLoadingSettings(false); + } + }); + + const currentStep = (): string | null => { + const conn = sseConnection(); + if (!conn) return null; + const progress = conn.latestProgress(); + return progress?.step ?? null; + }; + + const currentPercent = (): number => { + const conn = sseConnection(); + if (!conn) return 0; + const progress = conn.latestProgress(); + return progress?.percent ?? 0; + }; + + const currentMessage = (): string => { + const conn = sseConnection(); + if (!conn) return ''; + const progress = conn.latestProgress(); + return progress?.message ?? ''; + }; + + const sseStatus = (): SSEStatus => { + const conn = sseConnection(); + if (!conn) return 'idle'; + return conn.status(); + }; + + // Collect completed steps from progress events + const completedSteps = (): Set => { + const conn = sseConnection(); + if (!conn) return new Set(); + const events = conn.events(); + const completed = new Set(); + const progressEvents = events + .filter((e) => e.type === 'progress') + .map((e) => (e as { type: 'progress'; data: ProgressEvent }).data); + + // Mark all steps before the current one as completed + const current = currentStep(); + for (const step of STEPS) { + if (step.key === current) break; + // If we've received any progress event for a later step, earlier ones are done + const hasLaterEvent = progressEvents.some((pe) => { + const stepIndex = STEPS.findIndex((s) => s.key === pe.step); + const thisIndex = STEPS.findIndex((s) => s.key === step.key); + return stepIndex > thisIndex; + }); + if (hasLaterEvent) { + completed.add(step.key); + } + } + + // Also mark steps that had a 100%-equivalent high percent + for (const pe of progressEvents) { + const stepIndex = STEPS.findIndex((s) => s.key === pe.step); + const currentStepIndex = current ? STEPS.findIndex((s) => s.key === current) : -1; + if (stepIndex >= 0 && stepIndex < currentStepIndex) { + completed.add(pe.step); + } + } + + return completed; + }; + + const stepStatus = (stepKey: string): 'done' | 'in-progress' | 'pending' => { + const conn = sseConnection(); + if (!conn) return 'pending'; + + if (sseStatus() === 'complete') return 'done'; + if (completedSteps().has(stepKey)) return 'done'; + if (currentStep() === stepKey) return 'in-progress'; + + // Check if this step comes before the current step + const thisIndex = STEPS.findIndex((s) => s.key === stepKey); + const currentIndex = currentStep() + ? STEPS.findIndex((s) => s.key === currentStep()) + : -1; + if (thisIndex < currentIndex) return 'done'; + + return 'pending'; + }; + + // Auto-redirect on completion + createEffect(() => { + const conn = sseConnection(); + if (!conn) return; + const synthId = conn.completedSynthesisId(); + if (synthId) { + setSuccess(true); + setTimeout(() => { + navigate(`/synthesis/${synthId}`); + }, 2000); + } + }); + + // Handle SSE errors + createEffect(() => { + const conn = sseConnection(); + if (!conn) return; + const errMsg = conn.errorMessage(); + if (errMsg) { + setError(errMsg); + setGenerating(false); + } + }); + + const handleGenerate = async () => { + if (generating()) return; + + setGenerating(true); + setError(null); + setSuccess(false); + + try { + const response = await synthesesApi.generate(); + const url = synthesesApi.progressUrl(response.job_id); + const conn = createSSEConnection(url); + setSSEConnection(conn); + } catch (err) { + setGenerating(false); + if (isApiError(err)) { + setError(err.message); + } else { + setError(t('generate.error')); + } + } + }; + + const handleRetry = () => { + // Close existing connection + const conn = sseConnection(); + if (conn) { + conn.close(); + } + setSSEConnection(null); + setError(null); + setSuccess(false); + setGenerating(false); + }; + + const isInProgress = () => generating() && !success() && !error(); + + return ( + }> +
+
+
+

+ {t('generate.title')} +

+
+

+

+ {t('generate.note')} +

+
+ + {/* Error display */} + +
+
+
+
+
+

{error()}

+
+
+
+
+ + {/* Success display */} + +
+
+
+
+
+

{t('generate.complete')}

+
+
+
+
+ + {/* Progress display */} + +
+ {/* Progress bar */} +
+
+ {currentMessage()} + {currentPercent()}% +
+
+
+
+
+ + {/* Step checklist */} +
+ + {(step) => { + const status = () => stepStatus(step.key); + return ( +
+ + + + + + + + + + + {t(step.label as Parameters[0])} + +
+ ); + }} +
+
+ + {/* Can leave note */} +

+ {t('generate.canLeave')} +

+
+ + + {/* Action buttons */} +
+ + {t('generate.retry')} + + } + > + + +
+
+
+
+ + ); +}; + +export default GenerateSynthesis; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 2b413f4..214fa48 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,48 +1,219 @@ -import type { Component } from 'solid-js'; +import { + type Component, + createSignal, + onMount, + Show, + For, +} from 'solid-js'; import { A } from '@solidjs/router'; -import { FileText, Plus } from 'lucide-solid'; +import { FileText, Plus, Trash2 } from 'lucide-solid'; import { useI18n } from '~/i18n'; +import { synthesesApi } from '~/api/syntheses'; +import { isApiError } from '~/types'; +import type { SynthesisListItem } from '~/types'; +import { extractWeekNumber, formatDate } from '~/utils/dates'; +import LoadingSpinner from '~/components/ui/LoadingSpinner'; const Home: Component = () => { const { t } = useI18n(); - return ( -
-
-
-

{t('home.title')}

-

{t('home.subtitle')}

-
- -
+ const [syntheses, setSyntheses] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [deletingId, setDeletingId] = createSignal(null); + const [deleteTimers, setDeleteTimers] = createSignal>>({}); + + onMount(async () => { + try { + const data = await synthesesApi.list(); + setSyntheses(data); + } catch (err) { + if (isApiError(err)) { + setError(err.message); + } else { + setError(t('home.loadError')); + } + } finally { + setLoading(false); + } + }); + + const handleDelete = async (e: MouseEvent, id: string) => { + e.preventDefault(); + e.stopPropagation(); + + if (deletingId() === id) { + // Second click: confirm deletion + const timers = deleteTimers(); + if (timers[id]) { + clearTimeout(timers[id]); + const newTimers = { ...timers }; + delete newTimers[id]; + setDeleteTimers(newTimers); + } - {/* Empty state */} -
- -

- {t('home.empty.title')} -

-

- {t('home.empty.description')} -

-
- + {t('home.newSynthesis')} +
+ + {/* In-progress banner */} + + -
+ ); }; diff --git a/frontend/src/pages/SynthesisDetail.tsx b/frontend/src/pages/SynthesisDetail.tsx new file mode 100644 index 0000000..ffecc10 --- /dev/null +++ b/frontend/src/pages/SynthesisDetail.tsx @@ -0,0 +1,191 @@ +import { + type Component, + createSignal, + onMount, + Show, + For, +} from 'solid-js'; +import { useParams, useNavigate, A } from '@solidjs/router'; +import { ArrowLeft, ExternalLink, Trash2, AlertTriangle } from 'lucide-solid'; +import { useI18n } from '~/i18n'; +import { synthesesApi } from '~/api/syntheses'; +import { isApiError } from '~/types'; +import type { Synthesis, NewsItem as NewsItemType } from '~/types'; +import { extractWeekNumber, formatDateLong } from '~/utils/dates'; +import LoadingSpinner from '~/components/ui/LoadingSpinner'; + +const NewsItemCard: Component<{ item: NewsItemType }> = (props) => { + return ( +
+

+ + {props.item.title} + + +

+

+ {props.item.summary} +

+
+ ); +}; + +const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => { + return ( + 0}> +
+

+ {props.title} +

+
+ + {(item) => } + +
+
+
+ ); +}; + +const SynthesisDetail: Component = () => { + const { t } = useI18n(); + const params = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [synthesis, setSynthesis] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false); + const [isDeleting, setIsDeleting] = createSignal(false); + + onMount(async () => { + try { + const data = await synthesesApi.get(params.id); + setSynthesis(data); + } catch (err) { + if (isApiError(err) && err.status === 404) { + setError(t('synthesis.notFound')); + } else if (isApiError(err)) { + setError(err.message); + } else { + setError(t('synthesis.loadError')); + } + } finally { + setLoading(false); + } + }); + + const handleDelete = async () => { + const synth = synthesis(); + if (!synth) return; + + setIsDeleting(true); + try { + await synthesesApi.remove(synth.id); + navigate('/'); + } catch (err) { + if (isApiError(err)) { + setError(err.message); + } else { + setError(t('synthesis.deleteError')); + } + setIsDeleting(false); + setShowDeleteConfirm(false); + } + }; + + return ( + }> + +

{error()}

+ + ← {t('synthesis.backToHome')} + +
+ } + > + {(synth) => ( +
+
+ {/* Top bar: back link + delete */} +
+ + + {t('synthesis.backLink')} + + +
+ + {/* Delete confirmation banner */} + +
+
+ +

{t('synthesis.deleteConfirmMessage')}

+
+
+ + +
+
+
+ + {/* Title + date badge */} +
+

+ {t('synthesis.title', { week: extractWeekNumber(synth().week) })} +

+ + {t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })} + +
+
+ + {/* Sections */} +
+ + {(section) => ( +
+ )} + +
+
+ )} + + + ); +}; + +export default SynthesisDetail; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a15a783..f0eb047 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -91,6 +91,46 @@ export interface BulkImportResponse { errors: string[]; } +// ---- Synthesis ---- + +export interface NewsItem { + title: string; + url: string; + summary: string; +} + +export interface NewsSection { + title: string; + items: NewsItem[]; +} + +export interface Synthesis { + id: string; + user_id: string; + week: string; + sections: NewsSection[]; + status: string; + created_at: string; +} + +export interface SynthesisListItem { + id: string; + week: string; + sections: NewsSection[]; + status: string; + created_at: string; +} + +export interface GenerateResponse { + job_id: string; +} + +export interface ProgressEvent { + step: string; + message: string; + percent: number; +} + // ---- API Error ---- export interface ApiError { diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts new file mode 100644 index 0000000..e499d3d --- /dev/null +++ b/frontend/src/utils/dates.ts @@ -0,0 +1,33 @@ +import { format, parseISO } from 'date-fns'; +import { fr } from 'date-fns/locale'; + +/** + * Extract the week number from an ISO week string like "2026-W12". + * Returns the numeric string (e.g. "12") or the raw input if parsing fails. + */ +export function extractWeekNumber(week: string): string { + const match = week.match(/-W(\d{1,2})$/); + return match ? match[1] : week; +} + +/** + * Format an ISO date string (e.g. "2026-03-21T10:00:00Z") as "21 mars 2026". + */ +export function formatDate(isoDate: string): string { + try { + return format(parseISO(isoDate), 'dd MMM yyyy', { locale: fr }); + } catch { + return isoDate; + } +} + +/** + * Format an ISO date string as "21 mars 2026" (full month name). + */ +export function formatDateLong(isoDate: string): string { + try { + return format(parseISO(isoDate), 'dd MMMM yyyy', { locale: fr }); + } catch { + return isoDate; + } +} diff --git a/frontend/src/utils/sse.ts b/frontend/src/utils/sse.ts new file mode 100644 index 0000000..70901a3 --- /dev/null +++ b/frontend/src/utils/sse.ts @@ -0,0 +1,145 @@ +import { createSignal, onCleanup } from 'solid-js'; +import type { ProgressEvent } from '~/types'; + +export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'complete' | 'error'; + +export interface SSECompleteEvent { + type: 'complete'; + synthesis_id: string; +} + +export interface SSEErrorEvent { + type: 'error'; + message: string; +} + +export interface SSEProgressEvent { + type: 'progress'; + data: ProgressEvent; +} + +export type SSEEvent = SSEProgressEvent | SSECompleteEvent | SSEErrorEvent; + +export interface SSEConnection { + events: () => SSEEvent[]; + status: () => SSEStatus; + latestProgress: () => ProgressEvent | null; + completedSynthesisId: () => string | null; + errorMessage: () => string | null; + close: () => void; +} + +const MAX_RETRIES = 3; +const BASE_RETRY_DELAY = 1000; + +export function createSSEConnection(url: string): SSEConnection { + const [events, setEvents] = createSignal([]); + const [status, setStatus] = createSignal('idle'); + const [latestProgress, setLatestProgress] = createSignal(null); + const [completedSynthesisId, setCompletedSynthesisId] = createSignal(null); + const [errorMessage, setErrorMessage] = createSignal(null); + + let eventSource: EventSource | null = null; + let retryCount = 0; + let retryTimeout: ReturnType | null = null; + let closed = false; + + function connect() { + if (closed) return; + + setStatus('connecting'); + eventSource = new EventSource(url, { withCredentials: true }); + + eventSource.onopen = () => { + setStatus('connected'); + retryCount = 0; + }; + + eventSource.addEventListener('progress', (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) as ProgressEvent; + const event: SSEProgressEvent = { type: 'progress', data }; + setEvents((prev) => [...prev, event]); + setLatestProgress(data); + } catch { + // Ignore malformed events + } + }); + + eventSource.addEventListener('complete', (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) as { synthesis_id: string }; + const event: SSECompleteEvent = { type: 'complete', synthesis_id: data.synthesis_id }; + setEvents((prev) => [...prev, event]); + setCompletedSynthesisId(data.synthesis_id); + setStatus('complete'); + cleanup(); + } catch { + // Ignore malformed events + } + }); + + eventSource.addEventListener('error', (e: MessageEvent) => { + // Named 'error' event from server (not connection error) + try { + const data = JSON.parse(e.data) as { message: string }; + const event: SSEErrorEvent = { type: 'error', message: data.message }; + setEvents((prev) => [...prev, event]); + setErrorMessage(data.message); + setStatus('error'); + cleanup(); + } catch { + // Ignore malformed events + } + }); + + eventSource.onerror = () => { + // Connection error -- attempt reconnect if not already completed or explicitly errored + if (closed || status() === 'complete' || status() === 'error') return; + + cleanup(); + + if (retryCount < MAX_RETRIES) { + retryCount++; + const delay = BASE_RETRY_DELAY * Math.pow(2, retryCount - 1); + retryTimeout = setTimeout(() => { + connect(); + }, delay); + } else { + setErrorMessage('La connexion au serveur a ete perdue. Veuillez reessayer.'); + setStatus('error'); + } + }; + } + + function cleanup() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + function close() { + closed = true; + if (retryTimeout) { + clearTimeout(retryTimeout); + retryTimeout = null; + } + cleanup(); + } + + connect(); + + onCleanup(() => { + close(); + }); + + return { + events, + status, + latestProgress, + completedSynthesisId, + errorMessage, + close, + }; +}