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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user