import "server-only"; import { getDb } from "./db"; import { svgToRevealSteps } from "./vectorize"; import type { Lesson } from "./curriculum"; /** * Data + lifecycle for AI-created lessons. A row is generated asynchronously (ComfyUI), vectorized * (potrace), and then rendered through the normal runners by building Lesson objects on the fly. */ // pending/generating = working; review = generated but awaiting admin approval (hidden from kids); // ready = approved/auto-approved and visible; failed/blocked = terminal. export type CreatedStatus = "pending" | "generating" | "needs_review" | "review" | "ready" | "failed" | "blocked"; export interface CreatedLessonRow { id: number; user_id: number | null; level: string; subject: string; slug: string; status: CreatedStatus; template_svg: string | null; image: string | null; prompt: string; moderation: string | null; emoji: string; error: string | null; promoted: number; created_at: string; updated_at: string; } function kebab(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } /** Insert a created-lesson row and return it (slug includes the row id for uniqueness). */ export function createCreatedLesson(opts: { userId: number; level: string; subject: string; prompt: string; moderation: string; status: CreatedStatus; emoji?: string; }): CreatedLessonRow { const db = getDb(); const info = db .prepare( `INSERT INTO created_lessons (user_id, level, subject, slug, status, prompt, moderation, emoji) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run(opts.userId, opts.level, opts.subject, `pending-${Date.now()}`, opts.status, opts.prompt, opts.moderation, opts.emoji || "🎨"); const id = Number(info.lastInsertRowid); const slug = `created-${id}-${kebab(opts.subject)}`; db.prepare("UPDATE created_lessons SET slug = ? WHERE id = ?").run(slug, id); return getCreatedById(id)!; } export function getCreatedById(id: number): CreatedLessonRow | undefined { return getDb().prepare("SELECT * FROM created_lessons WHERE id = ?").get(id) as CreatedLessonRow | undefined; } /** Look up by the base slug (e.g. 'created-42-zebra'), ignoring any '-trace'/'-color' phase suffix. */ export function getCreatedBySlug(baseSlug: string): CreatedLessonRow | undefined { return getDb().prepare("SELECT * FROM created_lessons WHERE slug = ?").get(baseSlug) as CreatedLessonRow | undefined; } export function listCreatedForUser(userId: number, level: string): CreatedLessonRow[] { return getDb() .prepare("SELECT * FROM created_lessons WHERE user_id = ? AND level = ? ORDER BY created_at DESC") .all(userId, level) as CreatedLessonRow[]; } /** Promoted (global) creations, visible to everyone on a level. */ export function listPromoted(level: string): CreatedLessonRow[] { return getDb() .prepare("SELECT * FROM created_lessons WHERE promoted = 1 AND status = 'ready' AND level = ? ORDER BY created_at DESC") .all(level) as CreatedLessonRow[]; } /** Admin queue: everything not yet live (working, awaiting review, or failed). */ export function listAdminQueue(): CreatedLessonRow[] { return getDb() .prepare("SELECT * FROM created_lessons WHERE status IN ('pending','generating','needs_review','review','failed') ORDER BY created_at DESC") .all() as CreatedLessonRow[]; } /** Admin queue: ready creations not yet promoted to the global curriculum. */ export function listReadyUnpromoted(): CreatedLessonRow[] { return getDb() .prepare("SELECT * FROM created_lessons WHERE status = 'ready' AND promoted = 0 ORDER BY created_at DESC") .all() as CreatedLessonRow[]; } export function updateCreatedStatus(id: number, status: CreatedStatus, error?: string) { getDb() .prepare("UPDATE created_lessons SET status = ?, error = ?, updated_at = datetime('now') WHERE id = ?") .run(status, error ?? null, id); } function setGenerated(id: number, templateSvg: string, image: string, status: CreatedStatus) { getDb() .prepare("UPDATE created_lessons SET status = ?, template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?") .run(status, templateSvg, image, id); } /** Approve a creation: make it live (ready) and mark it approved. */ export function approveCreated(id: number) { getDb() .prepare("UPDATE created_lessons SET status = 'ready', moderation = 'approved', updated_at = datetime('now') WHERE id = ?") .run(id); } export function promoteCreated(id: number) { getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id); } /** * Run generation for a row: ComfyUI -> vectorize -> ready (or failed). Fire-and-forget from the API * route; safe on a long-lived self-hosted node. Heavy deps are imported lazily so the data layer stays * light for normal page renders. */ export async function processCreation(id: number): Promise { const row = getCreatedById(id); if (!row) return; try { updateCreatedStatus(id, "generating"); const { generateLineArt } = await import("./comfyui"); const { pngToTemplateSvg } = await import("./vectorize"); const png = await generateLineArt(row.subject); const templateSvg = await pngToTemplateSvg(png); // Auto-approved (allowlist) or admin-approved → live; anything else is quarantined for review. const fresh = getCreatedById(id); const needsReview = fresh?.moderation === "review" || fresh?.moderation === "needs_review"; setGenerated(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`, needsReview ? "review" : "ready"); } catch (e) { updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e)); } } /** Build the playable lessons (Trace + Color) from a ready row. */ export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] { const base = 900000 + row.id * 10; const tpl = row.template_svg || ""; const lower = row.subject.toLowerCase(); const steps = svgToRevealSteps(tpl).map((els, i) => ({ n: i + 1, title: `Lines ${i + 1}`, instruction: "Trace the lines, one part at a time.", tip: "", lines: els, })); const common = { level: row.level, emoji: row.emoji, subjectKey: row.slug, subjectName: row.subject, subjectEmoji: row.emoji, order: 0, badgeName: `${row.subject} Artist`, }; const trace: Lesson = { ...common, sublevel: base + 1, slug: `${row.slug}-trace`, title: `${row.subject} · Trace`, subject: `${lower} trace`, intro: `Watch your ${lower} appear, then trace it!`, badgeKey: "", phase: "outline", baseSvg: "", steps, }; const color: Lesson = { ...common, sublevel: base + 2, slug: `${row.slug}-color`, title: `${row.subject} · Color`, subject: `color the ${lower}`, intro: `Bring your ${lower} to life! Color it in.`, badgeKey: "", phase: "color", baseSvg: tpl, steps: [], }; return [trace, color]; } /** Resolve a lesson-page slug (…-trace / …-color) to its created lesson + its phase siblings. */ export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index: number; row: CreatedLessonRow } | null { const m = slug.match(/^(.*)-(trace|color)$/); if (!m) return null; const row = getCreatedBySlug(m[1]); // Built once generated: 'ready' (live) or 'review' (admin preview before approval). if (!row || !row.template_svg || (row.status !== "ready" && row.status !== "review")) return null; const lessons = buildCreatedLessons(row); const index = lessons.findIndex((l) => l.slug === slug); if (index < 0) return null; return { lessons, index, row }; }