# Scheduled Synthesis Generation — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Per-theme scheduled generation + email delivery. Background scheduler checks every 60s for due schedules, generates synthesis, and emails it to up to 3 addresses. **Architecture:** New `theme_schedules` table (1:1 with themes). Schedule CRUD via REST API. Internal `tokio::spawn` scheduler in `main.rs` (same pattern as session cleanup). Scheduler calls `run_generation_inner` directly, then `send_synthesis_email` for each recipient. **Tech Stack:** Rust (Axum, sqlx, tokio), SolidJS **Spec:** `docs/superpowers/specs/2026-03-27-scheduled-generation-design.md` --- ### Task 1: Migration + model + DB **Files:** - Create: `backend/migrations/20260327000030_create_theme_schedules.sql` - Create: `backend/src/models/schedule.rs` - Create: `backend/src/db/schedules.rs` - Modify: `backend/src/models/mod.rs`, `backend/src/db/mod.rs` - Modify: `CLAUDE.md` - [ ] **Step 1: Create migration** ```sql 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; ``` - [ ] **Step 2: Create `models/schedule.rs`** Structs: - `ThemeSchedule` — DB row struct (sqlx::FromRow + Serialize) - `UpsertScheduleRequest` — { enabled, days, time_utc, emails } with validation - `ScheduleResponse` — API response with parsed JSON arrays Validation for `UpsertScheduleRequest`: - `emails` max 3, each validated with basic email regex - `days` each must be one of `mon,tue,wed,thu,fri,sat,sun` - `time_utc` must match `HH:MM` (00-23:00-59) - [ ] **Step 3: Create `db/schedules.rs`** Functions: - `get_for_theme(pool, user_id, theme_id) -> Option` - `upsert(pool, user_id, theme_id, &UpsertScheduleRequest) -> ThemeSchedule` - `delete(pool, user_id, theme_id) -> bool` - `find_due_schedules(pool) -> Vec` — query for enabled schedules where: ```sql enabled = true AND days @> to_jsonb($1::text) -- today's day code AND time_utc <= $2 -- current HH:MM AND (last_run_at IS NULL OR last_run_at::date < CURRENT_DATE) ``` - `mark_run(pool, schedule_id)` — update `last_run_at = now()` - [ ] **Step 4: Register modules + update CLAUDE.md** `models/mod.rs`: add `pub mod schedule;` `db/mod.rs`: add `pub mod schedules;` `CLAUDE.md`: change to `## Database (30 migrations)` - [ ] **Step 5: Build and commit** ```bash cd backend && cargo build git add -A && git commit -m "feat: add theme_schedules table, model, and DB queries" ``` --- ### Task 2: Schedule CRUD handler + routes **Files:** - Create: `backend/src/handlers/schedules.rs` - Modify: `backend/src/handlers/mod.rs` - Modify: `backend/src/router.rs` - [ ] **Step 1: Create handler** Three handlers following existing patterns (read `handlers/themes.rs` for style): ```rust // GET /api/v1/themes/:id/schedule pub async fn get_schedule(auth_user, state, Path(theme_id)) -> schedule or null // PUT /api/v1/themes/:id/schedule pub async fn upsert_schedule(auth_user, state, Path(theme_id), Json(body)) -> schedule // Verify theme belongs to user before upserting // DELETE /api/v1/themes/:id/schedule pub async fn delete_schedule(auth_user, state, Path(theme_id)) -> 204 ``` - [ ] **Step 2: Register + routes** `handlers/mod.rs`: add `pub mod schedules;` In `router.rs`, add after theme routes: ```rust .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)) ``` - [ ] **Step 3: Build, test, commit** ```bash cd backend && cargo build && cargo test --lib git add -A && git commit -m "feat: add schedule CRUD endpoints" ``` --- ### Task 3: Background scheduler service **Files:** - Create: `backend/src/services/scheduler.rs` - Modify: `backend/src/services/mod.rs` - Modify: `backend/src/main.rs` - [ ] **Step 1: Create `services/scheduler.rs`** ```rust //! Background scheduler for automated synthesis generation. //! //! Checks every 60 seconds for due theme schedules and triggers //! generation + email delivery. use crate::app_state::AppState; use crate::db; use crate::services::{email, synthesis}; use std::sync::atomic::AtomicBool; use std::sync::Arc; use tokio::sync::watch; use uuid::Uuid; /// Check for due schedules and run them. pub async fn run_scheduled_jobs(state: &AppState) { let due = match db::schedules::find_due_schedules(&state.pool).await { Ok(schedules) => schedules, Err(e) => { tracing::warn!(error = %e, "Failed to query due schedules"); return; } }; if due.is_empty() { return; } tracing::info!(count = due.len(), "Found due scheduled jobs"); for schedule in due { // Skip if user has a manual generation in progress if state.job_store.has_active_job(schedule.user_id).is_some() { tracing::info!(user_id = %schedule.user_id, theme_id = %schedule.theme_id, "Skipping scheduled job — manual generation in progress"); continue; } tracing::info!( schedule_id = %schedule.id, theme_id = %schedule.theme_id, user_id = %schedule.user_id, "Running scheduled generation" ); // Run generation with a dummy progress channel let (tx, _rx) = watch::channel(synthesis::ProgressEvent::Progress { step: "init".into(), message: "Scheduled generation...".into(), percent: 0, }); let job_id = Uuid::new_v4(); let cancelled = AtomicBool::new(false); let result = synthesis::run_generation_inner( job_id, state, schedule.user_id, schedule.theme_id, &tx, None, // no provider override &cancelled, ) .await; match result { Ok(synthesis_id) => { tracing::info!(synthesis_id = %synthesis_id, "Scheduled generation completed"); // Send email to all recipients let emails: Vec = serde_json::from_value(schedule.emails.clone()).unwrap_or_default(); if !emails.is_empty() { // Load synthesis for email if let Ok(Some(synth)) = db::syntheses::get_by_id(&state.pool, synthesis_id).await { let sections: Vec = serde_json::from_value(synth.sections).unwrap_or_default(); let week = &synth.week; let date = synth.created_at.format("%d %B %Y").to_string(); for email_addr in &emails { match email::send_synthesis_email( &state.http_client, &state.config.resend_api_key, &state.config.email_from, email_addr, week, &date, §ions, ) .await { Ok(()) => tracing::info!(to = email_addr, "Scheduled email sent"), Err(e) => tracing::warn!(to = email_addr, error = %e, "Failed to send scheduled email"), } } } } // Mark as run db::schedules::mark_run(&state.pool, schedule.id).await.ok(); } Err(e) => { tracing::error!( schedule_id = %schedule.id, error = %e, "Scheduled generation failed" ); // Don't mark as run — will retry next interval } } } } ``` - [ ] **Step 2: Register module** `services/mod.rs`: add `pub mod scheduler;` - [ ] **Step 3: Spawn in main.rs** In `main.rs`, after the session cleanup spawn and before `let app = router::build_router(...)`, add: ```rust // Scheduled synthesis generation (check every 60 seconds) { let scheduler_state = state.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); loop { interval.tick().await; crate::services::scheduler::run_scheduled_jobs(&scheduler_state).await; } }); } ``` - [ ] **Step 4: Build, test, commit** ```bash cd backend && cargo build && cargo test --lib git add -A && git commit -m "feat: add background scheduler for automated synthesis generation" ``` --- ### Task 4: Frontend — schedule API + types + i18n **Files:** - Create: `frontend/src/api/schedules.ts` - Modify: `frontend/src/types.ts` - Modify: `frontend/src/i18n/fr.ts` - [ ] **Step 1: Create API client** Read existing API modules for the pattern, then create: ```typescript export interface ScheduleResponse { id: string; theme_id: string; enabled: boolean; days: string[]; time_utc: string; emails: string[]; last_run_at: string | null; } export interface UpsertScheduleRequest { enabled: boolean; days: string[]; time_utc: string; emails: string[]; } export const schedulesApi = { get: (themeId: string) => ..., upsert: (themeId: string, data: UpsertScheduleRequest) => ..., remove: (themeId: string) => ..., }; ``` - [ ] **Step 2: Add i18n labels** ```typescript 'schedule.title': 'Planification', 'schedule.enabled': 'Planification activee', 'schedule.days': 'Jours', 'schedule.time': 'Heure (UTC)', 'schedule.emails': 'Destinataires', 'schedule.addEmail': 'Ajouter une adresse', 'schedule.maxEmails': '3 adresses maximum', 'schedule.saved': 'Planification enregistree', 'schedule.deleted': 'Planification supprimee', 'schedule.dayMon': 'L', 'schedule.dayTue': 'M', 'schedule.dayWed': 'M', 'schedule.dayThu': 'J', 'schedule.dayFri': 'V', 'schedule.daySat': 'S', 'schedule.daySun': 'D', ``` - [ ] **Step 3: TypeScript check + commit** ```bash cd frontend && npx tsc --noEmit git add -A && git commit -m "feat: add schedule API client and i18n labels" ``` --- ### Task 5: Frontend — Schedule component in ThemeManager **Files:** - Create: `frontend/src/components/settings/SettingsSchedule.tsx` - Modify: `frontend/src/pages/ThemeManager.tsx` - [ ] **Step 1: Create SettingsSchedule component** Props: `{ themeId: string }` The component: 1. Loads the schedule on mount via `schedulesApi.get(themeId)` 2. Shows enable toggle, day buttons, time input, email list 3. Auto-saves on any change via `schedulesApi.upsert(themeId, data)` Day buttons: 7 buttons in a row, each toggles a day code in the `days` array. Use the day letter labels from i18n. Email list: up to 3 `` with add/remove. Validate on blur. - [ ] **Step 2: Add to ThemeManager** Import `SettingsSchedule` and render it inside the selected theme view, after the sources section: ```tsx {/* ... existing content settings + sources ... */} ``` - [ ] **Step 3: TypeScript check + commit** ```bash cd frontend && npx tsc --noEmit git add -A && git commit -m "feat: add schedule UI to theme management page" ``` --- ### Task 6: Integration tests + E2E **Files:** - Create: `backend/tests/api_schedules_test.rs` - Modify: `e2e/tests/themes.spec.ts` - [ ] **Step 1: Create integration tests** ```rust // Tests: // - get_schedule_returns_null_when_none // - upsert_schedule_creates_schedule // - upsert_schedule_updates_existing // - upsert_schedule_invalid_emails_returns_422 // - upsert_schedule_invalid_days_returns_422 // - upsert_schedule_too_many_emails_returns_422 // - delete_schedule_returns_204 // - schedule_requires_auth // - schedule_requires_theme_ownership ``` Each test creates a user + theme, then operates on the schedule endpoint. - [ ] **Step 2: Add E2E test** In `e2e/tests/themes.spec.ts`, add after the existing CRUD test: ```typescript // Create schedule via API const schedResp = await page.evaluate(async (tid: string) => { const resp = await fetch(`/api/v1/themes/${tid}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', body: JSON.stringify({ enabled: true, days: ['mon', 'fri'], time_utc: '09:00', emails: ['test@example.com'], }), }); return { status: resp.status, data: await resp.json() }; }, themeId); expect(schedResp.status).toBe(200); expect(schedResp.data.days).toEqual(['mon', 'fri']); ``` - [ ] **Step 3: Build + commit** ```bash cd backend && cargo build --tests git add -A && git commit -m "test: add schedule CRUD integration tests and E2E" ```