|
3 | 3 | * |
4 | 4 | * Bootstraps the scheduler sweep on server startup and sets up a periodic |
5 | 5 | * interval to process due generation and delivery work. |
| 6 | + * Auto-creates tables if they don't exist yet. |
6 | 7 | */ |
7 | 8 |
|
8 | 9 | import { createLogger } from "~/server/utils/logger"; |
| 10 | +import { sql } from "drizzle-orm"; |
| 11 | +import { db } from "~/server/db"; |
9 | 12 |
|
10 | 13 | const logger = createLogger("plugin:weekly-rhythms"); |
11 | 14 |
|
12 | 15 | const SWEEP_INTERVAL_MS = 60 * 1000; // 1 minute |
13 | 16 |
|
| 17 | +/** Ensure weekly-rhythms tables exist (safe for repeated calls) */ |
| 18 | +async function ensureTables(): Promise<boolean> { |
| 19 | + try { |
| 20 | + const result = await db.all<{ name: string }>( |
| 21 | + sql`SELECT name FROM sqlite_master WHERE type='table' AND name='weekly_rhythm_settings'`, |
| 22 | + ); |
| 23 | + if (result.length > 0) return true; |
| 24 | + |
| 25 | + // Tables missing — create them |
| 26 | + logger.info("Creating weekly-rhythms tables"); |
| 27 | + const ddl = [ |
| 28 | + sql`CREATE TABLE IF NOT EXISTS weekly_rhythm_settings ( |
| 29 | + id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, |
| 30 | + celebration_enabled INTEGER NOT NULL DEFAULT 0, encouragement_enabled INTEGER NOT NULL DEFAULT 0, |
| 31 | + celebration_tier TEXT NOT NULL DEFAULT 'stats_only', delivery_channels TEXT NOT NULL, |
| 32 | + generation_schedule TEXT NOT NULL, onboarding_completed_at TEXT, cloud_privacy_acknowledged_at TEXT, |
| 33 | + private_ai_unavailable_dismissed_at TEXT, email_unsubscribed_at TEXT, email_unsubscribe_source TEXT, |
| 34 | + consecutive_email_failures INTEGER NOT NULL DEFAULT 0, last_email_failure_at TEXT, |
| 35 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 36 | + )`, |
| 37 | + sql`CREATE TABLE IF NOT EXISTS weekly_stats_snapshots ( |
| 38 | + id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 39 | + kind TEXT NOT NULL, week_start_date TEXT NOT NULL, week_end_date TEXT NOT NULL, timezone TEXT NOT NULL, |
| 40 | + period_range TEXT NOT NULL, general_progress TEXT NOT NULL, rhythm_wins TEXT NOT NULL, |
| 41 | + encouragement_context TEXT, generated_at TEXT NOT NULL |
| 42 | + )`, |
| 43 | + sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_weekly_snapshots_user_kind_week ON weekly_stats_snapshots(user_id, kind, week_start_date)`, |
| 44 | + sql`CREATE INDEX IF NOT EXISTS idx_weekly_snapshots_kind_generated ON weekly_stats_snapshots(kind, generated_at)`, |
| 45 | + sql`CREATE TABLE IF NOT EXISTS weekly_messages ( |
| 46 | + id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| 47 | + snapshot_id TEXT NOT NULL REFERENCES weekly_stats_snapshots(id) ON DELETE CASCADE, |
| 48 | + kind TEXT NOT NULL, week_start_date TEXT NOT NULL, tier_requested TEXT NOT NULL, tier_applied TEXT NOT NULL, |
| 49 | + fallback_reason TEXT, status TEXT NOT NULL DEFAULT 'generated', title TEXT NOT NULL, |
| 50 | + summary_blocks TEXT NOT NULL, narrative_text TEXT, email_subject TEXT, email_html TEXT, email_text TEXT, |
| 51 | + in_app_visible_from TEXT NOT NULL, scheduled_delivery_at TEXT, delivered_at TEXT, dismissed_at TEXT, |
| 52 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 53 | + )`, |
| 54 | + sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_weekly_messages_user_kind_week ON weekly_messages(user_id, kind, week_start_date)`, |
| 55 | + sql`CREATE INDEX IF NOT EXISTS idx_weekly_messages_status_scheduled ON weekly_messages(status, scheduled_delivery_at)`, |
| 56 | + sql`CREATE TABLE IF NOT EXISTS weekly_delivery_attempts ( |
| 57 | + id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES weekly_messages(id) ON DELETE CASCADE, |
| 58 | + channel TEXT NOT NULL, status TEXT NOT NULL, attempt_number INTEGER NOT NULL, |
| 59 | + scheduled_for TEXT NOT NULL, attempted_at TEXT, retry_after TEXT, provider TEXT, |
| 60 | + provider_message_id TEXT, failure_code TEXT, failure_message TEXT, raw_response TEXT, |
| 61 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 62 | + )`, |
| 63 | + sql`CREATE INDEX IF NOT EXISTS idx_weekly_delivery_message ON weekly_delivery_attempts(message_id)`, |
| 64 | + sql`CREATE INDEX IF NOT EXISTS idx_weekly_delivery_retry ON weekly_delivery_attempts(status, retry_after)`, |
| 65 | + ]; |
| 66 | + for (const stmt of ddl) { |
| 67 | + await db.run(stmt); |
| 68 | + } |
| 69 | + logger.info("Weekly-rhythms tables created"); |
| 70 | + return true; |
| 71 | + } catch (err) { |
| 72 | + logger.error("Failed to ensure weekly-rhythms tables", err as Error); |
| 73 | + return false; |
| 74 | + } |
| 75 | +} |
| 76 | + |
14 | 77 | export default defineNitroPlugin((nitroApp) => { |
15 | 78 | logger.info("Weekly rhythms plugin initializing"); |
16 | 79 |
|
17 | 80 | let sweepTimer: ReturnType<typeof setInterval> | null = null; |
18 | 81 |
|
19 | 82 | // Delay first sweep to let the server fully start |
20 | 83 | const startupDelay = setTimeout(async () => { |
| 84 | + // Ensure tables exist before first sweep |
| 85 | + const tablesReady = await ensureTables(); |
| 86 | + if (!tablesReady) { |
| 87 | + logger.warn("Weekly rhythms tables not available, skipping scheduler"); |
| 88 | + return; |
| 89 | + } |
| 90 | + |
21 | 91 | try { |
22 | | - // Dynamic import to avoid circular dependency at plugin load time |
23 | 92 | const { runSchedulerSweep } = await import( |
24 | 93 | "~/server/services/weekly-rhythms/scheduler" |
25 | 94 | ); |
|
0 commit comments