13 KiB
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
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 validationScheduleResponse— API response with parsed JSON arrays
Validation for UpsertScheduleRequest:
-
emailsmax 3, each validated with basic email regex -
dayseach must be one ofmon,tue,wed,thu,fri,sat,sun -
time_utcmust matchHH:MM(00-23:00-59) -
Step 3: Create
db/schedules.rs
Functions:
-
get_for_theme(pool, user_id, theme_id) -> Option<ThemeSchedule> -
upsert(pool, user_id, theme_id, &UpsertScheduleRequest) -> ThemeSchedule -
delete(pool, user_id, theme_id) -> bool -
find_due_schedules(pool) -> Vec<ThemeSchedule>— query for enabled schedules where: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)— updatelast_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
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):
// 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:
.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
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
//! 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<String> = 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<crate::models::synthesis::NewsSection> =
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:
// 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
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:
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
'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
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:
- Loads the schedule on mount via
schedulesApi.get(themeId) - Shows enable toggle, day buttons, time input, email list
- 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 <input type="email"> 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:
<Show when={selectedThemeId()}>
{/* ... existing content settings + sources ... */}
<SettingsSchedule themeId={selectedThemeId()!} />
</Show>
- Step 3: TypeScript check + commit
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
// 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:
// 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
cd backend && cargo build --tests
git add -A && git commit -m "test: add schedule CRUD integration tests and E2E"