/** * Courses Agent — course-shaped products. * * A course is `products.type='course'` + `category_id='cat_18'`. StoreManager * owns storage, slug, cms-row seeding, and analytics triggers. This agent * owns the course-shaped layer on top: the **module → lesson** tree stored as * JSON on products.lessons, the total_lessons scalar, and student progress * reads from purchases. * * JSON shape on products.lessons (app-side canonical): * [ * { id: 'module-…', title: 'Module 1', order: 0, lessons: [ * { id: 'lesson-…', title: 'Hello', content: '

', order: 0 }, * … * ] }, * … * ] * * total_lessons is the SUM of lessons across every module — not the module * count. Order fields are 0-indexed and we rewrite them on every mutation so * they stay contiguous. * * ID convention matches the app (`module-`, `lesson-`). If caller * passes an id we keep it; otherwise we mint one. * * DB contract (businesskit-files/provision.ts): * - products.lessons TEXT (JSON — see shape above) * - products.total_lessons INTEGER * - products.course_access_type 'open' | … * - products.course_access_config TEXT (JSON) * - purchases.course_progress TEXT (JSON — lesson_id → state) * - purchases.course_completed INTEGER 0/1 * - purchases.course_completion_date INTEGER unix * * Triggers (product-triggers.ts): trg_product_insert / * trg_product_update_published / trg_product_delete all fire through * StoreManager. We never INSERT / DELETE products here. * * Timestamps on products are INTEGER unix seconds (now()). */ import { BaseAgent, db, now } from '../_base.ts' import { storeManager } from '../store/store.ts' export interface Lesson { id: string title: string content?: string video_url?: string duration_minutes?: number free_preview?: boolean order: number } export interface Module { id: string title: string order: number lessons: Lesson[] } export type LessonInput = Omit & { id?: string } export type ModuleInput = { id?: string title: string lessons?: LessonInput[] } export interface CourseInput { title: string description?: string excerpt?: string price_cents: number sale_price_cents?: number currency?: string thumbnail_url?: string hero_image_url?: string cover_video_url?: string tags?: string[] features?: string[] button_text?: string modules?: ModuleInput[] course_access_type?: string course_access_config?: Record collection_id?: string seo_title?: string seo_description?: string publish?: boolean } export class CourseCreator extends BaseAgent { readonly name = 'Courses' readonly title = 'Course Creator' // ── Create / lifecycle (delegates to StoreManager) ──────────────────────── async create(input: CourseInput) { await this.init() const modules = (input.modules ?? []).map((m, mi) => ({ id: m.id ?? moduleId(), title: m.title, order: mi, lessons: (m.lessons ?? []).map((l, li) => ({ ...l, id: l.id ?? lessonId(), order: li, })), })) as Module[] // StoreManager inserts the product row, seeds cat_18 cms row, and lets // trg_product_insert bump cms_analytics/profile_analytics. We pass the // module tree via `lessons` — StoreManager JSON-encodes it verbatim. const row = await storeManager.create({ ...input, type: 'course', lessons: modules as unknown[], }) // total_lessons is SUM across modules, not module count. StoreManager // doesn't know about the module shape, so we sync it here. const total = countLessons(modules) if (total) { await db.write({ sql: `UPDATE products SET total_lessons=?, updated_at=? WHERE id=? AND profile_id=?`, args: [total, now(), row.id as string, this.profileId], }) } await this.logMemory( `${input.publish ? 'Published' : 'Drafted'} course "${input.title}"`, { id: row.id, modules: modules.length, lessons: total }, ) return this.get(row.id as string) } async publish(id: string) { await this.assertCourse(id) return storeManager.publish(id) } async unpublish(id: string) { await this.assertCourse(id) return storeManager.unpublish(id) } async archive(id: string) { await this.assertCourse(id) await storeManager.archive(id) } // ── Reads ───────────────────────────────────────────────────────────────── async get(id: string) { const row = await storeManager.get(id) if (row.type !== 'course') throw new Error(`Not a course: ${id}`) return row } async list(opts: { published?: boolean; include_hidden?: boolean; limit?: number } = {}) { return storeManager.list({ ...opts, type: 'course' }) } async modules(courseId: string): Promise { const course = await this.get(courseId) return parseModules(course.lessons as string | null) } /** Flatten every lesson across every module — useful for progress views. */ async allLessons(courseId: string): Promise> { const modules = await this.modules(courseId) return modules.flatMap(m => m.lessons.map(l => ({ ...l, module_id: m.id }))) } // ── Module mutations ────────────────────────────────────────────────────── async addModule(courseId: string, mod: ModuleInput): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const next: Module = { id: mod.id ?? moduleId(), title: mod.title, order: modules.length, lessons: (mod.lessons ?? []).map((l, i) => ({ ...l, id: l.id ?? lessonId(), order: i, })), } const updated = [...modules, next] await this.writeModules(courseId, updated) await this.logMemory(`Added module "${mod.title}"`, { courseId, moduleId: next.id }) return updated } async updateModule( courseId: string, moduleId: string, patch: Partial>, ): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const idx = modules.findIndex(m => m.id === moduleId) if (idx === -1) throw new Error(`Module not found: ${moduleId}`) modules[idx] = { ...modules[idx], ...patch } await this.writeModules(courseId, modules) return modules } async removeModule(courseId: string, moduleId: string): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const next = modules .filter(m => m.id !== moduleId) .map((m, i) => ({ ...m, order: i })) if (next.length === modules.length) throw new Error(`Module not found: ${moduleId}`) await this.writeModules(courseId, next) await this.logMemory(`Removed module ${moduleId}`, { courseId }) return next } /** Reorder modules. Pass the new full order as a list of module ids. * Ids not present are dropped — pass the full list to preserve all. */ async reorderModules(courseId: string, orderedIds: string[]): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const byId = new Map(modules.map(m => [m.id, m])) const next: Module[] = [] for (let i = 0; i < orderedIds.length; i++) { const m = byId.get(orderedIds[i]) if (m) next.push({ ...m, order: i }) } await this.writeModules(courseId, next) return next } // ── Lesson mutations (scoped to a module) ───────────────────────────────── async addLesson( courseId: string, moduleId: string, lesson: LessonInput, ): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const m = modules.find(x => x.id === moduleId) if (!m) throw new Error(`Module not found: ${moduleId}`) const next: Lesson = { ...lesson, id: lesson.id ?? lessonId(), order: m.lessons.length, } m.lessons = [...m.lessons, next] await this.writeModules(courseId, modules) await this.logMemory(`Added lesson "${lesson.title}"`, { courseId, moduleId, lessonId: next.id }) return modules } async updateLesson( courseId: string, moduleId: string, lessonId: string, patch: Partial>, ): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const m = modules.find(x => x.id === moduleId) if (!m) throw new Error(`Module not found: ${moduleId}`) const idx = m.lessons.findIndex(l => l.id === lessonId) if (idx === -1) throw new Error(`Lesson not found: ${lessonId}`) m.lessons[idx] = { ...m.lessons[idx], ...patch } await this.writeModules(courseId, modules) return modules } async removeLesson( courseId: string, moduleId: string, lessonId: string, ): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const m = modules.find(x => x.id === moduleId) if (!m) throw new Error(`Module not found: ${moduleId}`) const before = m.lessons.length m.lessons = m.lessons .filter(l => l.id !== lessonId) .map((l, i) => ({ ...l, order: i })) if (m.lessons.length === before) throw new Error(`Lesson not found: ${lessonId}`) await this.writeModules(courseId, modules) await this.logMemory(`Removed lesson ${lessonId}`, { courseId, moduleId }) return modules } /** Reorder lessons inside a module. Ids not present are dropped. */ async reorderLessons( courseId: string, moduleId: string, orderedIds: string[], ): Promise { await this.assertCourse(courseId) const modules = await this.modules(courseId) const m = modules.find(x => x.id === moduleId) if (!m) throw new Error(`Module not found: ${moduleId}`) const byId = new Map(m.lessons.map(l => [l.id, l])) m.lessons = orderedIds .map((id, i) => { const l = byId.get(id) return l ? { ...l, order: i } : null }) .filter(Boolean) as Lesson[] await this.writeModules(courseId, modules) return modules } /** Move a lesson to a different module. Appends at end of the target * module and compacts order on both sides. */ async moveLesson( courseId: string, lessonId: string, fromModuleId: string, toModuleId: string, ): Promise { await this.assertCourse(courseId) if (fromModuleId === toModuleId) return this.modules(courseId) const modules = await this.modules(courseId) const from = modules.find(m => m.id === fromModuleId) const to = modules.find(m => m.id === toModuleId) if (!from) throw new Error(`Source module not found: ${fromModuleId}`) if (!to) throw new Error(`Target module not found: ${toModuleId}`) const idx = from.lessons.findIndex(l => l.id === lessonId) if (idx === -1) throw new Error(`Lesson not found in source module: ${lessonId}`) const [lesson] = from.lessons.splice(idx, 1) from.lessons = from.lessons.map((l, i) => ({ ...l, order: i })) to.lessons = [...to.lessons, { ...lesson, order: to.lessons.length }] await this.writeModules(courseId, modules) return modules } // ── Student progress (reads from purchases) ─────────────────────────────── async students(courseId: string, opts: { completed?: boolean; limit?: number } = {}) { await this.init() await this.assertCourse(courseId) let where = `WHERE product_id=? AND profile_id=? AND payment_status='completed' AND status='active'` const args: unknown[] = [courseId, this.profileId] if (opts.completed !== undefined) { where += ' AND course_completed=?' args.push(opts.completed ? 1 : 0) } const { rows } = await db.execute({ sql: `SELECT id, email, customer_name, course_progress, course_completed, course_completion_date, last_accessed_at, access_count, created_at FROM purchases ${where} ORDER BY created_at DESC LIMIT ?`, args: [...args, opts.limit ?? 100], }) return rows } async enrollmentStats(courseId: string) { await this.init() await this.assertCourse(courseId) const { rows: [r] } = await db.execute({ sql: `SELECT COUNT(*) AS enrolled, SUM(CASE WHEN course_completed=1 THEN 1 ELSE 0 END) AS completed, COALESCE(SUM(amount_cents), 0) AS revenue_cents FROM purchases WHERE product_id=? AND profile_id=? AND payment_status='completed' AND status='active'`, args: [courseId, this.profileId], }) const enrolled = Number(r?.enrolled ?? 0) const completed = Number(r?.completed ?? 0) return { enrolled, completed, completion_rate: enrolled ? completed / enrolled : 0, revenue_cents: Number(r?.revenue_cents ?? 0), } } // ── Private ─────────────────────────────────────────────────────────────── private async assertCourse(id: string): Promise { await this.init() const { rows: [r] } = await db.execute({ sql: `SELECT type FROM products WHERE id=? AND profile_id=? LIMIT 1`, args: [id, this.profileId], }) if (!r) throw new Error(`Course not found (or not yours): ${id}`) if (r.type !== 'course') throw new Error(`Not a course: ${id}`) } private async writeModules(courseId: string, modules: Module[]): Promise { await db.write({ sql: `UPDATE products SET lessons=?, total_lessons=?, updated_at=? WHERE id=? AND profile_id=?`, args: [ JSON.stringify(modules), countLessons(modules), now(), courseId, this.profileId, ], }) } } function countLessons(modules: Module[]): number { return modules.reduce((sum, m) => sum + (m.lessons?.length ?? 0), 0) } function parseModules(raw: string | null): Module[] { if (!raw) return [] try { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] // Normalise — older rows might use `position` instead of `order`, or // store a flat lesson array with no modules. Coerce to module shape. return parsed.map((entry, i) => { if (entry && typeof entry === 'object' && Array.isArray(entry.lessons)) { return { id: entry.id ?? moduleId(), title: entry.title ?? `Module ${i + 1}`, order: Number(entry.order ?? entry.position ?? i), lessons: entry.lessons.map((l: Record, li: number) => ({ ...l, order: Number(l.order ?? l.position ?? li), })) as Lesson[], } as Module } // Flat lesson — wrap as a single default module on first parse. return null }).filter(Boolean) as Module[] } catch { return [] } } const moduleId = () => `module-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const lessonId = () => `lesson-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` export const courseCreator = new CourseCreator()