From b124d73c2a9264fc8e260260d02280a92ec33208 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 28 Mar 2026 19:51:14 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20P1=20audit=20items=20=E2=80=94=20CSV=20e?= =?UTF-8?q?xport=20theme=20filter,=20theme=20validation,=20ownership=20che?= =?UTF-8?q?cks,=20history=20enums,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export_csv now accepts optional theme_id query param and filters accordingly - Add UpdateThemeRequest::validate() with bounds checking; call it in the update handler - Verify theme ownership in sources::create when theme_id is provided - Update STATUS_OPTIONS (add filtered_too_old, filtered_not_article; remove filtered_duplicate) and SOURCE_TYPE_OPTIONS (add brave_search; remove overflow) in ArticleHistory - Replace hardcoded French strings ('Confirmer', 'Erreur inconnue') with t() calls; add settings.apiKeys.unknownError key to fr.ts Co-Authored-By: Claude Sonnet 4.6 --- backend/src/handlers/sources.rs | 20 ++++++++- backend/src/handlers/themes.rs | 2 + backend/src/models/theme.rs | 44 +++++++++++++++++++ frontend/src/components/ApiKeyManager.tsx | 2 +- .../settings/SettingsBraveSearch.tsx | 2 +- frontend/src/i18n/fr.ts | 1 + frontend/src/pages/ArticleHistory.tsx | 7 +-- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/backend/src/handlers/sources.rs b/backend/src/handlers/sources.rs index 5e58ffe..0c276f7 100644 --- a/backend/src/handlers/sources.rs +++ b/backend/src/handlers/sources.rs @@ -60,6 +60,14 @@ pub async fn create( // Validate request fields body.validate().map_err(AppError::Validation)?; + // Verify theme ownership if theme_id is provided + if let Some(tid) = body.theme_id { + let theme = db::themes::get_by_id(&state.pool, auth_user.id, tid).await?; + if theme.is_none() { + return Err(AppError::NotFound("Theme not found".into())); + } + } + // Check source limit let count = db::sources::count_for_user(&state.pool, auth_user.id).await?; if count >= MAX_SOURCES_PER_USER { @@ -264,15 +272,23 @@ pub async fn import_csv( Ok(Json(response)) } +/// Query parameters for `GET /api/v1/sources/export-csv`. +#[derive(Debug, Deserialize)] +pub struct CsvExportQuery { + pub theme_id: Option, +} + /// `GET /api/v1/sources/export-csv` /// -/// Returns all of the authenticated user's sources as a CSV file download. +/// Returns the authenticated user's sources as a CSV file download. +/// Optionally filters by `theme_id` query parameter. /// Sets the appropriate `Content-Type` and `Content-Disposition` headers. pub async fn export_csv( auth_user: AuthUser, State(state): State, + Query(params): Query, ) -> Result { - let sources = db::sources::list_for_user(&state.pool, auth_user.id, None).await?; + let sources = db::sources::list_for_user(&state.pool, auth_user.id, params.theme_id).await?; let csv_content = csv_service::generate_csv(&sources); Ok(( diff --git a/backend/src/handlers/themes.rs b/backend/src/handlers/themes.rs index 8e93157..5d05c60 100644 --- a/backend/src/handlers/themes.rs +++ b/backend/src/handlers/themes.rs @@ -75,6 +75,8 @@ pub async fn update( Path(id): Path, Json(body): Json, ) -> Result { + body.validate().map_err(AppError::Validation)?; + let categories = body .categories .as_ref() diff --git a/backend/src/models/theme.rs b/backend/src/models/theme.rs index 9544c55..46a0350 100644 --- a/backend/src/models/theme.rs +++ b/backend/src/models/theme.rs @@ -84,6 +84,50 @@ pub struct UpdateThemeRequest { pub summary_length: Option, } +impl UpdateThemeRequest { + pub fn validate(&self) -> Result<(), String> { + if let Some(ref name) = self.name { + if name.trim().is_empty() { + return Err("Theme name cannot be empty".into()); + } + if name.len() > 200 { + return Err("Theme name must be at most 200 characters".into()); + } + } + if let Some(ref theme) = self.theme { + if theme.trim().is_empty() { + return Err("Theme search topic cannot be empty".into()); + } + } + if let Some(ref categories) = self.categories { + if categories.len() > 20 { + return Err("At most 20 categories are allowed".into()); + } + for (i, cat) in categories.iter().enumerate() { + if cat.trim().is_empty() { + return Err(format!("Category at index {} cannot be empty", i)); + } + } + } + if let Some(max) = self.max_items_per_category { + if !(1..=50).contains(&max) { + return Err("max_items_per_category must be between 1 and 50".into()); + } + } + if let Some(days) = self.max_age_days { + if !(1..=365).contains(&days) { + return Err("max_age_days must be between 1 and 365".into()); + } + } + if let Some(sl) = self.summary_length { + if !(1..=3).contains(&sl) { + return Err("summary_length must be between 1 and 3".into()); + } + } + Ok(()) + } +} + /// Response shape for theme API. #[derive(Debug, Serialize)] pub struct ThemeResponse { diff --git a/frontend/src/components/ApiKeyManager.tsx b/frontend/src/components/ApiKeyManager.tsx index 48bb330..018e4a3 100644 --- a/frontend/src/components/ApiKeyManager.tsx +++ b/frontend/src/components/ApiKeyManager.tsx @@ -129,7 +129,7 @@ const ProviderKeyCard: Component = (props) => { }); } } catch (err) { - const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' }); + const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: t('settings.apiKeys.unknownError') }); addToast({ type: 'error', message, duration: 5000 }); } finally { setTesting(false); diff --git a/frontend/src/components/settings/SettingsBraveSearch.tsx b/frontend/src/components/settings/SettingsBraveSearch.tsx index ab70347..c78c873 100644 --- a/frontend/src/components/settings/SettingsBraveSearch.tsx +++ b/frontend/src/components/settings/SettingsBraveSearch.tsx @@ -55,7 +55,7 @@ const SettingsBraveSearch: Component = (props) => { addToast({ type: 'error', message: t('settings.apiKeys.testFailure', { message: result.message }), duration: 6000 }); } } catch (err) { - const msg = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' }); + const msg = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: t('settings.apiKeys.unknownError') }); addToast({ type: 'error', message: msg, duration: 5000 }); } finally { setBraveTesting(false); diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 1ee7f55..e07adc5 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -287,6 +287,7 @@ const fr = { 'settings.apiKeys.deleted': 'Cle API supprimee avec succes.', 'settings.apiKeys.deleteError': 'Erreur lors de la suppression de la cle API.', 'settings.apiKeys.loadError': 'Erreur lors du chargement des cles API.', + 'settings.apiKeys.unknownError': 'Erreur inconnue', 'settings.apiKeys.noWebSearch': 'Ce fournisseur ne supporte pas la recherche web native. Le scraping backend sera utilise.', diff --git a/frontend/src/pages/ArticleHistory.tsx b/frontend/src/pages/ArticleHistory.tsx index 00cd8b1..8f95793 100644 --- a/frontend/src/pages/ArticleHistory.tsx +++ b/frontend/src/pages/ArticleHistory.tsx @@ -18,17 +18,18 @@ const PAGE_SIZE = 50; const STATUS_OPTIONS = [ 'used', 'filtered_empty', + 'filtered_too_old', 'filtered_history', 'filtered_diversity', 'filtered_homepage', - 'filtered_duplicate', + 'filtered_not_article', 'filtered_cross_phase_dedup', ]; const SOURCE_TYPE_OPTIONS = [ 'personalized_source', 'web_search', - 'overflow', + 'brave_search', ]; function statusBadgeClass(status: string): string { @@ -137,7 +138,7 @@ const ArticleHistory: Component = () => { : 'bg-white text-red-600 border-red-300 hover:bg-red-50' }`} > - {confirming() ? 'Confirmer' : t('articleHistory.clearAll')} + {confirming() ? t('common.confirm') : t('articleHistory.clearAll')}