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

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