From 848a25235e950903ea25d405d59f9e0aa5e2d694 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Fri, 27 Mar 2026 13:11:41 +0100 Subject: [PATCH] docs: add implementation plan for scheduled synthesis generation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-27-scheduled-generation.md | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-scheduled-generation.md diff --git a/docs/superpowers/plans/2026-03-27-scheduled-generation.md b/docs/superpowers/plans/2026-03-27-scheduled-generation.md new file mode 100644 index 0000000..495067b --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-scheduled-generation.md @@ -0,0 +1,438 @@ +# 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" +```