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:
Ryan Moon
2026-03-30 09:40:41 -05:00
parent 31f661ff4f
commit aae5a022a8
8 changed files with 661 additions and 2 deletions

View 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()
);

View File

@@ -232,6 +232,13 @@
"when": 1774920000000,
"tag": "0032_grading_scales",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1774930000000,
"tag": "0033_lesson_plans",
"breakpoints": true
}
]
}

View File

@@ -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

View File

@@ -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)
})
}

View File

@@ -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
},
}