Add lessons Phase 5: grading scales with nested levels

Custom grading scales with ordered levels (value, label, numeric score,
color). Supports one-default-per-store constraint, deep create with
nested levels, lookup endpoint for dropdowns, and search/pagination.
12 new tests (76 total lessons tests).
This commit is contained in:
Ryan Moon
2026-03-30 09:36:48 -05:00
parent 73360cd478
commit 31f661ff4f
8 changed files with 396 additions and 2 deletions

View File

@@ -0,0 +1,22 @@
-- Phase 5: Grading scales — custom grade definitions per store
CREATE TABLE "grading_scale" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"name" varchar(255) NOT NULL,
"description" text,
"is_default" boolean NOT NULL DEFAULT false,
"created_by" uuid REFERENCES "user"("id"),
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE "grading_scale_level" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"grading_scale_id" uuid NOT NULL REFERENCES "grading_scale"("id"),
"value" varchar(50) NOT NULL,
"label" varchar(255) NOT NULL,
"numeric_value" integer NOT NULL,
"color_hex" varchar(7),
"sort_order" integer NOT NULL
);

View File

@@ -225,6 +225,13 @@
"when": 1774910000000,
"tag": "0031_lesson_sessions",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1774920000000,
"tag": "0032_grading_scales",
"breakpoints": true
}
]
}

View File

