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