diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index ea87671..3078645 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -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) + }) }) diff --git a/packages/backend/src/db/migrations/0032_grading_scales.sql b/packages/backend/src/db/migrations/0032_grading_scales.sql new file mode 100644 index 0000000..996a76e --- /dev/null +++ b/packages/backend/src/db/migrations/0032_grading_scales.sql @@ -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 +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index bff9db4..5b0fb4d 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1774910000000, "tag": "0031_lesson_sessions", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1774920000000, + "tag": "0032_grading_scales", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/lessons.ts b/packages/backend/src/db/schema/lessons.ts index 8f5522b..468615f 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -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 diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index 7ffe084..89c90bd 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -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) + }) } diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index 227cfa7..db2cd08 100644 --- a/packages/backend/src/services/lesson.service.ts +++ b/packages/backend/src/services/lesson.service.ts @@ -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, 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, 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, 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 = { + 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) { + 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() + 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, 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, id: string) { + const [scale] = await db + .update(gradingScales) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(gradingScales.id, id)) + .returning() + return scale ?? null + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index c3b659b..01923c2 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -116,6 +116,8 @@ export { LessonSessionStatusUpdateSchema, LessonSessionNotesSchema, LessonSessionUpdateSchema, + GradingScaleCreateSchema, + GradingScaleUpdateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -130,4 +132,6 @@ export type { LessonSessionStatusUpdateInput, LessonSessionNotesInput, LessonSessionUpdateInput, + GradingScaleCreateInput, + GradingScaleUpdateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index 48eb968..4e5e040 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -106,3 +106,28 @@ export const LessonSessionUpdateSchema = z.object({ actualEndTime: opt(z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')), }) export type LessonSessionUpdateInput = z.infer + +// --- Grading Scale schemas --- + +export const GradingScaleLevelInput = z.object({ + value: z.string().min(1).max(50), + label: z.string().min(1).max(255), + numericValue: z.coerce.number().int().min(0).max(100), + colorHex: opt(z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Must be #RRGGBB format')), + sortOrder: z.coerce.number().int(), +}) + +export const GradingScaleCreateSchema = z.object({ + name: z.string().min(1).max(255), + description: opt(z.string()), + isDefault: z.boolean().default(false), + levels: z.array(GradingScaleLevelInput).min(1), +}) +export type GradingScaleCreateInput = z.infer + +export const GradingScaleUpdateSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: opt(z.string()), + isDefault: z.boolean().optional(), +}) +export type GradingScaleUpdateInput = z.infer