You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ai_synth/docs/superpowers/plans/2026-03-27-scheduled-genera...

439 lines
13 KiB
Markdown

# 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<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:
```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<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,
&sections,
)
.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 `<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:
```tsx
<Show when={selectedThemeId()}>
{/* ... existing content settings + sources ... */}
<SettingsSchedule themeId={selectedThemeId()!} />
</Show>
```
- [ ] **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"
```