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:
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user