Phase 5: Generation pipeline with SSE progress, syntheses CRUD

Backend:
- Full 2-pass generation pipeline: LLM search -> URL scraping -> LLM rewrite
- Async generation with tokio::spawn, JobStore with per-user concurrency limit
- SSE progress streaming via axum::response::Sse + tokio::sync::watch
- Syntheses CRUD: list (paginated), get (ownership check), delete
- Prompt construction ported from original geminiService.ts
- Parallel URL scraping with bounded concurrency (max 10)
- Graceful partial failure handling (some URLs fail -> continue)
- 36 new unit tests, 16 integration tests

Frontend:
- Home dashboard: synthesis card grid, week badges, delete with confirmation
- Generate page: SSE-driven progress bar, step checklist, auto-redirect
- Synthesis detail: section-by-section display, external links, delete
- SSE client helper with auto-reconnect (exponential backoff)
- Date utilities with French locale formatting

Critical fixes applied:
- SSE EventSource now sends credentials (withCredentials: true)
- Gemini error logging sanitized to prevent API key leak in logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 6 months ago
parent 439e547367
commit aa6f1ba76b

@ -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"
}

31
backend/Cargo.lock generated

@ -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]]

@ -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"

@ -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);

@ -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,
}
}
}

@ -6,4 +6,5 @@ pub mod rate_limits;
pub mod sessions;
pub mod settings;
pub mod sources;
pub mod syntheses;
pub mod users;

@ -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<Vec<Synthesis>, 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<Option<Synthesis>, 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<Option<Synthesis>, 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<NewsSection>`.
pub async fn create(
pool: &PgPool,
user_id: Uuid,
week: &str,
sections_json: &serde_json::Value,
) -> Result<Synthesis, AppError> {
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<bool, AppError> {
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)
}

@ -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<AppState>,
) -> Result<impl IntoResponse, AppError> {
// 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<AppState>,
Path(job_id): Path<Uuid>,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>>, 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"),
))
}

@ -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;

@ -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<i64>,
/// Number of syntheses to skip (default: 0).
pub offset: Option<i64>,
}
/// Response for `GET /api/v1/syntheses`.
#[derive(Debug, Serialize)]
pub struct ListResponse {
pub items: Vec<SynthesisListItem>,
}
/// `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<AppState>,
Query(params): Query<ListQuery>,
) -> Result<impl IntoResponse, AppError> {
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<SynthesisListItem> = syntheses
.into_iter()
.map(SynthesisListItem::try_from)
.collect::<Result<Vec<_>, _>>()?;
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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)
}

@ -6,4 +6,5 @@ pub mod rate_limit;
pub mod session;
pub mod settings;
pub mod source;
pub mod synthesis;
pub mod user;

@ -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<NewsItem>,
}
/// 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<Utc>,
}
/// Response shape for `GET /api/v1/syntheses/:id`.
#[derive(Debug, Serialize)]
pub struct SynthesisResponse {
pub id: Uuid,
pub week: String,
pub sections: Vec<NewsSection>,
pub status: String,
pub created_at: DateTime<Utc>,
}
impl TryFrom<Synthesis> for SynthesisResponse {
type Error = crate::errors::AppError;
fn try_from(s: Synthesis) -> Result<Self, Self::Error> {
let sections: Vec<NewsSection> =
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<Utc>,
pub first_section_title: Option<String>,
pub first_section_item_count: usize,
}
impl TryFrom<Synthesis> for SynthesisListItem {
type Error = crate::errors::AppError;
fn try_from(s: Synthesis) -> Result<Self, Self::Error> {
let sections: Vec<NewsSection> =
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(&section).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());
}
}

@ -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

@ -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"))
})?;

@ -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;

@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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, Vec<ScrapedNewsItem>>,
) -> (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"));
}
}

