//! Sources handlers. //! //! - `GET /api/v1/sources` — list user's sources //! - `POST /api/v1/sources` — add a single source //! - `DELETE /api/v1/sources/:id` — delete a source (ownership check) //! - `POST /api/v1/sources/bulk` — bulk import from JSON array //! - `POST /api/v1/sources/import-csv` — import from CSV file upload //! - `GET /api/v1/sources/export-csv` — download sources as CSV use axum::extract::{Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; use serde::Deserialize; use uuid::Uuid; use crate::app_state::AppState; use crate::db; 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; /// Maximum number of sources a user can have. const MAX_SOURCES_PER_USER: i64 = 100; /// Query parameters for `GET /api/v1/sources`. #[derive(Debug, Deserialize)] pub struct SourceListQuery { pub theme_id: Option, } /// `GET /api/v1/sources` /// /// Returns all sources belonging to the authenticated user, /// ordered by creation date (newest first). /// Optionally filters by `theme_id` query parameter. pub async fn list( auth_user: AuthUser, State(state): State, Query(params): Query, ) -> Result { let sources = db::sources::list_for_user(&state.pool, auth_user.id, params.theme_id).await?; let response: Vec = sources.into_iter().map(SourceResponse::from).collect(); Ok(Json(response)) } /// `POST /api/v1/sources` /// /// Creates a single source for the authenticated user. /// Validates the title and URL, and checks the per-user source limit. pub async fn create( auth_user: AuthUser, State(state): State, Json(body): Json, ) -> Result { // Validate request fields body.validate().map_err(AppError::Validation)?; // Check source limit let count = db::sources::count_for_user(&state.pool, auth_user.id).await?; if count >= MAX_SOURCES_PER_USER { return Err(AppError::Validation(format!( "Maximum of {} sources per user reached", MAX_SOURCES_PER_USER ))); } let source = db::sources::create(&state.pool, auth_user.id, &body.title, &body.url, body.theme_id).await?; tracing::info!(user_id = %auth_user.id, source_id = %source.id, "Source created"); Ok((StatusCode::CREATED, Json(SourceResponse::from(source)))) } /// `DELETE /api/v1/sources/:id` /// /// Deletes a source by ID. Returns 404 (not 403) if the source doesn't exist /// or doesn't belong to the current user, to avoid leaking information about /// other users' sources. pub async fn delete( auth_user: AuthUser, State(state): State, Path(id): Path, ) -> Result { let deleted = db::sources::delete(&state.pool, id, auth_user.id).await?; if !deleted { return Err(AppError::NotFound("Source not found".into())); } tracing::info!(user_id = %auth_user.id, source_id = %id, "Source deleted"); Ok(StatusCode::NO_CONTENT) } /// `POST /api/v1/sources/bulk` /// /// Bulk-imports sources from a JSON array. Validates each entry, /// skips duplicates (same URL for the same user), and returns a summary. pub async fn bulk_import( auth_user: AuthUser, State(state): State, Json(body): Json, ) -> Result { if body.sources.is_empty() { return Err(AppError::Validation("No sources provided".into())); } let mut valid_sources: Vec<(String, String)> = Vec::new(); let mut errors: Vec = Vec::new(); for (i, source) in body.sources.iter().enumerate() { if let Err(msg) = source.validate() { errors.push(format!("Row {}: {}", i + 1, msg)); continue; } valid_sources.push((source.title.clone(), source.url.clone())); } let response = do_bulk_import( &state.pool, auth_user.id, &mut valid_sources, &mut errors, "Bulk import", ) .await?; Ok(Json(response)) } /// Shared logic for bulk and CSV imports: enforce per-user limit, /// insert into DB, and build the response summary. async fn do_bulk_import( pool: &sqlx::PgPool, user_id: uuid::Uuid, valid_sources: &mut Vec<(String, String)>, errors: &mut Vec, log_label: &str, ) -> Result { let current_count = db::sources::count_for_user(pool, user_id).await?; let remaining_capacity = (MAX_SOURCES_PER_USER - current_count).max(0) as usize; if valid_sources.len() > remaining_capacity { valid_sources.truncate(remaining_capacity); errors.push(format!( "Only {} sources could be imported (limit of {} reached)", remaining_capacity, MAX_SOURCES_PER_USER )); } let created = db::sources::bulk_create(pool, user_id, valid_sources, None).await?; let imported = created.len(); let skipped = valid_sources.len() - imported; tracing::info!( user_id = %user_id, imported = imported, skipped = skipped, errors = errors.len(), "{} completed", log_label ); Ok(BulkImportResponse { imported, skipped, errors: errors.clone(), }) } /// `POST /api/v1/sources/import-csv` /// /// Imports sources from a CSV file uploaded via multipart form data. /// Expects a single file field. Parses the CSV, validates each row, /// skips duplicates, and returns a summary. pub async fn import_csv( auth_user: AuthUser, State(state): State, mut multipart: Multipart, ) -> Result { // Extract the first file field from the multipart upload let field = multipart .next_field() .await .map_err(|e| AppError::BadRequest(format!("Failed to read multipart field: {}", e)))? .ok_or_else(|| AppError::BadRequest("No file field found in upload".into()))?; // Validate Content-Type if present (allow text/csv, text/plain, or missing) if let Some(content_type) = field.content_type() { let ct = content_type.to_string(); if !ct.starts_with("text/csv") && !ct.starts_with("text/plain") && !ct.starts_with("application/octet-stream") { return Err(AppError::BadRequest(format!( "Invalid file type: {}. Expected a CSV file (text/csv or text/plain).", ct ))); } } let content = field .text() .await .map_err(|e| AppError::BadRequest(format!("Failed to read file content: {}", e)))?; // Parse CSV content into (title, url) pairs let parsed = csv_service::parse_csv(&content)?; if parsed.is_empty() { return Err(AppError::Validation( "No valid rows found in CSV file".into(), )); } // Validate each row let mut valid_sources: Vec<(String, String)> = Vec::new(); let mut errors: Vec = Vec::new(); for (i, (title, url)) in parsed.iter().enumerate() { if let Err(msg) = crate::models::source::validate_title(title) { errors.push(format!("Row {}: {}", i + 1, msg)); continue; } if let Err(msg) = crate::models::source::validate_url(url) { errors.push(format!("Row {}: {}", i + 1, msg)); continue; } valid_sources.push((title.clone(), url.clone())); } let response = do_bulk_import(&state.pool, auth_user.id, &mut valid_sources, &mut errors, "CSV import") .await?; Ok(Json(response)) } /// `GET /api/v1/sources/export-csv` /// /// Returns all of the authenticated user's sources as a CSV file download. /// Sets the appropriate `Content-Type` and `Content-Disposition` headers. pub async fn export_csv( auth_user: AuthUser, State(state): State, ) -> Result { let sources = db::sources::list_for_user(&state.pool, auth_user.id, None).await?; let csv_content = csv_service::generate_csv(&sources); Ok(( StatusCode::OK, [ ( axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8", ), ( axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"sources.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) }