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:
Ryan Moon
2026-03-30 10:33:21 -05:00
parent 5cd2d05983
commit 2cc8f24535
8 changed files with 446 additions and 2 deletions

View 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()
);

View File

@@ -246,6 +246,13 @@
"when": 1774940000000,
"tag": "0034_blocked_dates",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1774950000000,
"tag": "0035_grade_history",
"breakpoints": true
}
]
}

View File

@@ -218,6 +218,30 @@ export const lessonPlanItems = pgTable('lesson_plan_item', {
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 ---
export type Instructor = typeof instructors.$inferSelect
@@ -244,3 +268,7 @@ 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 LessonPlanItemGradeHistory = typeof lessonPlanItemGradeHistory.$inferSelect
export type LessonPlanItemGradeHistoryInsert = typeof lessonPlanItemGradeHistory.$inferInsert
export type LessonSessionPlanItem = typeof lessonSessionPlanItems.$inferSelect
export type LessonSessionPlanItemInsert = typeof lessonSessionPlanItems.$inferInsert

View File

@@ -18,10 +18,12 @@ import {
LessonPlanCreateSchema,
LessonPlanUpdateSchema,
LessonPlanItemUpdateSchema,
GradeCreateSchema,
SessionPlanItemsSchema,
InstructorBlockedDateCreateSchema,
StoreClosureCreateSchema,
} 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) => {
// --- Instructors ---
@@ -383,6 +385,44 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => {
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 ---
app.post('/instructors/:id/blocked-dates', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {

View File

@@ -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 } 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 {
InstructorCreateInput,
InstructorUpdateInput,
@@ -17,6 +17,8 @@ import type {
LessonPlanCreateInput,
LessonPlanUpdateInput,
LessonPlanItemUpdateInput,
GradeCreateInput,
SessionPlanItemsInput,
InstructorBlockedDateCreateInput,
StoreClosureCreateInput,
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 = {
async create(db: PostgresJsDatabase<any>, instructorId: string, input: InstructorBlockedDateCreateInput) {
if (input.startDate > input.endDate) {