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:
@@ -939,4 +939,133 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.every((s: any) => s.status === 'scheduled'))
|
||||
})
|
||||
|
||||
// ─── Grading Scales: CRUD ───
|
||||
|
||||
t.test('creates a grading scale with nested levels', { tags: ['grading-scales', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/grading-scales', {
|
||||
name: 'Standard Letter',
|
||||
description: 'Traditional letter grades',
|
||||
isDefault: true,
|
||||
levels: [
|
||||
{ value: 'A+', label: 'Excellent Plus', numericValue: 97, colorHex: '#4CAF50', sortOrder: 1 },
|
||||
{ value: 'A', label: 'Excellent', numericValue: 93, colorHex: '#4CAF50', sortOrder: 2 },
|
||||
{ value: 'A-', label: 'Excellent Minus', numericValue: 90, colorHex: '#8BC34A', sortOrder: 3 },
|
||||
{ value: 'B+', label: 'Good Plus', numericValue: 87, colorHex: '#CDDC39', sortOrder: 4 },
|
||||
{ value: 'B', label: 'Good', numericValue: 83, colorHex: '#CDDC39', sortOrder: 5 },
|
||||
{ value: 'F', label: 'Fail', numericValue: 0, colorHex: '#F44336', sortOrder: 10 },
|
||||
],
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.name, 'Standard Letter')
|
||||
t.assert.equal(res.data.isDefault, true)
|
||||
t.assert.equal(res.data.levels.length, 6)
|
||||
t.assert.equal(res.data.levels[0].value, 'A+')
|
||||
t.assert.equal(res.data.levels[0].numericValue, 97)
|
||||
})
|
||||
|
||||
t.test('creates a progress scale', { tags: ['grading-scales', 'create'] }, async () => {
|
||||
const res = await t.api.post('/v1/grading-scales', {
|
||||
name: 'Progress',
|
||||
levels: [
|
||||
{ value: 'Mastered', label: 'Skill fully mastered', numericValue: 100, colorHex: '#4CAF50', sortOrder: 1 },
|
||||
{ value: 'Proficient', label: 'Near mastery', numericValue: 75, colorHex: '#8BC34A', sortOrder: 2 },
|
||||
{ value: 'Developing', label: 'Making progress', numericValue: 50, colorHex: '#FFC107', sortOrder: 3 },
|
||||
{ value: 'Beginning', label: 'Just started', numericValue: 25, colorHex: '#FF9800', sortOrder: 4 },
|
||||
],
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.levels.length, 4)
|
||||
})
|
||||
|
||||
t.test('rejects scale without levels', { tags: ['grading-scales', 'create', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/grading-scales', { name: 'Empty Scale', levels: [] })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('rejects scale without name', { tags: ['grading-scales', 'create', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/grading-scales', { levels: [{ value: 'A', label: 'A', numericValue: 90, sortOrder: 1 }] })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('gets grading scale with levels', { tags: ['grading-scales', 'read'] }, async () => {
|
||||
const created = await t.api.post('/v1/grading-scales', {
|
||||
name: 'Get Test Scale',
|
||||
levels: [
|
||||
{ value: 'Pass', label: 'Passed', numericValue: 70, sortOrder: 1 },
|
||||
{ value: 'Fail', label: 'Failed', numericValue: 0, sortOrder: 2 },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await t.api.get(`/v1/grading-scales/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'Get Test Scale')
|
||||
t.assert.equal(res.data.levels.length, 2)
|
||||
t.assert.equal(res.data.levels[0].value, 'Pass')
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing grading scale', { tags: ['grading-scales', 'read'] }, async () => {
|
||||
const res = await t.api.get('/v1/grading-scales/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('lists grading scales with pagination', { tags: ['grading-scales', 'read', 'pagination'] }, async () => {
|
||||
const res = await t.api.get('/v1/grading-scales', { limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 2)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('searches grading scales by name', { tags: ['grading-scales', 'search'] }, async () => {
|
||||
const res = await t.api.get('/v1/grading-scales', { q: 'Progress' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.some((s: any) => s.name === 'Progress'))
|
||||
})
|
||||
|
||||
t.test('lists all scales with levels (lookup endpoint)', { tags: ['grading-scales', 'read'] }, async () => {
|
||||
const res = await t.api.get('/v1/grading-scales/all')
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(Array.isArray(res.data))
|
||||
t.assert.ok(res.data.length >= 2)
|
||||
t.assert.ok(res.data[0].levels, 'each scale should include levels')
|
||||
})
|
||||
|
||||
t.test('updates a grading scale', { tags: ['grading-scales', 'update'] }, async () => {
|
||||
const created = await t.api.post('/v1/grading-scales', {
|
||||
name: 'Before Update Scale',
|
||||
levels: [{ value: 'A', label: 'Grade A', numericValue: 90, sortOrder: 1 }],
|
||||
})
|
||||
const res = await t.api.patch(`/v1/grading-scales/${created.data.id}`, { name: 'After Update Scale' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.name, 'After Update Scale')
|
||||
})
|
||||
|
||||
t.test('setting new default unsets previous default', { tags: ['grading-scales', 'update'] }, async () => {
|
||||
// First scale was created with isDefault: true
|
||||
const newDefault = await t.api.post('/v1/grading-scales', {
|
||||
name: 'New Default Scale',
|
||||
isDefault: true,
|
||||
levels: [{ value: 'OK', label: 'Okay', numericValue: 70, sortOrder: 1 }],
|
||||
})
|
||||
t.assert.equal(newDefault.data.isDefault, true)
|
||||
|
||||
// Check all scales - only one should be default
|
||||
const allScales = await t.api.get('/v1/grading-scales/all')
|
||||
const defaults = allScales.data.filter((s: any) => s.isDefault === true)
|
||||
t.assert.equal(defaults.length, 1, 'only one scale should be default')
|
||||
t.assert.equal(defaults[0].id, newDefault.data.id)
|
||||
})
|
||||
|
||||
t.test('soft-deletes a grading scale', { tags: ['grading-scales', 'delete'] }, async () => {
|
||||
const created = await t.api.post('/v1/grading-scales', {
|
||||
name: 'To Delete Scale',
|
||||
levels: [{ value: 'X', label: 'Delete Me', numericValue: 0, sortOrder: 1 }],
|
||||
})
|
||||
const res = await t.api.del(`/v1/grading-scales/${created.data.id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.isActive, false)
|
||||
|
||||
const list = await t.api.get('/v1/grading-scales', { q: 'To Delete Scale', limit: 100 })
|
||||
t.assert.equal(list.data.data.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
22
packages/backend/src/db/migrations/0032_grading_scales.sql
Normal file
22
packages/backend/src/db/migrations/0032_grading_scales.sql
Normal 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
|
||||
);
|
||||
@@ -225,6 +225,13 @@
|
||||
"when": 1774910000000,
|
||||
"tag": "0031_lesson_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1774920000000,
|
||||
"tag": "0032_grading_scales",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user