@@ -120,6 +120,29 @@ export const lessonSessions = pgTable('lesson_session', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const gradingScales = pgTable('grading_scale', {
id: uuid('id').primaryKey().defaultRandom(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
isDefault: boolean('is_default').notNull().default(false),
createdBy: uuid('created_by').references(() => users.id),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const gradingScaleLevels = pgTable('grading_scale_level', {
id: uuid('id').primaryKey().defaultRandom(),
gradingScaleId: uuid('grading_scale_id')
.notNull()
.references(() => gradingScales.id),
value: varchar('value', { length: 50 }).notNull(),
label: varchar('label', { length: 255 }).notNull(),
numericValue: integer('numeric_value').notNull(),
colorHex: varchar('color_hex', { length: 7 }),
sortOrder: integer('sort_order').notNull(),
})
// --- Type exports ---
export type Instructor = typeof instructors.$inferSelect
@@ -132,3 +155,7 @@ export type Enrollment = typeof enrollments.$inferSelect
export type EnrollmentInsert = typeof enrollments.$inferInsert
export type LessonSession = typeof lessonSessions.$inferSelect
export type LessonSessionInsert = typeof lessonSessions.$inferInsert
export type GradingScale = typeof gradingScales.$inferSelect
export type GradingScaleInsert = typeof gradingScales.$inferInsert
export type GradingScaleLevel = typeof gradingScaleLevels.$inferSelect
export type GradingScaleLevelInsert = typeof gradingScaleLevels.$inferInsert

View File

@@ -13,8 +13,10 @@ import {
LessonSessionStatusUpdateSchema,
LessonSessionNotesSchema,
LessonSessionUpdateSchema,
GradingScaleCreateSchema,
GradingScaleUpdateSchema,
} from '@lunarfront/shared/schemas'
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService } from '../../services/lesson.service.js'
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService } from '../../services/lesson.service.js'
export const lessonRoutes: FastifyPluginAsync = async (app) => {
// --- Instructors ---
@@ -273,4 +275,51 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => {
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
return reply.send(session)
})
// --- Grading Scales ---
app.post('/grading-scales', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const parsed = GradingScaleCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const scale = await GradingScaleService.create(app.db, parsed.data, request.user.id)
return reply.status(201).send(scale)
})
app.get('/grading-scales', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await GradingScaleService.list(app.db, params)
return reply.send(result)
})
app.get('/grading-scales/all', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (_request, reply) => {
const scales = await GradingScaleService.listAll(app.db)
return reply.send(scales)
})
app.get('/grading-scales/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const scale = await GradingScaleService.getById(app.db, id)
if (!scale) return reply.status(404).send({ error: { message: 'Grading scale not found', statusCode: 404 } })
return reply.send(scale)
})
app.patch('/grading-scales/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = GradingScaleUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const scale = await GradingScaleService.update(app.db, id, parsed.data)
if (!scale) return reply.status(404).send({ error: { message: 'Grading scale not found', statusCode: 404 } })
return reply.send(scale)
})
app.delete('/grading-scales/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const scale = await GradingScaleService.delete(app.db, id)
if (!scale) return reply.status(404).send({ error: { message: 'Grading scale not found', statusCode: 404 } })
return reply.send(scale)
})
}

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 } from '../db/schema/lessons.js'
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, gradingScales, gradingScaleLevels } from '../db/schema/lessons.js'
import type {
InstructorCreateInput,
InstructorUpdateInput,
@@ -12,6 +12,8 @@ import type {
EnrollmentUpdateInput,
LessonSessionNotesInput,
LessonSessionUpdateInput,
GradingScaleCreateInput,
GradingScaleUpdateInput,
PaginationInput,
} from '@lunarfront/shared/schemas'
import {
@@ -577,3 +579,132 @@ export const LessonSessionService = {
return session ?? null
},
}
export const GradingScaleService = {
async create(db: PostgresJsDatabase<any>, input: GradingScaleCreateInput, createdBy?: string) {
// If setting as default, unset any existing default
if (input.isDefault) {
await db
.update(gradingScales)
.set({ isDefault: false, updatedAt: new Date() })
.where(eq(gradingScales.isDefault, true))
}
const [scale] = await db
.insert(gradingScales)
.values({
name: input.name,
description: input.description,
isDefault: input.isDefault,
createdBy,
})
.returning()
const levels = await db
.insert(gradingScaleLevels)
.values(
input.levels.map((l) => ({
gradingScaleId: scale.id,
value: l.value,
label: l.label,
numericValue: l.numericValue,
colorHex: l.colorHex,
sortOrder: l.sortOrder,
})),
)
.returning()
return { ...scale, levels }
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [scale] = await db
.select()
.from(gradingScales)
.where(eq(gradingScales.id, id))
.limit(1)
if (!scale) return null
const levels = await db
.select()
.from(gradingScaleLevels)
.where(eq(gradingScaleLevels.gradingScaleId, id))
.orderBy(gradingScaleLevels.sortOrder)
return { ...scale, levels }
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(gradingScales.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [gradingScales.name])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: gradingScales.name,
created_at: gradingScales.createdAt,
}
let query = db.select().from(gradingScales).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, gradingScales.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(gradingScales).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>) {
const scales = await db
.select()
.from(gradingScales)
.where(eq(gradingScales.isActive, true))
.orderBy(gradingScales.name)
// Fetch levels for all scales in one query
if (scales.length === 0) return []
const allLevels = await db
.select()
.from(gradingScaleLevels)
.where(inArray(gradingScaleLevels.gradingScaleId, scales.map((s) => s.id)))
.orderBy(gradingScaleLevels.sortOrder)
const levelsByScale = new Map<string, typeof allLevels>()
for (const level of allLevels) {
const existing = levelsByScale.get(level.gradingScaleId) ?? []
existing.push(level)
levelsByScale.set(level.gradingScaleId, existing)
}
return scales.map((s) => ({ ...s, levels: levelsByScale.get(s.id) ?? [] }))
},
async update(db: PostgresJsDatabase<any>, id: string, input: GradingScaleUpdateInput) {
if (input.isDefault) {
await db
.update(gradingScales)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(eq(gradingScales.isDefault, true), ne(gradingScales.id, id)))
}
const [scale] = await db
.update(gradingScales)
.set({ ...input, updatedAt: new Date() })
.where(eq(gradingScales.id, id))
.returning()
return scale ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [scale] = await db
.update(gradingScales)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(gradingScales.id, id))
.returning()
return scale ?? null
},
}