@ -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<watch::Sender<ProgressEvent>>,
/// 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<ProgressEvent>,
/// 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<DashMap<Uuid, JobEntry>>,
}
/// 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<watch::Sender<ProgressEvent>>)> {
// 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<watch::Receiver<ProgressEvent>> {
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<Uuid> {
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<watch::Sender<ProgressEvent>>,
) {
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<ProgressEvent>,
) -> Result<Uuid, AppError> {
// 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, &current_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<NewsItem>)
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, &sections_json).await?;
Ok(synthesis.id)
}
// ───────────────────────────────────────────────────────────────────
// Helper Functions
// ───────────────────────────────────────────────────────────────────
/// Emit a progress event via the watch channel.
fn emit_progress(tx: &watch::Sender<ProgressEvent>, 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<String, AppError> {
// 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<Vec<(String, Vec<NewsItem>)>, 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<NewsItem> = 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<NewsItem>)],
max_age_days: i64,
tx: &watch::Sender<ProgressEvent>,
) -> HashMap<String, Vec<ScrapedNewsItem>> {
let mut result: HashMap<String, Vec<ScrapedNewsItem>> = 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<Vec<NewsSection>, 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<NewsItem> = 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);
}
}

@ -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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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", &sections)
.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, &sections).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, &sections).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");
}

@ -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)]

@ -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 = () => {
<Route path="/" component={Home} />
<Route path="/settings" component={Settings} />
<Route path="/sources" component={Sources} />
<Route path="/generate" component={GenerateSynthesis} />
<Route path="/synthesis/:id" component={SynthesisDetail} />
</Route>
{/* Admin routes with admin layout wrapper */}

@ -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']);
});
});

@ -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');
});
});

@ -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<SynthesisListItem[]> =>
api.get<SynthesisListItem[]>(`/syntheses?limit=${limit}&offset=${offset}`),
get: (id: string): Promise<Synthesis> =>
api.get<Synthesis>(`/syntheses/${id}`),
remove: (id: string): Promise<void> =>
api.delete<void>(`/syntheses/${id}`),
generate: (): Promise<GenerateResponse> =>
api.post<GenerateResponse>('/syntheses/generate'),
progressUrl: (jobId: string): string =>
`${API_BASE}/syntheses/generate/${jobId}/progress`,
};

@ -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',

