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