From 7680a73d88c3d72193909ffd02ae2040057bc3ed Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 10:37:30 -0500 Subject: [PATCH] Add Phase 8: lesson plan templates with deep-copy instantiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tables: lesson_plan_template, lesson_plan_template_section, lesson_plan_template_item - skill_level enum: beginner, intermediate, advanced, all_levels - Templates are reusable curriculum definitions independent of any member/enrollment - POST /lesson-plan-templates/:id/create-plan deep-copies the template into a member plan - Instantiation uses template name as default plan title, accepts custom title override - Instantiation deactivates any existing active plan on the enrollment (one-active rule) - Plan items are independent copies — renaming the template does not affect existing plans - 11 new integration tests --- packages/backend/api-tests/suites/lessons.ts | 207 +++++++++++++++++ .../migrations/0036_lesson_plan_templates.sql | 35 +++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/lessons.ts | 46 ++++ packages/backend/src/routes/v1/lessons.ts | 63 ++++- .../backend/src/services/lesson.service.ts | 215 +++++++++++++++++- packages/shared/src/schemas/index.ts | 7 + packages/shared/src/schemas/lessons.schema.ts | 45 ++++ 8 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/db/migrations/0036_lesson_plan_templates.sql diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index 220135e..372c604 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -1779,4 +1779,211 @@ suite('Lessons', { tags: ['lessons'] }, (t) => { }) t.assert.status(res, 404) }) + + // ─── Lesson Plan Templates ─── + + t.test('creates a template with sections and items', { tags: ['templates', 'create'] }, async () => { + const res = await t.api.post('/v1/lesson-plan-templates', { + name: 'Beginner Piano', + instrument: 'Piano', + skillLevel: 'beginner', + sections: [ + { + title: 'Technique', + sortOrder: 0, + items: [ + { title: 'Scales (C major)', sortOrder: 0 }, + { title: 'Hand position', sortOrder: 1 }, + ], + }, + { + title: 'Repertoire', + sortOrder: 1, + items: [{ title: 'Twinkle Twinkle', sortOrder: 0 }], + }, + ], + }) + t.assert.status(res, 201) + t.assert.equal(res.data.name, 'Beginner Piano') + t.assert.equal(res.data.instrument, 'Piano') + t.assert.equal(res.data.skillLevel, 'beginner') + t.assert.equal(res.data.sections.length, 2) + t.assert.equal(res.data.sections[0].items.length, 2) + t.assert.equal(res.data.sections[1].items.length, 1) + t.assert.ok(res.data.id) + }) + + t.test('creates a minimal template with no sections', { tags: ['templates', 'create'] }, async () => { + const res = await t.api.post('/v1/lesson-plan-templates', { + name: 'Empty Template', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.skillLevel, 'all_levels') + t.assert.equal(res.data.sections.length, 0) + }) + + t.test('gets a template by id', { tags: ['templates', 'read'] }, async () => { + const created = await t.api.post('/v1/lesson-plan-templates', { + name: 'Get By ID Template', + sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'Item A', sortOrder: 0 }] }], + }) + const res = await t.api.get(`/v1/lesson-plan-templates/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.name, 'Get By ID Template') + t.assert.equal(res.data.sections[0].items[0].title, 'Item A') + }) + + t.test('returns 404 for missing template', { tags: ['templates', 'read'] }, async () => { + const res = await t.api.get('/v1/lesson-plan-templates/a0000000-0000-0000-0000-999999999999') + t.assert.status(res, 404) + }) + + t.test('lists templates with pagination and filtering', { tags: ['templates', 'read', 'pagination'] }, async () => { + await t.api.post('/v1/lesson-plan-templates', { name: 'Guitar Beginner', instrument: 'Guitar', skillLevel: 'beginner' }) + await t.api.post('/v1/lesson-plan-templates', { name: 'Guitar Advanced', instrument: 'Guitar', skillLevel: 'advanced' }) + await t.api.post('/v1/lesson-plan-templates', { name: 'Violin Beginner', instrument: 'Violin', skillLevel: 'beginner' }) + + const res = await t.api.get('/v1/lesson-plan-templates', { instrument: 'Guitar', limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.every((t: any) => t.instrument === 'Guitar')) + t.assert.ok(res.data.data.length >= 2) + }) + + t.test('updates a template', { tags: ['templates', 'update'] }, async () => { + const created = await t.api.post('/v1/lesson-plan-templates', { name: 'Before Update Template' }) + const res = await t.api.patch(`/v1/lesson-plan-templates/${created.data.id}`, { + name: 'After Update Template', + skillLevel: 'intermediate', + }) + t.assert.status(res, 200) + t.assert.equal(res.data.name, 'After Update Template') + t.assert.equal(res.data.skillLevel, 'intermediate') + }) + + t.test('soft-deletes a template', { tags: ['templates', 'delete'] }, async () => { + const created = await t.api.post('/v1/lesson-plan-templates', { name: 'Delete Template' }) + const res = await t.api.del(`/v1/lesson-plan-templates/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.isActive, false) + + const listRes = await t.api.get('/v1/lesson-plan-templates', { q: 'Delete Template', limit: 100 }) + const found = listRes.data.data.find((t: any) => t.id === created.data.id) + t.assert.falsy(found) + }) + + t.test('instantiates a template into a member lesson plan', { tags: ['templates', 'instantiate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Template Inst Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Template Inst Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Template Inst Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Template', lastName: 'Student' }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '08:00', + }) + const enrollment = await t.api.post('/v1/enrollments', { + memberId: member.data.id, accountId: acct.data.id, + scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01', + }) + + const template = await t.api.post('/v1/lesson-plan-templates', { + name: 'Piano Basics', + sections: [ + { title: 'Technique', sortOrder: 0, items: [{ title: 'Finger exercises', sortOrder: 0 }] }, + { title: 'Songs', sortOrder: 1, items: [{ title: 'Ode to Joy', sortOrder: 0 }, { title: 'Minuet', sortOrder: 1 }] }, + ], + }) + + const res = await t.api.post(`/v1/lesson-plan-templates/${template.data.id}/create-plan`, { + memberId: member.data.id, + enrollmentId: enrollment.data.id, + }) + t.assert.status(res, 201) + t.assert.equal(res.data.title, 'Piano Basics') // uses template name by default + t.assert.equal(res.data.sections.length, 2) + t.assert.equal(res.data.sections[0].items.length, 1) + t.assert.equal(res.data.sections[1].items.length, 2) + t.assert.equal(res.data.isActive, true) + // Items are independent copies, not template IDs + t.assert.notEqual(res.data.sections[0].items[0].id, template.data.sections[0].items[0].id) + }) + + t.test('instantiate uses custom title when provided', { tags: ['templates', 'instantiate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Custom Title Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Custom Title Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Custom Title Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Custom', lastName: 'Title' }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 5, startTime: '07:00', + }) + const enrollment = await t.api.post('/v1/enrollments', { + memberId: member.data.id, accountId: acct.data.id, + scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01', + }) + const template = await t.api.post('/v1/lesson-plan-templates', { name: 'Generic Template' }) + + const res = await t.api.post(`/v1/lesson-plan-templates/${template.data.id}/create-plan`, { + memberId: member.data.id, + enrollmentId: enrollment.data.id, + title: 'Custom Plan Title', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.title, 'Custom Plan Title') + }) + + t.test('instantiate deactivates previous active plan on enrollment', { tags: ['templates', 'instantiate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Deactivate Plan Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Deactivate Plan Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Deactivate Plan Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Deact', lastName: 'Plan' }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 6, startTime: '06:00', + }) + const enrollment = await t.api.post('/v1/enrollments', { + memberId: member.data.id, accountId: acct.data.id, + scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01', + }) + + // Create an existing active plan + const existing = await t.api.post('/v1/lesson-plans', { + memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Old Plan', sections: [], + }) + t.assert.equal(existing.data.isActive, true) + + const template = await t.api.post('/v1/lesson-plan-templates', { name: 'New Template' }) + const res = await t.api.post(`/v1/lesson-plan-templates/${template.data.id}/create-plan`, { + memberId: member.data.id, + enrollmentId: enrollment.data.id, + }) + t.assert.status(res, 201) + t.assert.equal(res.data.isActive, true) + + // Old plan should be inactive now + const oldPlanRes = await t.api.get(`/v1/lesson-plans/${existing.data.id}`) + t.assert.equal(oldPlanRes.data.isActive, false) + }) + + t.test('template changes do not affect already-instantiated plans', { tags: ['templates', 'instantiate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Isolation Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Isolation Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Isolation Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Iso', lastName: 'Student' }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '07:30', + }) + const enrollment = await t.api.post('/v1/enrollments', { + memberId: member.data.id, accountId: acct.data.id, + scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01', + }) + const template = await t.api.post('/v1/lesson-plan-templates', { name: 'Isolation Template' }) + + const plan = await t.api.post(`/v1/lesson-plan-templates/${template.data.id}/create-plan`, { + memberId: member.data.id, + enrollmentId: enrollment.data.id, + }) + + // Rename the template — plan title should remain unchanged + await t.api.patch(`/v1/lesson-plan-templates/${template.data.id}`, { name: 'Renamed Template' }) + + const planRes = await t.api.get(`/v1/lesson-plans/${plan.data.id}`) + t.assert.equal(planRes.data.title, 'Isolation Template') // original name preserved + }) }) diff --git a/packages/backend/src/db/migrations/0036_lesson_plan_templates.sql b/packages/backend/src/db/migrations/0036_lesson_plan_templates.sql new file mode 100644 index 0000000..820589a --- /dev/null +++ b/packages/backend/src/db/migrations/0036_lesson_plan_templates.sql @@ -0,0 +1,35 @@ +-- Phase 8: Lesson plan templates — reusable curriculum definitions + +CREATE TYPE "skill_level" AS ENUM ('beginner', 'intermediate', 'advanced', 'all_levels'); + +CREATE TABLE "lesson_plan_template" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" varchar(255) NOT NULL, + "description" text, + "instrument" varchar(100), + "skill_level" skill_level NOT NULL DEFAULT 'all_levels', + "created_by" uuid REFERENCES "user"("id"), + "is_active" boolean NOT NULL DEFAULT true, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE "lesson_plan_template_section" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "template_id" uuid NOT NULL REFERENCES "lesson_plan_template"("id"), + "title" varchar(255) NOT NULL, + "description" text, + "sort_order" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE "lesson_plan_template_item" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "section_id" uuid NOT NULL REFERENCES "lesson_plan_template_section"("id"), + "title" varchar(255) NOT NULL, + "description" text, + "grading_scale_id" uuid REFERENCES "grading_scale"("id"), + "target_grade_value" varchar(50), + "sort_order" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index f316f40..e78e524 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1774950000000, "tag": "0035_grade_history", "breakpoints": true + }, + { + "idx": 36, + "version": "7", + "when": 1774960000000, + "tag": "0036_lesson_plan_templates", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/lessons.ts b/packages/backend/src/db/schema/lessons.ts index 8335373..0c432f1 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -218,6 +218,46 @@ export const lessonPlanItems = pgTable('lesson_plan_item', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) +// --- Lesson Plan Templates --- + +export const skillLevelEnum = pgEnum('skill_level', ['beginner', 'intermediate', 'advanced', 'all_levels']) + +export const lessonPlanTemplates = pgTable('lesson_plan_template', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 255 }).notNull(), + description: text('description'), + instrument: varchar('instrument', { length: 100 }), + skillLevel: skillLevelEnum('skill_level').notNull().default('all_levels'), + createdBy: uuid('created_by').references(() => users.id), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const lessonPlanTemplateSections = pgTable('lesson_plan_template_section', { + id: uuid('id').primaryKey().defaultRandom(), + templateId: uuid('template_id') + .notNull() + .references(() => lessonPlanTemplates.id), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + sortOrder: integer('sort_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const lessonPlanTemplateItems = pgTable('lesson_plan_template_item', { + id: uuid('id').primaryKey().defaultRandom(), + sectionId: uuid('section_id') + .notNull() + .references(() => lessonPlanTemplateSections.id), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + gradingScaleId: uuid('grading_scale_id').references(() => gradingScales.id), + targetGradeValue: varchar('target_grade_value', { length: 50 }), + sortOrder: integer('sort_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + export const lessonPlanItemGradeHistory = pgTable('lesson_plan_item_grade_history', { id: uuid('id').primaryKey().defaultRandom(), lessonPlanItemId: uuid('lesson_plan_item_id') @@ -268,6 +308,12 @@ export type LessonPlanSection = typeof lessonPlanSections.$inferSelect export type LessonPlanSectionInsert = typeof lessonPlanSections.$inferInsert export type LessonPlanItem = typeof lessonPlanItems.$inferSelect export type LessonPlanItemInsert = typeof lessonPlanItems.$inferInsert +export type LessonPlanTemplate = typeof lessonPlanTemplates.$inferSelect +export type LessonPlanTemplateInsert = typeof lessonPlanTemplates.$inferInsert +export type LessonPlanTemplateSection = typeof lessonPlanTemplateSections.$inferSelect +export type LessonPlanTemplateSectionInsert = typeof lessonPlanTemplateSections.$inferInsert +export type LessonPlanTemplateItem = typeof lessonPlanTemplateItems.$inferSelect +export type LessonPlanTemplateItemInsert = typeof lessonPlanTemplateItems.$inferInsert export type LessonPlanItemGradeHistory = typeof lessonPlanItemGradeHistory.$inferSelect export type LessonPlanItemGradeHistoryInsert = typeof lessonPlanItemGradeHistory.$inferInsert export type LessonSessionPlanItem = typeof lessonSessionPlanItems.$inferSelect diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index 0f73552..9f7e683 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -20,10 +20,13 @@ import { LessonPlanItemUpdateSchema, GradeCreateSchema, SessionPlanItemsSchema, + LessonPlanTemplateCreateSchema, + LessonPlanTemplateUpdateSchema, + TemplateInstantiateSchema, InstructorBlockedDateCreateSchema, StoreClosureCreateSchema, } from '@lunarfront/shared/schemas' -import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService, GradeHistoryService, SessionPlanItemService, InstructorBlockedDateService, StoreClosureService } from '../../services/lesson.service.js' +import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService, LessonPlanTemplateService, GradeHistoryService, SessionPlanItemService, InstructorBlockedDateService, StoreClosureService } from '../../services/lesson.service.js' export const lessonRoutes: FastifyPluginAsync = async (app) => { // --- Instructors --- @@ -385,6 +388,64 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => { return reply.send(item) }) + // --- Lesson Plan Templates --- + + app.post('/lesson-plan-templates', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const parsed = LessonPlanTemplateCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const template = await LessonPlanTemplateService.create(app.db, parsed.data, request.user.id) + return reply.status(201).send(template) + }) + + app.get('/lesson-plan-templates', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const filters = { + instrument: query.instrument, + skillLevel: query.skillLevel, + } + const result = await LessonPlanTemplateService.list(app.db, params, filters) + return reply.send(result) + }) + + app.get('/lesson-plan-templates/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const template = await LessonPlanTemplateService.getById(app.db, id) + if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) + return reply.send(template) + }) + + app.patch('/lesson-plan-templates/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = LessonPlanTemplateUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const template = await LessonPlanTemplateService.update(app.db, id, parsed.data) + if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) + return reply.send(template) + }) + + app.delete('/lesson-plan-templates/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const template = await LessonPlanTemplateService.delete(app.db, id) + if (!template) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) + return reply.send(template) + }) + + app.post('/lesson-plan-templates/:id/create-plan', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = TemplateInstantiateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const plan = await LessonPlanTemplateService.instantiate(app.db, id, parsed.data, request.user.id) + if (!plan) return reply.status(404).send({ error: { message: 'Template not found', statusCode: 404 } }) + return reply.status(201).send(plan) + }) + // --- Grade History --- app.post('/lesson-plan-items/:id/grades', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index 60f7f2d..b85d996 100644 --- a/packages/backend/src/services/lesson.service.ts +++ b/packages/backend/src/services/lesson.service.ts @@ -1,6 +1,6 @@ import { eq, and, ne, count, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' -import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js' +import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanTemplates, lessonPlanTemplateSections, lessonPlanTemplateItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js' import type { InstructorCreateInput, InstructorUpdateInput, @@ -19,6 +19,9 @@ import type { LessonPlanItemUpdateInput, GradeCreateInput, SessionPlanItemsInput, + LessonPlanTemplateCreateInput, + LessonPlanTemplateUpdateInput, + TemplateInstantiateInput, InstructorBlockedDateCreateInput, StoreClosureCreateInput, PaginationInput, @@ -959,6 +962,216 @@ export const LessonPlanService = { }, } +export const LessonPlanTemplateService = { + async create(db: PostgresJsDatabase, input: LessonPlanTemplateCreateInput, createdBy?: string) { + const [template] = await db + .insert(lessonPlanTemplates) + .values({ + name: input.name, + description: input.description, + instrument: input.instrument, + skillLevel: input.skillLevel, + createdBy, + }) + .returning() + + const sections: any[] = [] + for (const sectionInput of input.sections ?? []) { + const [section] = await db + .insert(lessonPlanTemplateSections) + .values({ + templateId: template.id, + title: sectionInput.title, + description: sectionInput.description, + sortOrder: sectionInput.sortOrder, + }) + .returning() + + const items: any[] = [] + if (sectionInput.items?.length) { + const createdItems = await db + .insert(lessonPlanTemplateItems) + .values( + sectionInput.items.map((item) => ({ + sectionId: section.id, + title: item.title, + description: item.description, + gradingScaleId: item.gradingScaleId, + targetGradeValue: item.targetGradeValue, + sortOrder: item.sortOrder, + })), + ) + .returning() + items.push(...createdItems) + } + sections.push({ ...section, items }) + } + + return { ...template, sections } + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [template] = await db + .select() + .from(lessonPlanTemplates) + .where(eq(lessonPlanTemplates.id, id)) + .limit(1) + if (!template) return null + + const sections = await db + .select() + .from(lessonPlanTemplateSections) + .where(eq(lessonPlanTemplateSections.templateId, id)) + .orderBy(lessonPlanTemplateSections.sortOrder) + + const sectionIds = sections.map((s) => s.id) + const items = sectionIds.length > 0 + ? await db + .select() + .from(lessonPlanTemplateItems) + .where(inArray(lessonPlanTemplateItems.sectionId, sectionIds)) + .orderBy(lessonPlanTemplateItems.sortOrder) + : [] + + const itemsBySection = new Map() + for (const item of items) { + const existing = itemsBySection.get(item.sectionId) ?? [] + existing.push(item) + itemsBySection.set(item.sectionId, existing) + } + + return { + ...template, + sections: sections.map((s) => ({ + ...s, + items: itemsBySection.get(s.id) ?? [], + })), + } + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { + instrument?: string + skillLevel?: string + }) { + const conditions: SQL[] = [eq(lessonPlanTemplates.isActive, true)] + + if (params.q) { + const search = buildSearchCondition(params.q, [lessonPlanTemplates.name, lessonPlanTemplates.instrument]) + if (search) conditions.push(search) + } + if (filters?.instrument) { + conditions.push(eq(lessonPlanTemplates.instrument, filters.instrument)) + } + if (filters?.skillLevel) { + conditions.push(eq(lessonPlanTemplates.skillLevel, filters.skillLevel as any)) + } + + const where = and(...conditions) + + const sortableColumns: Record = { + name: lessonPlanTemplates.name, + instrument: lessonPlanTemplates.instrument, + skill_level: lessonPlanTemplates.skillLevel, + created_at: lessonPlanTemplates.createdAt, + } + + let query = db.select().from(lessonPlanTemplates).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, lessonPlanTemplates.name) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(lessonPlanTemplates).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async update(db: PostgresJsDatabase, id: string, input: LessonPlanTemplateUpdateInput) { + const [template] = await db + .update(lessonPlanTemplates) + .set({ ...input, updatedAt: new Date() }) + .where(eq(lessonPlanTemplates.id, id)) + .returning() + return template ?? null + }, + + async delete(db: PostgresJsDatabase, id: string) { + const [template] = await db + .update(lessonPlanTemplates) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(lessonPlanTemplates.id, id)) + .returning() + return template ?? null + }, + + /** + * Deep-copy a template into a new member_lesson_plan for the given enrollment. + * Template changes after this point do not affect the created plan. + */ + async instantiate(db: PostgresJsDatabase, templateId: string, input: TemplateInstantiateInput, createdBy?: string) { + const template = await LessonPlanTemplateService.getById(db, templateId) + if (!template) return null + + // Deactivate any existing active plan on this enrollment + await db + .update(memberLessonPlans) + .set({ isActive: false, updatedAt: new Date() }) + .where( + and( + eq(memberLessonPlans.enrollmentId, input.enrollmentId), + eq(memberLessonPlans.isActive, true), + ), + ) + + const [plan] = await db + .insert(memberLessonPlans) + .values({ + memberId: input.memberId, + enrollmentId: input.enrollmentId, + createdBy, + title: input.title ?? template.name, + description: template.description, + isActive: true, + }) + .returning() + + const sections: any[] = [] + for (const tSection of template.sections) { + const [section] = await db + .insert(lessonPlanSections) + .values({ + lessonPlanId: plan.id, + title: tSection.title, + description: tSection.description, + sortOrder: tSection.sortOrder, + }) + .returning() + + const items: any[] = [] + if (tSection.items.length > 0) { + const createdItems = await db + .insert(lessonPlanItems) + .values( + tSection.items.map((item) => ({ + sectionId: section.id, + title: item.title, + description: item.description, + gradingScaleId: item.gradingScaleId, + targetGradeValue: item.targetGradeValue, + sortOrder: item.sortOrder, + })), + ) + .returning() + items.push(...createdItems) + } + sections.push({ ...section, items }) + } + + return { ...plan, sections } + }, +} + export const GradeHistoryService = { async create(db: PostgresJsDatabase, lessonPlanItemId: string, input: GradeCreateInput, gradedBy?: string) { // Get the item to check current status and validate grading scale diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 97147e5..bace48a 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -126,6 +126,10 @@ export { StoreClosureCreateSchema, GradeCreateSchema, SessionPlanItemsSchema, + SkillLevel, + LessonPlanTemplateCreateSchema, + LessonPlanTemplateUpdateSchema, + TemplateInstantiateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -149,4 +153,7 @@ export type { StoreClosureCreateInput, GradeCreateInput, SessionPlanItemsInput, + LessonPlanTemplateCreateInput, + LessonPlanTemplateUpdateInput, + TemplateInstantiateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index bc63ad2..db7cfe9 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -200,6 +200,51 @@ export const LessonPlanItemUpdateSchema = z.object({ }) export type LessonPlanItemUpdateInput = z.infer +// --- Lesson Plan Template schemas --- + +export const SkillLevel = z.enum(['beginner', 'intermediate', 'advanced', 'all_levels']) +export type SkillLevel = z.infer + +const TemplateItemInput = z.object({ + title: z.string().min(1).max(255), + description: opt(z.string()), + gradingScaleId: opt(z.string().uuid()), + targetGradeValue: opt(z.string().max(50)), + sortOrder: z.coerce.number().int(), +}) + +const TemplateSectionInput = z.object({ + title: z.string().min(1).max(255), + description: opt(z.string()), + sortOrder: z.coerce.number().int(), + items: z.array(TemplateItemInput).default([]), +}) + +export const LessonPlanTemplateCreateSchema = z.object({ + name: z.string().min(1).max(255), + description: opt(z.string()), + instrument: opt(z.string().max(100)), + skillLevel: SkillLevel.default('all_levels'), + sections: z.array(TemplateSectionInput).default([]), +}) +export type LessonPlanTemplateCreateInput = z.infer + +export const LessonPlanTemplateUpdateSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: opt(z.string()), + instrument: opt(z.string().max(100)), + skillLevel: SkillLevel.optional(), + isActive: z.boolean().optional(), +}) +export type LessonPlanTemplateUpdateInput = z.infer + +export const TemplateInstantiateSchema = z.object({ + memberId: z.string().uuid(), + enrollmentId: z.string().uuid(), + title: opt(z.string().min(1).max(255)), +}) +export type TemplateInstantiateInput = z.infer + // --- Grade History schemas --- export const GradeCreateSchema = z.object({