diff --git a/CLAUDE.md b/CLAUDE.md index 3720c7f..7f80257 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 (29 migrations) +## Database (30 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/20260327000030_create_theme_schedules.sql b/backend/migrations/20260327000030_create_theme_schedules.sql new file mode 100644 index 0000000..6298f07 --- /dev/null +++ b/backend/migrations/20260327000030_create_theme_schedules.sql @@ -0,0 +1,13 @@ +CREATE TABLE theme_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id UUID NOT NULL UNIQUE REFERENCES themes(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + days JSONB NOT NULL DEFAULT '[]', + time_utc TEXT NOT NULL DEFAULT '08:00', + emails JSONB NOT NULL DEFAULT '[]', + last_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_theme_schedules_enabled ON theme_schedules(enabled) WHERE enabled = true; diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index b4f3399..d25e9f4 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -5,6 +5,7 @@ pub mod audit; pub mod magic_links; pub mod providers; pub mod rate_limits; +pub mod schedules; pub mod sessions; pub mod settings; pub mod sources; diff --git a/backend/src/db/schedules.rs b/backend/src/db/schedules.rs new file mode 100644 index 0000000..0e35ef3 --- /dev/null +++ b/backend/src/db/schedules.rs @@ -0,0 +1,108 @@ +//! Database queries for the `theme_schedules` table. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::errors::AppError; +use crate::models::schedule::{ThemeSchedule, UpsertScheduleRequest}; + +/// Fetch the schedule for a given theme, scoped to the owning user. +pub async fn get_for_theme( + pool: &PgPool, + user_id: Uuid, + theme_id: Uuid, +) -> Result, AppError> { + let row = sqlx::query_as::<_, ThemeSchedule>( + "SELECT * FROM theme_schedules WHERE theme_id = $1 AND user_id = $2", + ) + .bind(theme_id) + .bind(user_id) + .fetch_optional(pool) + .await?; + Ok(row) +} + +/// Insert or update the schedule for a theme. +pub async fn upsert( + pool: &PgPool, + user_id: Uuid, + theme_id: Uuid, + req: &UpsertScheduleRequest, +) -> Result { + let days = serde_json::to_value(&req.days) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to serialize days: {}", e)))?; + let emails = serde_json::to_value(&req.emails) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to serialize emails: {}", e)))?; + + let row = sqlx::query_as::<_, ThemeSchedule>( + r#" + INSERT INTO theme_schedules (user_id, theme_id, enabled, days, time_utc, emails) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (theme_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + days = EXCLUDED.days, + time_utc = EXCLUDED.time_utc, + emails = EXCLUDED.emails, + updated_at = now() + RETURNING * + "#, + ) + .bind(user_id) + .bind(theme_id) + .bind(req.enabled) + .bind(days) + .bind(&req.time_utc) + .bind(emails) + .fetch_one(pool) + .await?; + Ok(row) +} + +/// Delete the schedule for a theme. Returns true if a row was deleted. +pub async fn delete(pool: &PgPool, user_id: Uuid, theme_id: Uuid) -> Result { + let result = sqlx::query( + "DELETE FROM theme_schedules WHERE theme_id = $1 AND user_id = $2", + ) + .bind(theme_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Find all enabled schedules that are due to run. +/// +/// A schedule is due when: +/// - It is enabled +/// - Its `days` array contains `day_code` +/// - Its `time_utc` is at or before `current_time` +/// - It has not already run today +pub async fn find_due_schedules( + pool: &PgPool, + day_code: &str, + current_time: &str, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, ThemeSchedule>( + r#" + SELECT * FROM theme_schedules + WHERE enabled = true + AND days @> to_jsonb($1::text) + AND time_utc <= $2 + AND (last_run_at IS NULL OR last_run_at::date < CURRENT_DATE) + "#, + ) + .bind(day_code) + .bind(current_time) + .fetch_all(pool) + .await?; + Ok(rows) +} + +/// Update `last_run_at` to now for the given schedule. +pub async fn mark_run(pool: &PgPool, schedule_id: Uuid) -> Result<(), AppError> { + sqlx::query("UPDATE theme_schedules SET last_run_at = now() WHERE id = $1") + .bind(schedule_id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 9abb378..ce24531 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod config; pub mod generation; pub mod health; pub mod llm_logs; +pub mod schedules; pub mod settings; pub mod sources; pub mod syntheses; diff --git a/backend/src/handlers/schedules.rs b/backend/src/handlers/schedules.rs new file mode 100644 index 0000000..86d838f --- /dev/null +++ b/backend/src/handlers/schedules.rs @@ -0,0 +1,101 @@ +//! Schedule handlers. +//! +//! - `GET /api/v1/themes/:id/schedule` — get schedule for a theme +//! - `PUT /api/v1/themes/:id/schedule` — create or update schedule for a theme +//! - `DELETE /api/v1/themes/:id/schedule` — delete schedule for a theme + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use uuid::Uuid; + +use crate::app_state::AppState; +use crate::db; +use crate::errors::AppError; +use crate::middleware::auth::AuthUser; +use crate::models::schedule::{ScheduleResponse, UpsertScheduleRequest}; + +/// `GET /api/v1/themes/:id/schedule` +/// +/// Returns the schedule for a theme, or null if none exists. +/// Returns 404 if the theme doesn't belong to the current user. +pub async fn get_schedule( + auth_user: AuthUser, + State(state): State, + Path(theme_id): Path, +) -> Result { + // Verify theme ownership + let theme = db::themes::get_by_id(&state.pool, auth_user.id, theme_id).await?; + if theme.is_none() { + return Err(AppError::NotFound("Theme not found".into())); + } + + let schedule = db::schedules::get_for_theme(&state.pool, auth_user.id, theme_id).await?; + match schedule { + Some(s) => { + let response = ScheduleResponse::try_from(s)?; + Ok((StatusCode::OK, Json(serde_json::json!(response))).into_response()) + } + None => Ok((StatusCode::OK, Json(serde_json::Value::Null)).into_response()), + } +} + +/// `PUT /api/v1/themes/:id/schedule` +/// +/// Creates or updates the schedule for a theme. +/// Returns 404 if the theme doesn't belong to the current user. +pub async fn upsert_schedule( + auth_user: AuthUser, + State(state): State, + Path(theme_id): Path, + Json(body): Json, +) -> Result { + body.validate().map_err(AppError::Validation)?; + + // Verify theme ownership + let theme = db::themes::get_by_id(&state.pool, auth_user.id, theme_id).await?; + if theme.is_none() { + return Err(AppError::NotFound("Theme not found".into())); + } + + let schedule = db::schedules::upsert(&state.pool, auth_user.id, theme_id, &body).await?; + + tracing::info!( + user_id = %auth_user.id, + theme_id = %theme_id, + "Schedule upserted" + ); + + let response = ScheduleResponse::try_from(schedule)?; + Ok((StatusCode::OK, Json(response)).into_response()) +} + +/// `DELETE /api/v1/themes/:id/schedule` +/// +/// Deletes the schedule for a theme. +/// Returns 404 if the theme doesn't belong to the current user or has no schedule. +pub async fn delete_schedule( + auth_user: AuthUser, + State(state): State, + Path(theme_id): Path, +) -> Result { + // Verify theme ownership + let theme = db::themes::get_by_id(&state.pool, auth_user.id, theme_id).await?; + if theme.is_none() { + return Err(AppError::NotFound("Theme not found".into())); + } + + let deleted = db::schedules::delete(&state.pool, auth_user.id, theme_id).await?; + if !deleted { + return Err(AppError::NotFound("Schedule not found".into())); + } + + tracing::info!( + user_id = %auth_user.id, + theme_id = %theme_id, + "Schedule deleted" + ); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 883c058..4496d0f 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod audit; pub mod magic_link; pub mod provider; pub mod rate_limit; +pub mod schedule; pub mod session; pub mod settings; pub mod source; diff --git a/backend/src/models/schedule.rs b/backend/src/models/schedule.rs new file mode 100644 index 0000000..dfa5757 --- /dev/null +++ b/backend/src/models/schedule.rs @@ -0,0 +1,105 @@ +//! Schedule model — per-theme scheduled generation settings. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::errors::AppError; + +/// A theme schedule DB row. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct ThemeSchedule { + pub id: Uuid, + pub theme_id: Uuid, + pub user_id: Uuid, + pub enabled: bool, + pub days: serde_json::Value, + pub time_utc: String, + pub emails: serde_json::Value, + pub last_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Valid day codes. +const VALID_DAYS: &[&str] = &["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + +/// Request body for creating or updating a schedule. +#[derive(Debug, Deserialize)] +pub struct UpsertScheduleRequest { + pub enabled: bool, + pub days: Vec, + pub time_utc: String, + pub emails: Vec, +} + +impl UpsertScheduleRequest { + pub fn validate(&self) -> Result<(), String> { + for day in &self.days { + if !VALID_DAYS.contains(&day.as_str()) { + return Err(format!( + "Invalid day '{}': must be one of mon/tue/wed/thu/fri/sat/sun", + day + )); + } + } + + // Validate HH:MM format (00-23 : 00-59) + let parts: Vec<&str> = self.time_utc.splitn(2, ':').collect(); + let valid_time = parts.len() == 2 + && parts[0].len() == 2 + && parts[1].len() == 2 + && parts[0].parse::().map(|h| h <= 23).unwrap_or(false) + && parts[1].parse::().map(|m| m <= 59).unwrap_or(false); + if !valid_time { + return Err("time_utc must be in HH:MM format (e.g. 08:00)".into()); + } + + if self.emails.len() > 3 { + return Err("At most 3 emails are allowed".into()); + } + for email in &self.emails { + if !email.contains('@') { + return Err(format!("Invalid email address: '{}'", email)); + } + } + + Ok(()) + } +} + +/// Response shape for schedule API. +#[derive(Debug, Serialize)] +pub struct ScheduleResponse { + pub id: Uuid, + pub theme_id: Uuid, + pub enabled: bool, + pub days: Vec, + pub time_utc: String, + pub emails: Vec, + pub last_run_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl TryFrom for ScheduleResponse { + type Error = AppError; + + fn try_from(s: ThemeSchedule) -> Result { + let days: Vec = serde_json::from_value(s.days) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to parse days: {}", e)))?; + let emails: Vec = serde_json::from_value(s.emails) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to parse emails: {}", e)))?; + Ok(Self { + id: s.id, + theme_id: s.theme_id, + enabled: s.enabled, + days, + time_utc: s.time_utc, + emails, + last_run_at: s.last_run_at, + created_at: s.created_at, + updated_at: s.updated_at, + }) + } +} diff --git a/backend/src/router.rs b/backend/src/router.rs index c746c5d..a239730 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -42,6 +42,9 @@ pub fn build_router(state: AppState, config: &AppConfig) -> Router { .route("/themes", post(handlers::themes::create)) .route("/themes/{id}", put(handlers::themes::update)) .route("/themes/{id}", delete(handlers::themes::delete)) + .route("/themes/{id}/schedule", get(handlers::schedules::get_schedule)) + .route("/themes/{id}/schedule", put(handlers::schedules::upsert_schedule)) + .route("/themes/{id}/schedule", delete(handlers::schedules::delete_schedule)) // Sources routes (authenticated) .route("/sources", get(handlers::sources::list)) .route("/sources", post(handlers::sources::create))