feat: add preferred sources — prioritized during synthesis generation

Users can mark sources as preferred via star buttons on the theme page.
Preferred sources are processed first in the pipeline (ordered before
non-preferred in waves, shuffled separately then merged).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 48b5e77e7e
commit e43a4d2180

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

@ -0,0 +1 @@
ALTER TABLE sources ADD COLUMN is_preferred BOOLEAN NOT NULL DEFAULT false;

@ -16,7 +16,7 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, theme_id: Option<Uuid>)
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<Uuid>)
} 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<i64, AppErro
Ok(row.0)
}
/// Bulk-update the `is_preferred` flag for a user's sources.
///
/// First resets all of the user's sources to non-preferred, then sets the
/// specified source IDs to preferred. This ensures a clean toggle behavior.
pub async fn update_preferred(
pool: &PgPool,
user_id: Uuid,
preferred_ids: &[Uuid],
) -> 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(())
}

@ -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<AppState>,
Json(body): Json<UpdatePreferredRequest>,
) -> Result<impl IntoResponse, AppError> {
db::sources::update_preferred(&state.pool, auth_user.id, &body.source_ids).await?;
Ok(StatusCode::OK)
}

@ -15,6 +15,7 @@ pub struct Source {
pub title: String,
pub url: String,
pub theme_id: Option<Uuid>,
pub is_preferred: bool,
pub created_at: DateTime<Utc>,
}
@ -25,6 +26,7 @@ pub struct SourceResponse {
pub title: String,
pub url: String,
pub theme_id: Option<Uuid>,
pub is_preferred: bool,
pub created_at: DateTime<Utc>,
}
@ -35,6 +37,7 @@ impl From<Source> 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<Uuid>,
}
/// Request body for `POST /api/v1/sources/bulk`.
#[derive(Debug, Deserialize)]
pub struct BulkImportRequest {

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

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

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

@ -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<Vec<&crate::models::source::Source>> = rotated_sources
let source_chunks: Vec<Vec<&crate::models::source::Source>> = 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<String> = 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");

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

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

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

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

@ -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<void> =>
api.put<void>('/sources/preferred', { source_ids: sourceIds }),
};

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

@ -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 = () => {
<For each={sources()}>
{(source) => (
<li>
<div class="px-4 py-4 flex items-center sm:px-6">
<div class={`px-4 py-4 flex items-center sm:px-6 ${source.is_preferred ? 'bg-amber-50' : ''}`}>
<div class="flex-shrink-0 mr-3">
<button
type="button"
onClick={() => handleTogglePreferred(source.id)}
class={`p-1 rounded transition-colors ${
source.is_preferred
? 'text-amber-500 hover:text-amber-600'
: 'text-gray-300 hover:text-amber-400'
}`}
title={t('sources.preferred')}
>
<Star class={`h-5 w-5 ${source.is_preferred ? 'fill-current' : ''}`} />
</button>
</div>
<div class="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div class="truncate">
<div class="flex text-sm">
@ -857,6 +894,17 @@ const ThemeManager: Component = () => {
</Show>
</ul>
</div>
<Show when={preferredCount() > 0}>
<div class="mt-3 flex items-center gap-2 text-sm text-amber-700">
<Star class="h-4 w-4 fill-current text-amber-500" />
<span>
{t('sources.preferredCount').replace('{count}', String(preferredCount()))}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{t('sources.preferredHelp')}
</p>
</Show>
</Show>
</div>

@ -76,6 +76,7 @@ export interface Source {
user_id: string;
title: string;
url: string;
is_preferred: boolean;
created_at: string;
}

Loading…
Cancel
Save