Add Phase 8: lesson plan templates with deep-copy instantiation

- 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
This commit is contained in:
Ryan Moon
2026-03-30 10:37:30 -05:00
parent 2cc8f24535
commit 7680a73d88
8 changed files with 623 additions and 2 deletions

View File

@@ -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()
);

View File

@@ -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
}
]
}

View File

@@ -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

View File

@@ -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<string, string | undefined>
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) => {

View File

@@ -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<any>, 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<any>, 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<string, typeof items>()
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<any>, 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<string, Column> = {
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<any>, 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<any>, 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<any>, 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<any>, lessonPlanItemId: string, input: GradeCreateInput, gradedBy?: string) {
// Get the item to check current status and validate grading scale