feat: add theme schedules — model, DB, CRUD handler, routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
848a25235e
commit
384649b2b6
@ -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;
|
||||
@ -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(())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue