docs: add implementation plan for scheduled synthesis generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
96f138bd61
commit
848a25235e
@ -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<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,
|
||||||
|
§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 `<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"
|
||||||
|
```
|
||||||
Loading…
Reference in New Issue