@ -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<UserSettings>({ ...DEFAULT_SETTINGS });
const [loadingSettings, setLoadingSettings] = createSignal(true);
const [generating, setGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [success, setSuccess] = createSignal(false);
const [sseConnection, setSSEConnection] = createSignal<SSEConnection | null>(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<string> => {
const conn = sseConnection();
if (!conn) return new Set();
const events = conn.events();
const completed = new Set<string>();
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 (
<Show when={!loadingSettings()} fallback={<LoadingSpinner />}>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{t('generate.title')}
</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p innerHTML={t('generate.description', {
days: String(settings().max_age_days),
theme: settings().theme,
})} />
<p class="mt-2 text-xs text-gray-400">
{t('generate.note')}
</p>
</div>
{/* Error display */}
<Show when={error()}>
<div class="mt-4 bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<AlertCircle class="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-red-700">{error()}</p>
</div>
</div>
</div>
</Show>
{/* Success display */}
<Show when={success()}>
<div class="mt-4 bg-green-50 border-l-4 border-green-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<CheckCircle class="h-5 w-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-green-700">{t('generate.complete')}</p>
</div>
</div>
</div>
</Show>
{/* Progress display */}
<Show when={isInProgress() && sseConnection()}>
<div class="mt-6 space-y-4">
{/* Progress bar */}
<div>
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>{currentMessage()}</span>
<span>{currentPercent()}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-3">
<div
class="bg-indigo-600 rounded-full h-3 transition-all duration-500 ease-out"
style={{ width: `${currentPercent()}%` }}
/>
</div>
</div>
{/* Step checklist */}
<div class="space-y-3 mt-4">
<For each={STEPS}>
{(step) => {
const status = () => stepStatus(step.key);
return (
<div class="flex items-center gap-3">
<Show when={status() === 'done'}>
<CheckCircle class="h-5 w-5 text-green-500 flex-shrink-0" />
</Show>
<Show when={status() === 'in-progress'}>
<Loader2 class="h-5 w-5 text-indigo-600 animate-spin flex-shrink-0" />
</Show>
<Show when={status() === 'pending'}>
<Circle class="h-5 w-5 text-gray-300 flex-shrink-0" />
</Show>
<span
class={`text-sm ${
status() === 'done'
? 'text-green-700'
: status() === 'in-progress'
? 'text-indigo-700 font-medium'
: 'text-gray-400'
}`}
>
{t(step.label as Parameters<typeof t>[0])}
</span>
</div>
);
}}
</For>
</div>
{/* Can leave note */}
<p class="text-xs text-gray-400 mt-4 italic">
{t('generate.canLeave')}
</p>
</div>
</Show>
{/* Action buttons */}
<div class="mt-5">
<Show
when={!error()}
fallback={
<button
type="button"
onClick={handleRetry}
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm w-full sm:w-auto"
>
{t('generate.retry')}
</button>
}
>
<button
type="button"
onClick={handleGenerate}
disabled={generating() || success()}
class="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
>
<Show when={generating() && !success()}>
<Loader2 class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" />
</Show>
{generating() && !success()
? t('generate.inProgress')
: t('generate.launch')}
</button>
</Show>
</div>
</div>
</div>
</div>
</Show>
);
};
export default GenerateSynthesis;

@ -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 (
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">{t('home.title')}</h1>
<p class="mt-2 text-sm text-gray-500">{t('home.subtitle')}</p>
</div>
<button
disabled
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
title={t('home.comingSoon')}
>
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{t('home.newSynthesis')}
</button>
</div>
const [syntheses, setSyntheses] = createSignal<SynthesisListItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [deletingId, setDeletingId] = createSignal<string | null>(null);
const [deleteTimers, setDeleteTimers] = createSignal<Record<string, ReturnType<typeof setTimeout>>>({});
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 */}
<div class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
<FileText class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">
{t('home.empty.title')}
</h3>
<p class="mt-1 text-sm text-gray-500">
{t('home.empty.description')}
</p>
<div class="mt-6">
<button
disabled
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
try {
await synthesesApi.remove(id);
setSyntheses((prev) => prev.filter((s) => s.id !== id));
setDeletingId(null);
} catch (err) {
setDeletingId(null);
if (isApiError(err)) {
setError(err.message);
} else {
setError(t('home.deleteError'));
}
}
} else {
// First click: show confirmation state
setDeletingId(id);
// Auto-cancel after 3 seconds
const timer = setTimeout(() => {
setDeletingId((current) => (current === id ? null : current));
setDeleteTimers((prev) => {
const newTimers = { ...prev };
delete newTimers[id];
return newTimers;
});
}, 3000);
setDeleteTimers((prev) => ({ ...prev, [id]: timer }));
}
};
const hasInProgress = () =>
syntheses().some((s) => s.status === 'in_progress');
return (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">{t('home.title')}</h1>
<p class="mt-2 text-sm text-gray-500">{t('home.subtitle')}</p>
</div>
<A
href="/generate"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{t('home.empty.action')}
</button>
{t('home.newSynthesis')}
</A>
</div>
{/* In-progress banner */}
<Show when={hasInProgress()}>
<div class="mb-6 bg-indigo-50 border border-indigo-200 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600 mr-3" />
<span class="text-sm font-medium text-indigo-800">
{t('home.generationInProgress')}
</span>
</div>
<A
href="/generate"
class="text-sm font-medium text-indigo-600 hover:text-indigo-800"
>
{t('home.viewProgress')}
</A>
</div>
</Show>
{/* Error display */}
<Show when={error()}>
<div class="mb-6 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-red-800">
{error()}
</div>
</Show>
<Show
when={syntheses().length > 0}
fallback={
<div class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
<FileText class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">
{t('home.empty.title')}
</h3>
<p class="mt-1 text-sm text-gray-500">
{t('home.empty.description')}
</p>
<div class="mt-6">
<A
href="/generate"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<Plus class="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{t('home.empty.action')}
</A>
</div>
</div>
}
>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<For each={syntheses()}>
{(synth) => (
<A
href={`/synthesis/${synth.id}`}
class="block bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md hover:border-indigo-300 transition-all duration-200 overflow-hidden"
>
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{t('home.weekLabel', { week: extractWeekNumber(synth.week) })}
</span>
<span class="text-sm text-gray-500">
{formatDate(synth.created_at)}
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{t('home.cardTitle')}
</h3>
<div class="text-sm text-gray-600 space-y-1.5">
<Show
when={synth.sections?.[0]?.items?.length > 0}
fallback={
<p>{t('home.noPreview')}</p>
}
>
<For each={synth.sections[0].items.slice(0, 4)}>
{(item) => (
<p class="line-clamp-2">&bull; {item.title}</p>
)}
</For>
</Show>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center">
<span class="text-sm font-medium text-indigo-600 hover:text-indigo-500">
{t('home.readLink')} &rarr;
</span>
<button
onClick={(e) => handleDelete(e, synth.id)}
class={`inline-flex items-center p-1.5 rounded-md transition-colors ${
deletingId() === synth.id
? 'bg-red-100 text-red-700 hover:bg-red-200'
: 'text-gray-400 hover:text-red-600 hover:bg-red-50'
}`}
title={
deletingId() === synth.id
? t('home.deleteConfirmTooltip')
: t('home.deleteTooltip')
}
>
<Show when={deletingId() === synth.id}>
<span class="text-xs font-medium mr-1">
{t('home.deleteConfirm')}
</span>
</Show>
<Trash2 class="h-4 w-4" />
</button>
</div>
</A>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
);
};

@ -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 (
<div class="bg-white rounded-lg shadow-sm border border-gray-100 p-6 hover:shadow-md transition-shadow">
<h3 class="text-lg font-semibold text-indigo-700 mb-2">
<a
href={props.item.url}
target="_blank"
rel="noopener noreferrer"
class="hover:underline flex items-center gap-2"
>
{props.item.title}
<ExternalLink class="h-4 w-4 text-gray-400 flex-shrink-0" />
</a>
</h3>
<p class="text-gray-700 leading-relaxed text-sm">
{props.item.summary}
</p>
</div>
);
};
const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => {
return (
<Show when={props.items && props.items.length > 0}>
<div class="mb-10">
<h2 class="text-2xl font-bold text-gray-900 mb-6 border-b pb-2">
{props.title}
</h2>
<div class="space-y-6">
<For each={props.items}>
{(item) => <NewsItemCard item={item} />}
</For>
</div>
</div>
</Show>
);
};
const SynthesisDetail: Component = () => {
const { t } = useI18n();
const params = useParams<{ id: string }>();
const navigate = useNavigate();
const [synthesis, setSynthesis] = createSignal<Synthesis | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(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 (
<Show when={!loading()} fallback={<LoadingSpinner />}>
<Show
when={!error() && synthesis()}
fallback={
<div class="max-w-4xl mx-auto px-4 py-12 text-center">
<p class="text-red-600 mb-4">{error()}</p>
<A href="/" class="text-indigo-600 hover:text-indigo-800 font-medium">
&larr; {t('synthesis.backToHome')}
</A>
</div>
}
>
{(synth) => (
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
{/* Top bar: back link + delete */}
<div class="flex justify-between items-center mb-6">
<A
href="/"
class="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-800"
>
<ArrowLeft class="mr-2 h-4 w-4" />
{t('synthesis.backLink')}
</A>
<button
onClick={() => setShowDeleteConfirm(true)}
class="inline-flex items-center text-sm font-medium text-red-600 hover:text-red-800"
>
<Trash2 class="mr-1 h-4 w-4" />
{t('synthesis.delete')}
</button>
</div>
{/* Delete confirmation banner */}
<Show when={showDeleteConfirm()}>
<div class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center text-red-800">
<AlertTriangle class="h-5 w-5 mr-2 flex-shrink-0" />
<p class="text-sm">{t('synthesis.deleteConfirmMessage')}</p>
</div>
<div class="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting()}
class="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
{t('synthesis.deleteCancel')}
</button>
<button
onClick={handleDelete}
disabled={isDeleting()}
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 disabled:opacity-50"
>
<Show when={isDeleting()}>
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
</Show>
{t('synthesis.deleteConfirm')}
</button>
</div>
</div>
</Show>
{/* Title + date badge */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h1 class="text-3xl font-extrabold text-gray-900 tracking-tight">
{t('synthesis.title', { week: extractWeekNumber(synth().week) })}
</h1>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
{t('synthesis.generatedAt', { date: formatDateLong(synth().created_at) })}
</span>
</div>
</div>
{/* Sections */}
<div class="space-y-12">
<For each={synth().sections}>
{(section) => (
<Section title={section.title} items={section.items} />
)}
</For>
</div>
</div>
)}
</Show>
</Show>
);
};
export default SynthesisDetail;

@ -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 {

@ -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;
}
}

@ -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<SSEEvent[]>([]);
const [status, setStatus] = createSignal<SSEStatus>('idle');
const [latestProgress, setLatestProgress] = createSignal<ProgressEvent | null>(null);
const [completedSynthesisId, setCompletedSynthesisId] = createSignal<string | null>(null);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
let eventSource: EventSource | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | 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,
};
}
Loading…
Cancel
Save