Add lessons Phase 6: lesson plans with curriculum tracking
Structured lesson plans with nested sections and items per enrollment. Deep create in one request, one-active-per-enrollment constraint, auto-set startedDate/masteredDate on status transitions, progress % calculation (skipped items excluded). 8 new tests (84 total).
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
43
packages/backend/src/db/migrations/0033_lesson_plans.sql
Normal file
43
packages/backend/src/db/migrations/0033_lesson_plans.sql
Normal file
@@ -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()
|
||||
);
|
||||
@@ -232,6 +232,13 @@
|
||||
"when": 1774920000000,
|
||||
"tag": "0032_grading_scales",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1774930000000,
|
||||
"tag": "0033_lesson_plans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string | undefined>
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<any>, 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<any>, 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<string, typeof items>()
|
||||
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<any>, 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<string, Column> = {
|
||||
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<any>, 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<any>, 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<string, unknown> = { ...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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user