diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index 7cb2ad3..220135e 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -1551,4 +1551,232 @@ suite('Lessons', { tags: ['lessons'] }, (t) => { }) 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) + }) }) diff --git a/packages/backend/src/db/migrations/0035_grade_history.sql b/packages/backend/src/db/migrations/0035_grade_history.sql new file mode 100644 index 0000000..7dafc5a --- /dev/null +++ b/packages/backend/src/db/migrations/0035_grade_history.sql @@ -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() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 483f985..f316f40 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1774940000000, "tag": "0034_blocked_dates", "breakpoints": true + }, + { + "idx": 35, + "version": "7", + "when": 1774950000000, + "tag": "0035_grade_history", + "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 33e2aba..8335373 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -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 diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index 6133f07..0f73552 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -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) => { diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index 1508982..60f7f2d 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, 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, 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 = { + 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, lessonPlanItemId: string) { + return db + .select() + .from(lessonPlanItemGradeHistory) + .where(eq(lessonPlanItemGradeHistory.lessonPlanItemId, lessonPlanItemId)) + .orderBy(lessonPlanItemGradeHistory.createdAt) + }, +} + +export const SessionPlanItemService = { + async linkItems(db: PostgresJsDatabase, 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, sessionId: string) { + return db + .select() + .from(lessonSessionPlanItems) + .where(eq(lessonSessionPlanItems.sessionId, sessionId)) + }, +} + export const InstructorBlockedDateService = { async create(db: PostgresJsDatabase, instructorId: string, input: InstructorBlockedDateCreateInput) { if (input.startDate > input.endDate) { diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index de0a0fb..97147e5 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -124,6 +124,8 @@ export { LessonPlanItemUpdateSchema, InstructorBlockedDateCreateSchema, StoreClosureCreateSchema, + GradeCreateSchema, + SessionPlanItemsSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -145,4 +147,6 @@ export type { LessonPlanItemUpdateInput, InstructorBlockedDateCreateInput, StoreClosureCreateInput, + GradeCreateInput, + SessionPlanItemsInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index b6e80cf..bc63ad2 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -199,3 +199,20 @@ export const LessonPlanItemUpdateSchema = z.object({ sortOrder: z.coerce.number().int().optional(), }) export type LessonPlanItemUpdateInput = z.infer + +// --- 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 + +// --- Session Plan Items schemas --- + +export const SessionPlanItemsSchema = z.object({ + lessonPlanItemIds: z.array(z.string().uuid()).min(1), +}) +export type SessionPlanItemsInput = z.infer