diff --git a/CLAUDE.md b/CLAUDE.md index f5016c2..3720c7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ cd frontend && npx tsc --noEmit - `GET /api/v1/admin/users` — user list - `PUT /api/v1/admin/users/:id/role` — role management -## Database (28 migrations) +## Database (29 migrations) Tables: `users`, `sessions`, `magic_link_tokens`, `user_settings`, `sources`, `syntheses`, `admin_providers`, `admin_rate_limits`, `user_api_keys`, `audit_log` ## Environment Variables diff --git a/backend/migrations/20260327000029_add_source_preferred.sql b/backend/migrations/20260327000029_add_source_preferred.sql new file mode 100644 index 0000000..f78881e --- /dev/null +++ b/backend/migrations/20260327000029_add_source_preferred.sql @@ -0,0 +1 @@ +ALTER TABLE sources ADD COLUMN is_preferred BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/db/sources.rs b/backend/src/db/sources.rs index ae234a9..b75e902 100644 --- a/backend/src/db/sources.rs +++ b/backend/src/db/sources.rs @@ -16,7 +16,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option) let sources = if let Some(tid) = theme_id { sqlx::query_as::<_, Source>( r#" - SELECT id, user_id, title, url, theme_id, created_at + SELECT id, user_id, title, url, theme_id, is_preferred, created_at FROM sources WHERE user_id = $1 AND theme_id = $2 ORDER BY created_at DESC @@ -29,7 +29,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option) } else { sqlx::query_as::<_, Source>( r#" - SELECT id, user_id, title, url, theme_id, created_at + SELECT id, user_id, title, url, theme_id, is_preferred, created_at FROM sources WHERE user_id = $1 ORDER BY created_at DESC @@ -58,7 +58,7 @@ pub async fn create( r#" INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4) - RETURNING id, user_id, title, url, theme_id, created_at + RETURNING id, user_id, title, url, theme_id, is_preferred, created_at "#, ) .bind(user_id) @@ -108,7 +108,7 @@ pub async fn bulk_create( INSERT INTO sources (user_id, title, url, theme_id) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, url) DO NOTHING - RETURNING id, user_id, title, url, theme_id, created_at + RETURNING id, user_id, title, url, theme_id, is_preferred, created_at "#, ) .bind(user_id) @@ -141,3 +141,28 @@ pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result Result<(), AppError> { + sqlx::query("UPDATE sources SET is_preferred = false WHERE user_id = $1") + .bind(user_id) + .execute(pool) + .await?; + + if !preferred_ids.is_empty() { + sqlx::query("UPDATE sources SET is_preferred = true WHERE user_id = $1 AND id = ANY($2)") + .bind(user_id) + .bind(preferred_ids) + .execute(pool) + .await?; + } + + Ok(()) +} diff --git a/backend/src/handlers/sources.rs b/backend/src/handlers/sources.rs index f0d85ea..749c1ce 100644 --- a/backend/src/handlers/sources.rs +++ b/backend/src/handlers/sources.rs @@ -20,6 +20,7 @@ use crate::errors::AppError; use crate::middleware::auth::AuthUser; use crate::models::source::{ BulkImportRequest, BulkImportResponse, CreateSourceRequest, SourceResponse, + UpdatePreferredRequest, }; use crate::services::csv as csv_service; @@ -263,3 +264,16 @@ pub async fn export_csv( csv_content, )) } + +/// `PUT /api/v1/sources/preferred` +/// +/// Bulk-update which sources are marked as preferred. +/// Accepts a list of source IDs; all other sources are set to non-preferred. +pub async fn update_preferred( + auth_user: AuthUser, + State(state): State, + Json(body): Json, +) -> Result { + db::sources::update_preferred(&state.pool, auth_user.id, &body.source_ids).await?; + Ok(StatusCode::OK) +} diff --git a/backend/src/models/source.rs b/backend/src/models/source.rs index 9ff2e78..36d6021 100644 --- a/backend/src/models/source.rs +++ b/backend/src/models/source.rs @@ -15,6 +15,7 @@ pub struct Source { pub title: String, pub url: String, pub theme_id: Option, + pub is_preferred: bool, pub created_at: DateTime, } @@ -25,6 +26,7 @@ pub struct SourceResponse { pub title: String, pub url: String, pub theme_id: Option, + pub is_preferred: bool, pub created_at: DateTime, } @@ -35,6 +37,7 @@ impl From for SourceResponse { title: s.title, url: s.url, theme_id: s.theme_id, + is_preferred: s.is_preferred, created_at: s.created_at, } } @@ -61,6 +64,12 @@ impl CreateSourceRequest { } } +/// Request body for `PUT /api/v1/sources/preferred`. +#[derive(Debug, Deserialize)] +pub struct UpdatePreferredRequest { + pub source_ids: Vec, +} + /// Request body for `POST /api/v1/sources/bulk`. #[derive(Debug, Deserialize)] pub struct BulkImportRequest { diff --git a/backend/src/router.rs b/backend/src/router.rs index ab22e4a..c746c5d 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -45,6 +45,7 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { // Sources routes (authenticated) .route("/sources", get(handlers::sources::list)) .route("/sources", post(handlers::sources::create)) + .route("/sources/preferred", put(handlers::sources::update_preferred)) .route("/sources/{id}", delete(handlers::sources::delete)) .route("/sources/bulk", post(handlers::sources::bulk_import)) .route("/sources/import-csv", post(handlers::sources::import_csv)) diff --git a/backend/src/services/csv.rs b/backend/src/services/csv.rs index 5bb1a17..68ed8d9 100644 --- a/backend/src/services/csv.rs +++ b/backend/src/services/csv.rs @@ -260,6 +260,7 @@ mod tests { title: "My Blog".into(), url: "https://blog.example.com".into(), theme_id: None, + is_preferred: false, created_at: Utc::now(), }, Source { @@ -268,6 +269,7 @@ mod tests { title: "News".into(), url: "https://news.example.com".into(), theme_id: None, + is_preferred: false, created_at: Utc::now(), }, ]; @@ -287,6 +289,7 @@ mod tests { title: "Blog, with commas".into(), url: "https://example.com".into(), theme_id: None, + is_preferred: false, created_at: Utc::now(), }]; @@ -310,6 +313,7 @@ mod tests { title: "Simple Blog".into(), url: "https://blog.example.com".into(), theme_id: None, + is_preferred: false, created_at: Utc::now(), }, Source { @@ -318,6 +322,7 @@ mod tests { title: "News, Quotes \"here\"".into(), url: "https://news.example.com".into(), theme_id: None, + is_preferred: false, created_at: Utc::now(), }, ]; diff --git a/backend/src/services/prompts.rs b/backend/src/services/prompts.rs index a591aa6..45189df 100644 --- a/backend/src/services/prompts.rs +++ b/backend/src/services/prompts.rs @@ -242,16 +242,18 @@ mod tests { user_id: Uuid::nil(), title: "TechCrunch".into(), url: "https://techcrunch.com".into(), - created_at: Utc::now(), theme_id: None, + is_preferred: false, + 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(), theme_id: None, + is_preferred: false, + created_at: Utc::now(), }, ]; diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index 94da595..f771d4a 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -316,11 +316,15 @@ pub async fn run_generation_inner( let last_source = db::article_history::get_last_source_url(&state.pool, user_id).await.unwrap_or(None); let rotated_sources = rotate_sources(sources.clone(), last_source.as_deref()); + // Preferred sources first, preserving rotation order within each group + let preferred: Vec<_> = rotated_sources.iter().filter(|s| s.is_preferred).cloned().collect(); + let non_preferred: Vec<_> = rotated_sources.iter().filter(|s| !s.is_preferred).cloned().collect(); + let ordered_sources = [preferred, non_preferred].concat(); let max_links = settings.max_links_per_source.max(1) as usize; let window_size = settings.source_extraction_window.max(1) as usize; // Process sources in waves of `window_size` - let source_chunks: Vec> = rotated_sources + let source_chunks: Vec> = ordered_sources .chunks(window_size) .map(|chunk| chunk.iter().collect()) .collect(); @@ -398,9 +402,28 @@ pub async fn run_generation_inner( } } - // 1c. Shuffle this wave's candidates + // 1c. Preferred-first shuffle: preferred source URLs first, then others use rand::seq::SliceRandom; - wave_urls.shuffle(&mut rand::thread_rng()); + { + let preferred_source_urls: std::collections::HashSet = wave_sources.iter() + .filter(|s| s.is_preferred) + .map(|s| s.url.clone()) + .collect(); + + let mut preferred_urls: Vec<_> = wave_urls.iter() + .filter(|(_, source_url)| preferred_source_urls.contains(source_url)) + .cloned() + .collect(); + let mut other_urls: Vec<_> = wave_urls.iter() + .filter(|(_, source_url)| !preferred_source_urls.contains(source_url)) + .cloned() + .collect(); + + preferred_urls.shuffle(&mut rand::thread_rng()); + other_urls.shuffle(&mut rand::thread_rng()); + + wave_urls = [preferred_urls, other_urls].concat(); + } // Track url -> source for (url, source_url) in &wave_urls { @@ -1860,8 +1883,8 @@ mod tests { #[test] fn rotate_sources_no_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), None); assert_eq!(result.len(), 2); @@ -1871,9 +1894,9 @@ mod tests { #[test] fn rotate_sources_with_last_url() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, created_at: chrono::Utc::now() }, - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "B".into(), url: "https://b.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "C".into(), url: "https://c.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources, Some("https://a.com")); assert_eq!(result[0].url, "https://b.com"); @@ -1884,7 +1907,7 @@ mod tests { #[test] fn rotate_sources_last_url_not_found() { let sources = vec![ - crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, created_at: chrono::Utc::now() }, + crate::models::source::Source { id: Uuid::new_v4(), user_id: Uuid::new_v4(), title: "A".into(), url: "https://a.com".into(), theme_id: None, is_preferred: false, created_at: chrono::Utc::now() }, ]; let result = rotate_sources(sources.clone(), Some("https://notfound.com")); assert_eq!(result[0].url, "https://a.com"); diff --git a/backend/tests/api_sources_preferred_test.rs b/backend/tests/api_sources_preferred_test.rs new file mode 100644 index 0000000..80aa4a3 --- /dev/null +++ b/backend/tests/api_sources_preferred_test.rs @@ -0,0 +1,184 @@ +//! Integration tests for the `PUT /api/v1/sources/preferred` endpoint. +//! +//! Tests: +//! - Setting preferred sources +//! - Clearing all preferred sources +//! - Authentication requirement +//! +//! 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() +} + +#[tokio::test] +async fn update_preferred_sets_sources() { + 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("pref-set@example.com") + .await; + + // Create a theme first + let theme_body = serde_json::json!({ + "name": "Pref Theme", + "theme": "Test", + "categories": ["Cat"] + }); + let (theme_status, theme_resp) = app + .post_with_session("/api/v1/themes", &theme_body, &session) + .await; + assert_eq!(theme_status, StatusCode::CREATED); + let theme_id = theme_resp["id"].as_str().unwrap(); + + // Create 3 sources + let mut source_ids = Vec::new(); + for i in 1..=3 { + let body = serde_json::json!({ + "title": format!("Source {}", i), + "url": format!("https://source{}.example.com", i), + "theme_id": theme_id + }); + let (status, resp) = app + .post_with_session("/api/v1/sources", &body, &session) + .await; + assert_eq!(status, StatusCode::CREATED); + source_ids.push(resp["id"].as_str().unwrap().to_string()); + } + + // PUT /sources/preferred with [id1, id3] + let pref_body = serde_json::json!({ + "source_ids": [source_ids[0], source_ids[2]] + }); + let (pref_status, _) = app + .put_with_session("/api/v1/sources/preferred", &pref_body, &session) + .await; + assert_eq!(pref_status, StatusCode::OK); + + // GET /sources → verify preferred flags + let (list_status, list_body) = app + .get_with_session( + &format!("/api/v1/sources?theme_id={}", theme_id), + &session, + ) + .await; + assert_eq!(list_status, StatusCode::OK); + + let sources = list_body.as_array().expect("Should be an array"); + assert_eq!(sources.len(), 3); + + for source in sources { + let id = source["id"].as_str().unwrap(); + let is_preferred = source["is_preferred"].as_bool().unwrap(); + if id == source_ids[0] || id == source_ids[2] { + assert!(is_preferred, "Source {} should be preferred", id); + } else { + assert!(!is_preferred, "Source {} should NOT be preferred", id); + } + } +} + +#[tokio::test] +async fn update_preferred_clears_all_when_empty() { + 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("pref-clear@example.com") + .await; + + // Create a theme + let theme_body = serde_json::json!({ + "name": "Pref Clear Theme", + "theme": "Test", + "categories": ["Cat"] + }); + let (_, theme_resp) = app + .post_with_session("/api/v1/themes", &theme_body, &session) + .await; + let theme_id = theme_resp["id"].as_str().unwrap(); + + // Create 2 sources + let mut source_ids = Vec::new(); + for i in 1..=2 { + let body = serde_json::json!({ + "title": format!("Source {}", i), + "url": format!("https://clear-source{}.example.com", i), + "theme_id": theme_id + }); + let (_, resp) = app + .post_with_session("/api/v1/sources", &body, &session) + .await; + source_ids.push(resp["id"].as_str().unwrap().to_string()); + } + + // Set some as preferred + let pref_body = serde_json::json!({ + "source_ids": [source_ids[0]] + }); + let (pref_status, _) = app + .put_with_session("/api/v1/sources/preferred", &pref_body, &session) + .await; + assert_eq!(pref_status, StatusCode::OK); + + // Clear all preferred + let clear_body = serde_json::json!({ + "source_ids": [] + }); + let (clear_status, _) = app + .put_with_session("/api/v1/sources/preferred", &clear_body, &session) + .await; + assert_eq!(clear_status, StatusCode::OK); + + // GET /sources → all is_preferred=false + let (list_status, list_body) = app + .get_with_session( + &format!("/api/v1/sources?theme_id={}", theme_id), + &session, + ) + .await; + assert_eq!(list_status, StatusCode::OK); + + let sources = list_body.as_array().expect("Should be an array"); + for source in sources { + assert_eq!( + source["is_preferred"].as_bool().unwrap(), + false, + "All sources should be non-preferred after clearing" + ); + } +} + +#[tokio::test] +async fn update_preferred_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!({ + "source_ids": [] + }); + let (status, resp) = app + .put_with_session("/api/v1/sources/preferred", &body, "invalid-session-token") + .await; + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "PUT /sources/preferred without auth should return 401" + ); + assert_eq!(resp["error"], "unauthorized"); +} diff --git a/e2e/tests/sources.spec.ts b/e2e/tests/sources.spec.ts index d7859ba..0bc529e 100644 --- a/e2e/tests/sources.spec.ts +++ b/e2e/tests/sources.spec.ts @@ -118,7 +118,36 @@ test.describe('Sources management', () => { expect(listResp2.data.length).toBe(1); expect(listResp2.data[0].title).toBe('News Site'); - // Step 8: Cleanup - delete theme (cascades sources) + // Step 8: Mark a source as preferred via API + const sourceId2 = addResp2.data.id; + const prefResp = await page.evaluate(async (ids: string[]) => { + const resp = await fetch('/api/v1/sources/preferred', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + credentials: 'same-origin', + body: JSON.stringify({ source_ids: ids }), + }); + return { status: resp.status }; + }, [sourceId2]); + expect(prefResp.status).toBe(200); + + // Step 9: Verify source is now preferred + const listResp3 = await page.evaluate(async (tid: string) => { + const resp = await fetch(`/api/v1/sources?theme_id=${tid}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }); + return { status: resp.status, data: await resp.json() }; + }, themeId); + expect(listResp3.status).toBe(200); + const preferred = listResp3.data.filter((s: any) => s.is_preferred); + expect(preferred.length).toBe(1); + expect(preferred[0].id).toBe(sourceId2); + + // Step 10: Cleanup - delete theme (cascades sources) await page.evaluate(async (tid: string) => { await fetch(`/api/v1/themes/${tid}`, { method: 'DELETE', diff --git a/frontend/src/__tests__/fixtures.ts b/frontend/src/__tests__/fixtures.ts index 56de0ee..d69c0f8 100644 --- a/frontend/src/__tests__/fixtures.ts +++ b/frontend/src/__tests__/fixtures.ts @@ -50,7 +50,7 @@ export const MOCK_SYNTHESIS_DETAIL: Synthesis = { // ---- Sources ---- export const MOCK_SOURCE: Source = { - id: 'src-1', user_id: 'u1', title: 'Test Blog', url: 'https://test.example.com/blog', created_at: '2026-03-21T10:00:00Z', + id: 'src-1', user_id: 'u1', title: 'Test Blog', url: 'https://test.example.com/blog', is_preferred: false, created_at: '2026-03-21T10:00:00Z', }; export const MOCK_SOURCES: Source[] = [ MOCK_SOURCE, diff --git a/frontend/src/__tests__/pages/sources.test.tsx b/frontend/src/__tests__/pages/sources.test.tsx index 17cdbac..a76711e 100644 --- a/frontend/src/__tests__/pages/sources.test.tsx +++ b/frontend/src/__tests__/pages/sources.test.tsx @@ -121,6 +121,7 @@ describe('Sources Page', () => { user_id: 'u1', title: 'New Blog', url: 'https://newblog.com', + is_preferred: false, created_at: '2026-03-20T10:00:00Z', }; mockedCreate.mockResolvedValue(newSource); diff --git a/frontend/src/api/sources.ts b/frontend/src/api/sources.ts index d2c3118..c8e45d7 100644 --- a/frontend/src/api/sources.ts +++ b/frontend/src/api/sources.ts @@ -36,4 +36,8 @@ export const sourcesApi = { const response = await fetchFile(`/sources/export-csv${themeId ? `?theme_id=${themeId}` : ''}`); await triggerDownload(response, 'sources.csv'); }, + + /** PUT /sources/preferred -- bulk-update which sources are preferred. */ + updatePreferred: (sourceIds: string[]): Promise => + api.put('/sources/preferred', { source_ids: sourceIds }), }; diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 1087a3e..e912d13 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -239,6 +239,9 @@ const fr = { 'sources.titleRequired': 'Le titre est requis.', 'sources.urlRequired': "L'URL est requise.", 'sources.urlInvalid': "L'URL n'est pas valide.", + 'sources.preferred': 'Prioritaire', + 'sources.preferredCount': '{count} source(s) prioritaire(s)', + 'sources.preferredHelp': 'Les sources prioritaires sont traitees en premier lors de la generation.', // Themes 'themes.title': 'Personnaliser les syntheses', diff --git a/frontend/src/pages/ThemeManager.tsx b/frontend/src/pages/ThemeManager.tsx index a4a75dd..bdacd10 100644 --- a/frontend/src/pages/ThemeManager.tsx +++ b/frontend/src/pages/ThemeManager.tsx @@ -14,6 +14,7 @@ import { Download, Upload, Save, + Star, } from 'lucide-solid'; import Button from '~/components/ui/Button'; import { themesApi } from '~/api/themes'; @@ -314,6 +315,28 @@ const ThemeManager: Component = () => { } }; + // ---- Toggle preferred source ---- + const handleTogglePreferred = async (sourceId: string) => { + const current = sources(); + const toggled = current.map((s) => + s.id === sourceId ? { ...s, is_preferred: !s.is_preferred } : s, + ); + const newPreferredIds = toggled.filter((s) => s.is_preferred).map((s) => s.id); + + // Optimistically update local state + setSources(toggled); + + try { + await sourcesApi.updatePreferred(newPreferredIds); + } catch (err) { + // Revert on error + setSources(current); + console.error('Failed to update preferred sources:', err); + } + }; + + const preferredCount = (): number => sources().filter((s) => s.is_preferred).length; + // ---- CSV Export ---- const handleExportCsv = async () => { setCsvError(null); @@ -803,7 +826,21 @@ const ThemeManager: Component = () => { {(source) => (
  • -
    +
    +
    + +
    @@ -857,6 +894,17 @@ const ThemeManager: Component = () => {
    + 0}> +
    + + + {t('sources.preferredCount').replace('{count}', String(preferredCount()))} + +
    +

    + {t('sources.preferredHelp')} +

    +
    diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3942764..090d10c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -76,6 +76,7 @@ export interface Source { user_id: string; title: string; url: string; + is_preferred: boolean; created_at: string; }