diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index 3078645..16954e5 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -1068,4 +1068,240 @@ suite('Lessons', { tags: ['lessons'] }, (t) => { const list = await t.api.get('/v1/grading-scales', { q: 'To Delete Scale', limit: 100 }) t.assert.equal(list.data.data.length, 0) }) + + // ─── Lesson Plans: Deep Create ─── + + t.test('creates a lesson plan with nested sections and items', { tags: ['lesson-plans', 'create'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Plan Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Emma', lastName: 'Chen' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Plan Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Plan Type', durationMinutes: 30 }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, 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-15', + }) + + const res = await t.api.post('/v1/lesson-plans', { + memberId: member.data.id, + enrollmentId: enrollment.data.id, + title: 'Year 2 Piano', + description: 'Second year curriculum', + startedDate: '2026-01-15', + sections: [ + { + title: 'Scales & Arpeggios', + sortOrder: 1, + items: [ + { title: 'C Major 2 octaves', sortOrder: 1 }, + { title: 'G Major 2 octaves', sortOrder: 2 }, + ], + }, + { + title: 'Repertoire', + sortOrder: 2, + items: [ + { title: 'Minuet in G (Bach)', sortOrder: 1 }, + { title: 'Fur Elise (Beethoven)', sortOrder: 2 }, + ], + }, + { + title: 'Theory', + sortOrder: 3, + items: [ + { title: 'Key signatures to 2 sharps', sortOrder: 1 }, + ], + }, + ], + }) + t.assert.status(res, 201) + t.assert.equal(res.data.title, 'Year 2 Piano') + t.assert.equal(res.data.isActive, true) + t.assert.equal(res.data.sections.length, 3) + t.assert.equal(res.data.sections[0].title, 'Scales & Arpeggios') + t.assert.equal(res.data.sections[0].items.length, 2) + t.assert.equal(res.data.sections[1].items.length, 2) + t.assert.equal(res.data.sections[2].items.length, 1) + t.assert.equal(res.data.sections[0].items[0].status, 'not_started') + }) + + t.test('creating new plan deactivates previous active plan', { tags: ['lesson-plans', 'create'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Deactivate Plan Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Deact', lastName: 'Student' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Deact Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Deact Type', durationMinutes: 30 }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '14: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 plan1 = await t.api.post('/v1/lesson-plans', { + memberId: member.data.id, enrollmentId: enrollment.data.id, + title: 'Plan 1', sections: [], + }) + t.assert.equal(plan1.data.isActive, true) + + const plan2 = await t.api.post('/v1/lesson-plans', { + memberId: member.data.id, enrollmentId: enrollment.data.id, + title: 'Plan 2', sections: [], + }) + t.assert.equal(plan2.data.isActive, true) + + // Check plan1 is now inactive + const check = await t.api.get(`/v1/lesson-plans/${plan1.data.id}`) + t.assert.equal(check.data.isActive, false) + }) + + // ─── Lesson Plans: Read ─── + + t.test('gets lesson plan with sections, items, and progress', { tags: ['lesson-plans', 'read'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Get Plan Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Get', lastName: 'Plan' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Get Plan Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Get Plan Type', durationMinutes: 30 }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, 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 created = await t.api.post('/v1/lesson-plans', { + memberId: member.data.id, enrollmentId: enrollment.data.id, + title: 'Progress Plan', + sections: [{ + title: 'Skills', sortOrder: 1, + items: [ + { title: 'Skill A', sortOrder: 1 }, + { title: 'Skill B', sortOrder: 2 }, + ], + }], + }) + + const res = await t.api.get(`/v1/lesson-plans/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.title, 'Progress Plan') + t.assert.equal(res.data.progress, 0, 'no items mastered yet') + t.assert.equal(res.data.sections.length, 1) + t.assert.equal(res.data.sections[0].items.length, 2) + }) + + t.test('returns 404 for missing lesson plan', { tags: ['lesson-plans', 'read'] }, async () => { + const res = await t.api.get('/v1/lesson-plans/a0000000-0000-0000-0000-999999999999') + t.assert.status(res, 404) + }) + + t.test('lists lesson plans with pagination', { tags: ['lesson-plans', 'read', 'pagination'] }, async () => { + const res = await t.api.get('/v1/lesson-plans', { limit: 100 }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.length >= 1) + t.assert.ok(res.data.pagination) + }) + + // ─── Lesson Plan Items: Status + Auto-Dates ─── + + t.test('item status transition auto-sets startedDate and masteredDate', { tags: ['lesson-plans', 'items', 'status'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Item Status Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Item', lastName: 'Status' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Item Status Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Item Status Type', durationMinutes: 30 }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 5, startTime: '15: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: 'Status Plan', + sections: [{ + title: 'Skills', sortOrder: 1, + items: [{ title: 'Test Item', sortOrder: 1 }], + }], + }) + const itemId = plan.data.sections[0].items[0].id + + // not_started -> in_progress: sets startedDate + const inProgress = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'in_progress' }) + t.assert.equal(inProgress.data.status, 'in_progress') + t.assert.ok(inProgress.data.startedDate, 'startedDate should be set') + t.assert.equal(inProgress.data.masteredDate, null) + + // in_progress -> mastered: sets masteredDate + const mastered = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'mastered' }) + t.assert.equal(mastered.data.status, 'mastered') + t.assert.ok(mastered.data.masteredDate, 'masteredDate should be set') + + // mastered -> in_progress: clears masteredDate + const backToProgress = await t.api.patch(`/v1/lesson-plan-items/${itemId}`, { status: 'in_progress' }) + t.assert.equal(backToProgress.data.masteredDate, null, 'masteredDate should be cleared') + t.assert.ok(backToProgress.data.startedDate, 'startedDate should remain') + }) + + t.test('progress percentage calculation with mastered and skipped items', { tags: ['lesson-plans', 'progress'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Progress Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Prog', lastName: 'Student' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Progress Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Progress Type', durationMinutes: 30 }) + 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: 'Progress Plan', + sections: [{ + title: 'Skills', sortOrder: 1, + items: [ + { title: 'Item A', sortOrder: 1 }, + { title: 'Item B', sortOrder: 2 }, + { title: 'Item C', sortOrder: 3 }, + { title: 'Item D (skip)', sortOrder: 4 }, + ], + }], + }) + + const items = plan.data.sections[0].items + await t.api.patch(`/v1/lesson-plan-items/${items[0].id}`, { status: 'mastered' }) + await t.api.patch(`/v1/lesson-plan-items/${items[3].id}`, { status: 'skipped' }) + + // 1 mastered out of 3 non-skipped = 33% + const res = await t.api.get(`/v1/lesson-plans/${plan.data.id}`) + t.assert.equal(res.data.progress, 33) + }) + + t.test('updates a lesson plan title', { tags: ['lesson-plans', 'update'] }, async () => { + const acct = await t.api.post('/v1/accounts', { name: 'Update Plan Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Update', lastName: 'Plan' }) + const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Plan Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Update Plan Type', durationMinutes: 30 }) + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, 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: 'Before Update', sections: [], + }) + + const res = await t.api.patch(`/v1/lesson-plans/${plan.data.id}`, { title: 'After Update' }) + t.assert.status(res, 200) + t.assert.equal(res.data.title, 'After Update') + }) }) diff --git a/packages/backend/src/db/migrations/0033_lesson_plans.sql b/packages/backend/src/db/migrations/0033_lesson_plans.sql new file mode 100644 index 0000000..9f93dae --- /dev/null +++ b/packages/backend/src/db/migrations/0033_lesson_plans.sql @@ -0,0 +1,43 @@ +-- Phase 6: Lesson plans — structured curriculum per enrollment + +CREATE TYPE "lesson_plan_item_status" AS ENUM ('not_started', 'in_progress', 'mastered', 'skipped'); + +CREATE TABLE "member_lesson_plan" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "member_id" uuid NOT NULL REFERENCES "member"("id"), + "enrollment_id" uuid NOT NULL REFERENCES "enrollment"("id"), + "created_by" uuid REFERENCES "user"("id"), + "title" varchar(255) NOT NULL, + "description" text, + "is_active" boolean NOT NULL DEFAULT true, + "started_date" date, + "completed_date" date, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE "lesson_plan_section" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "lesson_plan_id" uuid NOT NULL REFERENCES "member_lesson_plan"("id"), + "title" varchar(255) NOT NULL, + "description" text, + "sort_order" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE "lesson_plan_item" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "section_id" uuid NOT NULL REFERENCES "lesson_plan_section"("id"), + "title" varchar(255) NOT NULL, + "description" text, + "status" lesson_plan_item_status NOT NULL DEFAULT 'not_started', + "grading_scale_id" uuid REFERENCES "grading_scale"("id"), + "current_grade_value" varchar(50), + "target_grade_value" varchar(50), + "started_date" date, + "mastered_date" date, + "notes" text, + "sort_order" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_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 5b0fb4d..195ad1c 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1774920000000, "tag": "0032_grading_scales", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1774930000000, + "tag": "0033_lesson_plans", + "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 468615f..814934b 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -143,6 +143,61 @@ export const gradingScaleLevels = pgTable('grading_scale_level', { sortOrder: integer('sort_order').notNull(), }) +export const lessonPlanItemStatusEnum = pgEnum('lesson_plan_item_status', [ + 'not_started', + 'in_progress', + 'mastered', + 'skipped', +]) + +export const memberLessonPlans = pgTable('member_lesson_plan', { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id), + enrollmentId: uuid('enrollment_id') + .notNull() + .references(() => enrollments.id), + createdBy: uuid('created_by').references(() => users.id), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + isActive: boolean('is_active').notNull().default(true), + startedDate: date('started_date'), + completedDate: date('completed_date'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const lessonPlanSections = pgTable('lesson_plan_section', { + id: uuid('id').primaryKey().defaultRandom(), + lessonPlanId: uuid('lesson_plan_id') + .notNull() + .references(() => memberLessonPlans.id), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + sortOrder: integer('sort_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const lessonPlanItems = pgTable('lesson_plan_item', { + id: uuid('id').primaryKey().defaultRandom(), + sectionId: uuid('section_id') + .notNull() + .references(() => lessonPlanSections.id), + title: varchar('title', { length: 255 }).notNull(), + description: text('description'), + status: lessonPlanItemStatusEnum('status').notNull().default('not_started'), + gradingScaleId: uuid('grading_scale_id').references(() => gradingScales.id), + currentGradeValue: varchar('current_grade_value', { length: 50 }), + targetGradeValue: varchar('target_grade_value', { length: 50 }), + startedDate: date('started_date'), + masteredDate: date('mastered_date'), + notes: text('notes'), + sortOrder: integer('sort_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + // --- Type exports --- export type Instructor = typeof instructors.$inferSelect @@ -159,3 +214,9 @@ export type GradingScale = typeof gradingScales.$inferSelect export type GradingScaleInsert = typeof gradingScales.$inferInsert export type GradingScaleLevel = typeof gradingScaleLevels.$inferSelect export type GradingScaleLevelInsert = typeof gradingScaleLevels.$inferInsert +export type MemberLessonPlan = typeof memberLessonPlans.$inferSelect +export type MemberLessonPlanInsert = typeof memberLessonPlans.$inferInsert +export type LessonPlanSection = typeof lessonPlanSections.$inferSelect +export type LessonPlanSectionInsert = typeof lessonPlanSections.$inferInsert +export type LessonPlanItem = typeof lessonPlanItems.$inferSelect +export type LessonPlanItemInsert = typeof lessonPlanItems.$inferInsert diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index 89c90bd..252a405 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -15,8 +15,11 @@ import { LessonSessionUpdateSchema, GradingScaleCreateSchema, GradingScaleUpdateSchema, + LessonPlanCreateSchema, + LessonPlanUpdateSchema, + LessonPlanItemUpdateSchema, } from '@lunarfront/shared/schemas' -import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService } from '../../services/lesson.service.js' +import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService } from '../../services/lesson.service.js' export const lessonRoutes: FastifyPluginAsync = async (app) => { // --- Instructors --- @@ -322,4 +325,58 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => { if (!scale) return reply.status(404).send({ error: { message: 'Grading scale not found', statusCode: 404 } }) return reply.send(scale) }) + + // --- Lesson Plans --- + + app.post('/lesson-plans', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { + const parsed = LessonPlanCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const plan = await LessonPlanService.create(app.db, parsed.data, request.user.id) + return reply.status(201).send(plan) + }) + + app.get('/lesson-plans', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const filters = { + enrollmentId: query.enrollmentId, + memberId: query.memberId, + isActive: query.isActive === 'true' ? true : query.isActive === 'false' ? false : undefined, + } + const result = await LessonPlanService.list(app.db, params, filters) + return reply.send(result) + }) + + app.get('/lesson-plans/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const plan = await LessonPlanService.getById(app.db, id) + if (!plan) return reply.status(404).send({ error: { message: 'Lesson plan not found', statusCode: 404 } }) + return reply.send(plan) + }) + + app.patch('/lesson-plans/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = LessonPlanUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const plan = await LessonPlanService.update(app.db, id, parsed.data) + if (!plan) return reply.status(404).send({ error: { message: 'Lesson plan not found', statusCode: 404 } }) + return reply.send(plan) + }) + + // --- Lesson Plan Items --- + + app.patch('/lesson-plan-items/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = LessonPlanItemUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const item = await LessonPlanItemService.update(app.db, id, parsed.data) + if (!item) return reply.status(404).send({ error: { message: 'Lesson plan item not found', statusCode: 404 } }) + return reply.send(item) + }) } diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index db2cd08..05cf70e 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, gradingScales, gradingScaleLevels } from '../db/schema/lessons.js' +import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems } from '../db/schema/lessons.js' import type { InstructorCreateInput, InstructorUpdateInput, @@ -14,6 +14,9 @@ import type { LessonSessionUpdateInput, GradingScaleCreateInput, GradingScaleUpdateInput, + LessonPlanCreateInput, + LessonPlanUpdateInput, + LessonPlanItemUpdateInput, PaginationInput, } from '@lunarfront/shared/schemas' import { @@ -708,3 +711,199 @@ export const GradingScaleService = { return scale ?? null }, } + +export const LessonPlanService = { + async create(db: PostgresJsDatabase, input: LessonPlanCreateInput, createdBy?: string) { + // Deactivate any existing active plan on this enrollment + await db + .update(memberLessonPlans) + .set({ isActive: false, updatedAt: new Date() }) + .where( + and( + eq(memberLessonPlans.enrollmentId, input.enrollmentId), + eq(memberLessonPlans.isActive, true), + ), + ) + + const [plan] = await db + .insert(memberLessonPlans) + .values({ + memberId: input.memberId, + enrollmentId: input.enrollmentId, + createdBy, + title: input.title, + description: input.description, + isActive: true, + startedDate: input.startedDate, + }) + .returning() + + const sections: any[] = [] + for (const sectionInput of input.sections ?? []) { + const [section] = await db + .insert(lessonPlanSections) + .values({ + lessonPlanId: plan.id, + title: sectionInput.title, + description: sectionInput.description, + sortOrder: sectionInput.sortOrder, + }) + .returning() + + const items: any[] = [] + if (sectionInput.items?.length) { + const createdItems = await db + .insert(lessonPlanItems) + .values( + sectionInput.items.map((item) => ({ + sectionId: section.id, + title: item.title, + description: item.description, + gradingScaleId: item.gradingScaleId, + targetGradeValue: item.targetGradeValue, + sortOrder: item.sortOrder, + })), + ) + .returning() + items.push(...createdItems) + } + sections.push({ ...section, items }) + } + + return { ...plan, sections } + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [plan] = await db + .select() + .from(memberLessonPlans) + .where(eq(memberLessonPlans.id, id)) + .limit(1) + if (!plan) return null + + const sections = await db + .select() + .from(lessonPlanSections) + .where(eq(lessonPlanSections.lessonPlanId, id)) + .orderBy(lessonPlanSections.sortOrder) + + const sectionIds = sections.map((s) => s.id) + const items = sectionIds.length > 0 + ? await db + .select() + .from(lessonPlanItems) + .where(inArray(lessonPlanItems.sectionId, sectionIds)) + .orderBy(lessonPlanItems.sortOrder) + : [] + + const itemsBySection = new Map() + for (const item of items) { + const existing = itemsBySection.get(item.sectionId) ?? [] + existing.push(item) + itemsBySection.set(item.sectionId, existing) + } + + // Calculate progress + const totalItems = items.filter((i) => i.status !== 'skipped').length + const masteredItems = items.filter((i) => i.status === 'mastered').length + const progress = totalItems > 0 ? Math.round((masteredItems / totalItems) * 100) : 0 + + return { + ...plan, + progress, + sections: sections.map((s) => ({ + ...s, + items: itemsBySection.get(s.id) ?? [], + })), + } + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { + enrollmentId?: string + memberId?: string + isActive?: boolean + }) { + const conditions: SQL[] = [] + if (filters?.enrollmentId) conditions.push(eq(memberLessonPlans.enrollmentId, filters.enrollmentId)) + if (filters?.memberId) conditions.push(eq(memberLessonPlans.memberId, filters.memberId)) + if (filters?.isActive !== undefined) conditions.push(eq(memberLessonPlans.isActive, filters.isActive)) + + const where = conditions.length > 0 ? and(...conditions) : undefined + + const sortableColumns: Record = { + title: memberLessonPlans.title, + created_at: memberLessonPlans.createdAt, + } + + let query = db.select().from(memberLessonPlans).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, memberLessonPlans.createdAt) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(memberLessonPlans).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async update(db: PostgresJsDatabase, id: string, input: LessonPlanUpdateInput) { + // If activating, deactivate others on same enrollment + if (input.isActive === true) { + const existing = await LessonPlanService.getById(db, id) + if (existing) { + await db + .update(memberLessonPlans) + .set({ isActive: false, updatedAt: new Date() }) + .where( + and( + eq(memberLessonPlans.enrollmentId, existing.enrollmentId), + eq(memberLessonPlans.isActive, true), + ne(memberLessonPlans.id, id), + ), + ) + } + } + + const [plan] = await db + .update(memberLessonPlans) + .set({ ...input, updatedAt: new Date() }) + .where(eq(memberLessonPlans.id, id)) + .returning() + return plan ?? null + }, +} + +export const LessonPlanItemService = { + async update(db: PostgresJsDatabase, id: string, input: LessonPlanItemUpdateInput) { + const existing = await db + .select() + .from(lessonPlanItems) + .where(eq(lessonPlanItems.id, id)) + .limit(1) + if (!existing[0]) return null + + const updates: Record = { ...input, updatedAt: new Date() } + const today = new Date().toISOString().slice(0, 10) + + // Auto-set dates on status transitions + if (input.status) { + if (input.status !== 'not_started' && !existing[0].startedDate) { + updates.startedDate = today + } + if (input.status === 'mastered' && !existing[0].masteredDate) { + updates.masteredDate = today + } + if (input.status !== 'mastered') { + updates.masteredDate = null + } + } + + const [item] = await db + .update(lessonPlanItems) + .set(updates) + .where(eq(lessonPlanItems.id, id)) + .returning() + return item ?? null + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 01923c2..e2eadf0 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -118,6 +118,10 @@ export { LessonSessionUpdateSchema, GradingScaleCreateSchema, GradingScaleUpdateSchema, + LessonPlanItemStatus, + LessonPlanCreateSchema, + LessonPlanUpdateSchema, + LessonPlanItemUpdateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -134,4 +138,7 @@ export type { LessonSessionUpdateInput, GradingScaleCreateInput, GradingScaleUpdateInput, + LessonPlanCreateInput, + LessonPlanUpdateInput, + LessonPlanItemUpdateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index 4e5e040..dca72fc 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -131,3 +131,52 @@ export const GradingScaleUpdateSchema = z.object({ isDefault: z.boolean().optional(), }) export type GradingScaleUpdateInput = z.infer + +// --- Lesson Plan schemas --- + +export const LessonPlanItemStatus = z.enum(['not_started', 'in_progress', 'mastered', 'skipped']) +export type LessonPlanItemStatus = z.infer + +const LessonPlanItemInput = z.object({ + title: z.string().min(1).max(255), + description: opt(z.string()), + gradingScaleId: opt(z.string().uuid()), + targetGradeValue: opt(z.string().max(50)), + sortOrder: z.coerce.number().int(), +}) + +const LessonPlanSectionInput = z.object({ + title: z.string().min(1).max(255), + description: opt(z.string()), + sortOrder: z.coerce.number().int(), + items: z.array(LessonPlanItemInput).default([]), +}) + +export const LessonPlanCreateSchema = z.object({ + memberId: z.string().uuid(), + enrollmentId: z.string().uuid(), + title: z.string().min(1).max(255), + description: opt(z.string()), + startedDate: opt(z.string()), + sections: z.array(LessonPlanSectionInput).default([]), +}) +export type LessonPlanCreateInput = z.infer + +export const LessonPlanUpdateSchema = z.object({ + title: z.string().min(1).max(255).optional(), + description: opt(z.string()), + isActive: z.boolean().optional(), +}) +export type LessonPlanUpdateInput = z.infer + +export const LessonPlanItemUpdateSchema = z.object({ + title: z.string().min(1).max(255).optional(), + description: opt(z.string()), + status: LessonPlanItemStatus.optional(), + gradingScaleId: opt(z.string().uuid()), + currentGradeValue: opt(z.string().max(50)), + targetGradeValue: opt(z.string().max(50)), + notes: opt(z.string()), + sortOrder: z.coerce.number().int().optional(), +}) +export type LessonPlanItemUpdateInput = z.infer