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)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
19
packages/backend/src/db/migrations/0035_grade_history.sql
Normal file
19
packages/backend/src/db/migrations/0035_grade_history.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Phase 7: Grade history and session-plan item linking
|
||||||
|
|
||||||
|
CREATE TABLE "lesson_plan_item_grade_history" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"lesson_plan_item_id" uuid NOT NULL REFERENCES "lesson_plan_item"("id"),
|
||||||
|
"grading_scale_id" uuid REFERENCES "grading_scale"("id"),
|
||||||
|
"grade_value" varchar(50) NOT NULL,
|
||||||
|
"graded_by" uuid REFERENCES "user"("id"),
|
||||||
|
"session_id" uuid REFERENCES "lesson_session"("id"),
|
||||||
|
"notes" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "lesson_session_plan_item" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"session_id" uuid NOT NULL REFERENCES "lesson_session"("id"),
|
||||||
|
"lesson_plan_item_id" uuid NOT NULL REFERENCES "lesson_plan_item"("id"),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -246,6 +246,13 @@
|
|||||||
"when": 1774940000000,
|
"when": 1774940000000,
|
||||||
"tag": "0034_blocked_dates",
|
"tag": "0034_blocked_dates",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774950000000,
|
||||||
|
"tag": "0035_grade_history",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -218,6 +218,30 @@ export const lessonPlanItems = pgTable('lesson_plan_item', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_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')
|
||||||
|
.notNull()
|
||||||
|
.references(() => lessonPlanItems.id),
|
||||||
|
gradingScaleId: uuid('grading_scale_id').references(() => gradingScales.id),
|
||||||
|
gradeValue: varchar('grade_value', { length: 50 }).notNull(),
|
||||||
|
gradedBy: uuid('graded_by').references(() => users.id),
|
||||||
|
sessionId: uuid('session_id').references(() => lessonSessions.id),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const lessonSessionPlanItems = pgTable('lesson_session_plan_item', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sessionId: uuid('session_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => lessonSessions.id),
|
||||||
|
lessonPlanItemId: uuid('lesson_plan_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => lessonPlanItems.id),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
// --- Type exports ---
|
// --- Type exports ---
|
||||||
|
|
||||||
export type Instructor = typeof instructors.$inferSelect
|
export type Instructor = typeof instructors.$inferSelect
|
||||||
@@ -244,3 +268,7 @@ export type LessonPlanSection = typeof lessonPlanSections.$inferSelect
|
|||||||
export type LessonPlanSectionInsert = typeof lessonPlanSections.$inferInsert
|
export type LessonPlanSectionInsert = typeof lessonPlanSections.$inferInsert
|
||||||
export type LessonPlanItem = typeof lessonPlanItems.$inferSelect
|
export type LessonPlanItem = typeof lessonPlanItems.$inferSelect
|
||||||
export type LessonPlanItemInsert = typeof lessonPlanItems.$inferInsert
|
export type LessonPlanItemInsert = typeof lessonPlanItems.$inferInsert
|
||||||
|
export type LessonPlanItemGradeHistory = typeof lessonPlanItemGradeHistory.$inferSelect
|
||||||
|
export type LessonPlanItemGradeHistoryInsert = typeof lessonPlanItemGradeHistory.$inferInsert
|
||||||
|
export type LessonSessionPlanItem = typeof lessonSessionPlanItems.$inferSelect
|
||||||
|
export type LessonSessionPlanItemInsert = typeof lessonSessionPlanItems.$inferInsert
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import {
|
|||||||
LessonPlanCreateSchema,
|
LessonPlanCreateSchema,
|
||||||
LessonPlanUpdateSchema,
|
LessonPlanUpdateSchema,
|
||||||
LessonPlanItemUpdateSchema,
|
LessonPlanItemUpdateSchema,
|
||||||
|
GradeCreateSchema,
|
||||||
|
SessionPlanItemsSchema,
|
||||||
InstructorBlockedDateCreateSchema,
|
InstructorBlockedDateCreateSchema,
|
||||||
StoreClosureCreateSchema,
|
StoreClosureCreateSchema,
|
||||||
} from '@lunarfront/shared/schemas'
|
} from '@lunarfront/shared/schemas'
|
||||||
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService, InstructorBlockedDateService, StoreClosureService } from '../../services/lesson.service.js'
|
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService, GradeHistoryService, SessionPlanItemService, InstructorBlockedDateService, StoreClosureService } from '../../services/lesson.service.js'
|
||||||
|
|
||||||
export const lessonRoutes: FastifyPluginAsync = async (app) => {
|
export const lessonRoutes: FastifyPluginAsync = async (app) => {
|
||||||
// --- Instructors ---
|
// --- Instructors ---
|
||||||
@@ -383,6 +385,44 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(item)
|
return reply.send(item)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Grade History ---
|
||||||
|
|
||||||
|
app.post('/lesson-plan-items/:id/grades', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const parsed = GradeCreateSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
|
}
|
||||||
|
const result = await GradeHistoryService.create(app.db, id, parsed.data, request.user.id)
|
||||||
|
if (!result) return reply.status(404).send({ error: { message: 'Lesson plan item not found', statusCode: 404 } })
|
||||||
|
return reply.status(201).send(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/lesson-plan-items/:id/grade-history', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const history = await GradeHistoryService.list(app.db, id)
|
||||||
|
return reply.send(history)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Session Plan Items ---
|
||||||
|
|
||||||
|
app.post('/lesson-sessions/:id/plan-items', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const parsed = SessionPlanItemsSchema.safeParse(request.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||||
|
}
|
||||||
|
const result = await SessionPlanItemService.linkItems(app.db, id, parsed.data)
|
||||||
|
if (result === null) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
|
||||||
|
return reply.status(201).send({ linked: result.length, items: result })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/lesson-sessions/:id/plan-items', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const items = await SessionPlanItemService.listForSession(app.db, id)
|
||||||
|
return reply.send(items)
|
||||||
|
})
|
||||||
|
|
||||||
// --- Instructor Blocked Dates ---
|
// --- Instructor Blocked Dates ---
|
||||||
|
|
||||||
app.post('/instructors/:id/blocked-dates', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
|
app.post('/instructors/:id/blocked-dates', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, ne, count, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm'
|
import { eq, and, ne, count, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm'
|
||||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems } from '../db/schema/lessons.js'
|
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js'
|
||||||
import type {
|
import type {
|
||||||
InstructorCreateInput,
|
InstructorCreateInput,
|
||||||
InstructorUpdateInput,
|
InstructorUpdateInput,
|
||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
LessonPlanCreateInput,
|
LessonPlanCreateInput,
|
||||||
LessonPlanUpdateInput,
|
LessonPlanUpdateInput,
|
||||||
LessonPlanItemUpdateInput,
|
LessonPlanItemUpdateInput,
|
||||||
|
GradeCreateInput,
|
||||||
|
SessionPlanItemsInput,
|
||||||
InstructorBlockedDateCreateInput,
|
InstructorBlockedDateCreateInput,
|
||||||
StoreClosureCreateInput,
|
StoreClosureCreateInput,
|
||||||
PaginationInput,
|
PaginationInput,
|
||||||
@@ -957,6 +959,105 @@ export const LessonPlanService = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const [item] = await db
|
||||||
|
.select()
|
||||||
|
.from(lessonPlanItems)
|
||||||
|
.where(eq(lessonPlanItems.id, lessonPlanItemId))
|
||||||
|
.limit(1)
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
// Create the grade history record
|
||||||
|
const [record] = await db
|
||||||
|
.insert(lessonPlanItemGradeHistory)
|
||||||
|
.values({
|
||||||
|
lessonPlanItemId,
|
||||||
|
gradingScaleId: input.gradingScaleId,
|
||||||
|
gradeValue: input.gradeValue,
|
||||||
|
gradedBy,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
notes: input.notes,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
// Update the item's current grade and auto-transition status
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
currentGradeValue: input.gradeValue,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
if (input.gradingScaleId) updates.gradingScaleId = input.gradingScaleId
|
||||||
|
if (item.status === 'not_started') {
|
||||||
|
updates.status = 'in_progress'
|
||||||
|
updates.startedDate = new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedItem] = await db
|
||||||
|
.update(lessonPlanItems)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(lessonPlanItems.id, lessonPlanItemId))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return { record, item: updatedItem }
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(db: PostgresJsDatabase<any>, lessonPlanItemId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(lessonPlanItemGradeHistory)
|
||||||
|
.where(eq(lessonPlanItemGradeHistory.lessonPlanItemId, lessonPlanItemId))
|
||||||
|
.orderBy(lessonPlanItemGradeHistory.createdAt)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionPlanItemService = {
|
||||||
|
async linkItems(db: PostgresJsDatabase<any>, sessionId: string, input: SessionPlanItemsInput) {
|
||||||
|
// Verify session exists
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(lessonSessions)
|
||||||
|
.where(eq(lessonSessions.id, sessionId))
|
||||||
|
.limit(1)
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
// Find already-linked item ids to avoid duplicates
|
||||||
|
const existing = await db
|
||||||
|
.select({ lessonPlanItemId: lessonSessionPlanItems.lessonPlanItemId })
|
||||||
|
.from(lessonSessionPlanItems)
|
||||||
|
.where(eq(lessonSessionPlanItems.sessionId, sessionId))
|
||||||
|
const existingIds = new Set(existing.map((r) => r.lessonPlanItemId))
|
||||||
|
|
||||||
|
const newIds = input.lessonPlanItemIds.filter((id) => !existingIds.has(id))
|
||||||
|
if (newIds.length === 0) return []
|
||||||
|
|
||||||
|
// Auto-transition any not_started items to in_progress
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
await db
|
||||||
|
.update(lessonPlanItems)
|
||||||
|
.set({ status: 'in_progress', startedDate: today, updatedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(lessonPlanItems.id, newIds),
|
||||||
|
eq(lessonPlanItems.status, 'not_started'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = newIds.map((id) => ({ sessionId, lessonPlanItemId: id }))
|
||||||
|
const created = await db.insert(lessonSessionPlanItems).values(rows).returning()
|
||||||
|
return created
|
||||||
|
},
|
||||||
|
|
||||||
|
async listForSession(db: PostgresJsDatabase<any>, sessionId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(lessonSessionPlanItems)
|
||||||
|
.where(eq(lessonSessionPlanItems.sessionId, sessionId))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const InstructorBlockedDateService = {
|
export const InstructorBlockedDateService = {
|
||||||
async create(db: PostgresJsDatabase<any>, instructorId: string, input: InstructorBlockedDateCreateInput) {
|
async create(db: PostgresJsDatabase<any>, instructorId: string, input: InstructorBlockedDateCreateInput) {
|
||||||
if (input.startDate > input.endDate) {
|
if (input.startDate > input.endDate) {
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export {
|
|||||||
LessonPlanItemUpdateSchema,
|
LessonPlanItemUpdateSchema,
|
||||||
InstructorBlockedDateCreateSchema,
|
InstructorBlockedDateCreateSchema,
|
||||||
StoreClosureCreateSchema,
|
StoreClosureCreateSchema,
|
||||||
|
GradeCreateSchema,
|
||||||
|
SessionPlanItemsSchema,
|
||||||
} from './lessons.schema.js'
|
} from './lessons.schema.js'
|
||||||
export type {
|
export type {
|
||||||
InstructorCreateInput,
|
InstructorCreateInput,
|
||||||
@@ -145,4 +147,6 @@ export type {
|
|||||||
LessonPlanItemUpdateInput,
|
LessonPlanItemUpdateInput,
|
||||||
InstructorBlockedDateCreateInput,
|
InstructorBlockedDateCreateInput,
|
||||||
StoreClosureCreateInput,
|
StoreClosureCreateInput,
|
||||||
|
GradeCreateInput,
|
||||||
|
SessionPlanItemsInput,
|
||||||
} from './lessons.schema.js'
|
} from './lessons.schema.js'
|
||||||
|
|||||||
@@ -199,3 +199,20 @@ export const LessonPlanItemUpdateSchema = z.object({
|
|||||||
sortOrder: z.coerce.number().int().optional(),
|
sortOrder: z.coerce.number().int().optional(),
|
||||||
})
|
})
|
||||||
export type LessonPlanItemUpdateInput = z.infer<typeof LessonPlanItemUpdateSchema>
|
export type LessonPlanItemUpdateInput = z.infer<typeof LessonPlanItemUpdateSchema>
|
||||||
|
|
||||||
|
// --- Grade History schemas ---
|
||||||
|
|
||||||
|
export const GradeCreateSchema = z.object({
|
||||||
|
gradingScaleId: opt(z.string().uuid()),
|
||||||
|
gradeValue: z.string().min(1).max(50),
|
||||||
|
sessionId: opt(z.string().uuid()),
|
||||||
|
notes: opt(z.string()),
|
||||||
|
})
|
||||||
|
export type GradeCreateInput = z.infer<typeof GradeCreateSchema>
|
||||||
|
|
||||||
|
// --- Session Plan Items schemas ---
|
||||||
|
|
||||||
|
export const SessionPlanItemsSchema = z.object({
|
||||||
|
lessonPlanItemIds: z.array(z.string().uuid()).min(1),
|
||||||
|
})
|
||||||
|
export type SessionPlanItemsInput = z.infer<typeof SessionPlanItemsSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user