feat: add theme schedules — model, DB, CRUD handler, routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent 848a25235e
commit 384649b2b6

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

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

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

@ -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<Option<ThemeSchedule>, 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<ThemeSchedule, AppError> {
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<bool, AppError> {
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<Vec<ThemeSchedule>, 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(())
}

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

@ -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<AppState>,
Path(theme_id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
// 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<AppState>,
Path(theme_id): Path<Uuid>,
Json(body): Json<UpsertScheduleRequest>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
Path(theme_id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
// 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)
}

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

@ -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<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// 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<String>,
pub time_utc: String,
pub emails: Vec<String>,
}
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::<u8>().map(|h| h <= 23).unwrap_or(false)
&& parts[1].parse::<u8>().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<String>,
pub time_utc: String,
pub emails: Vec<String>,
pub last_run_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl TryFrom<ThemeSchedule> for ScheduleResponse {
type Error = AppError;
fn try_from(s: ThemeSchedule) -> Result<Self, Self::Error> {
let days: Vec<String> = serde_json::from_value(s.days)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to parse days: {}", e)))?;
let emails: Vec<String> = 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,
})
}
}

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

Loading…
Cancel
Save