Add Phase 7: grade history and session-plan item linking
- New tables: lesson_plan_item_grade_history (append-only), lesson_session_plan_item - Grading an item updates current_grade_value and creates immutable history record - Grading a not_started item auto-transitions it to in_progress - Linking items to a session also auto-transitions not_started items - Link operation is idempotent — re-linking same items produces no duplicates - Endpoints: POST/GET /lesson-plan-items/:id/grades, GET /lesson-plan-items/:id/grade-history - Endpoints: POST/GET /lesson-sessions/:id/plan-items - 8 new integration tests
This commit is contained in:
@@ -1551,4 +1551,232 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
// ─── Grade History ───
|
||||
|
||||
t.test('creates a grade record and updates item current grade', { tags: ['grades', 'create'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Grade Test Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Grade Test Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Grade Test Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Grade', lastName: 'Student' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '09: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Grade Test Plan',
|
||||
sections: [{ title: 'Section 1', sortOrder: 0, items: [{ title: 'Scale Practice', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
|
||||
const scale = await t.api.post('/v1/grading-scales', {
|
||||
name: 'Grade Test Scale',
|
||||
levels: [
|
||||
{ value: 'B', label: 'Good', numericValue: 80, sortOrder: 0 },
|
||||
{ value: 'A', label: 'Excellent', numericValue: 95, sortOrder: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-plan-items/${item.id}/grades`, {
|
||||
gradingScaleId: scale.data.id,
|
||||
gradeValue: 'B',
|
||||
notes: 'Good progress this week',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.record.gradeValue, 'B')
|
||||
t.assert.equal(res.data.item.currentGradeValue, 'B')
|
||||
t.assert.ok(res.data.record.id)
|
||||
})
|
||||
|
||||
t.test('grading a not_started item auto-transitions to in_progress', { tags: ['grades', 'create', 'status'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Auto Transition Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Auto Transition Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Auto Transition Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Auto', lastName: 'Student' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 5, startTime: '10: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Transition Plan',
|
||||
sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'Arpeggios', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
t.assert.equal(item.status, 'not_started')
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-plan-items/${item.id}/grades`, { gradeValue: 'C' })
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.item.status, 'in_progress')
|
||||
t.assert.ok(res.data.item.startedDate)
|
||||
})
|
||||
|
||||
t.test('grade history is append-only — multiple grades accumulate', { tags: ['grades', 'history'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'History Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'History Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'History Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'History', lastName: 'Student' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 6, startTime: '11: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'History Plan',
|
||||
sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'Technique', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
|
||||
await t.api.post(`/v1/lesson-plan-items/${item.id}/grades`, { gradeValue: 'C' })
|
||||
await t.api.post(`/v1/lesson-plan-items/${item.id}/grades`, { gradeValue: 'B' })
|
||||
await t.api.post(`/v1/lesson-plan-items/${item.id}/grades`, { gradeValue: 'A' })
|
||||
|
||||
const res = await t.api.get(`/v1/lesson-plan-items/${item.id}/grade-history`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.length, 3)
|
||||
t.assert.equal(res.data[0].gradeValue, 'C')
|
||||
t.assert.equal(res.data[2].gradeValue, 'A')
|
||||
|
||||
// Current grade on item should be the most recent
|
||||
const itemRes = await t.api.get(`/v1/lesson-plan-items/${item.id}/grade-history`)
|
||||
t.assert.equal(itemRes.data.length, 3)
|
||||
})
|
||||
|
||||
t.test('returns 404 when grading missing item', { tags: ['grades', 'create', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/lesson-plan-items/a0000000-0000-0000-0000-999999999999/grades', {
|
||||
gradeValue: 'B',
|
||||
})
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
// ─── Session Plan Items ───
|
||||
|
||||
t.test('links plan items to a session', { tags: ['session-plan-items', 'create'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Link Items Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Link Items Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Link Items Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Link', lastName: 'Student' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '16: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Link Plan',
|
||||
sections: [{
|
||||
title: 'S1', sortOrder: 0,
|
||||
items: [
|
||||
{ title: 'Item 1', sortOrder: 0 },
|
||||
{ title: 'Item 2', sortOrder: 1 },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const items = plan.data.sections[0].items
|
||||
const genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`)
|
||||
const session = genRes.data.sessions[0]
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${session.id}/plan-items`, {
|
||||
lessonPlanItemIds: items.map((i: any) => i.id),
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.linked, 2)
|
||||
t.assert.equal(res.data.items.length, 2)
|
||||
})
|
||||
|
||||
t.test('linking items to session auto-transitions not_started to in_progress', { tags: ['session-plan-items', 'status'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Link Transition Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Link Transition Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Link Transition Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'LinkTrans', lastName: 'Student' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 1, startTime: '17: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Trans Plan',
|
||||
sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'New Item', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
t.assert.equal(item.status, 'not_started')
|
||||
|
||||
const genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`)
|
||||
const session = genRes.data.sessions[0]
|
||||
|
||||
await t.api.post(`/v1/lesson-sessions/${session.id}/plan-items`, {
|
||||
lessonPlanItemIds: [item.id],
|
||||
})
|
||||
|
||||
// Check item status via grade-history (item is updated in DB)
|
||||
const historyRes = await t.api.get(`/v1/lesson-plan-items/${item.id}/grade-history`)
|
||||
// Item should now be in_progress — verify by fetching the plan
|
||||
const planRes = await t.api.get(`/v1/lesson-plans/${plan.data.id}`)
|
||||
const updatedItem = planRes.data.sections[0].items[0]
|
||||
t.assert.equal(updatedItem.status, 'in_progress')
|
||||
})
|
||||
|
||||
t.test('linking items is idempotent — no duplicates on re-link', { tags: ['session-plan-items', 'idempotent'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Idempotent Link Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Idempotent Link Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Idempotent Link Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Idemp', lastName: 'Link' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, startTime: '18: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Idemp Plan',
|
||||
sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'Item', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
const genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`)
|
||||
const session = genRes.data.sessions[0]
|
||||
|
||||
await t.api.post(`/v1/lesson-sessions/${session.id}/plan-items`, { lessonPlanItemIds: [item.id] })
|
||||
const res2 = await t.api.post(`/v1/lesson-sessions/${session.id}/plan-items`, { lessonPlanItemIds: [item.id] })
|
||||
t.assert.status(res2, 201)
|
||||
t.assert.equal(res2.data.linked, 0) // Already linked, nothing new
|
||||
|
||||
const listRes = await t.api.get(`/v1/lesson-sessions/${session.id}/plan-items`)
|
||||
t.assert.status(listRes, 200)
|
||||
t.assert.equal(listRes.data.length, 1)
|
||||
})
|
||||
|
||||
t.test('returns 404 when linking to missing session', { tags: ['session-plan-items', 'validation'] }, async () => {
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Missing Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Missing Session Type', durationMinutes: 30 })
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Missing Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Miss', lastName: 'Session' })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '19: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 plan = await t.api.post('/v1/lesson-plans', {
|
||||
memberId: member.data.id, enrollmentId: enrollment.data.id, title: 'Miss Plan',
|
||||
sections: [{ title: 'S1', sortOrder: 0, items: [{ title: 'Item', sortOrder: 0 }] }],
|
||||
})
|
||||
const item = plan.data.sections[0].items[0]
|
||||
|
||||
const res = await t.api.post('/v1/lesson-sessions/a0000000-0000-0000-0000-999999999999/plan-items', {
|
||||
lessonPlanItemIds: [item.id],
|
||||
})
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user