Add Phase 8: lesson plan templates with deep-copy instantiation
- New tables: lesson_plan_template, lesson_plan_template_section, lesson_plan_template_item - skill_level enum: beginner, intermediate, advanced, all_levels - Templates are reusable curriculum definitions independent of any member/enrollment - POST /lesson-plan-templates/:id/create-plan deep-copies the template into a member plan - Instantiation uses template name as default plan title, accepts custom title override - Instantiation deactivates any existing active plan on the enrollment (one-active rule) - Plan items are independent copies — renaming the template does not affect existing plans - 11 new integration tests
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, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js'
|
||||
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanTemplates, lessonPlanTemplateSections, lessonPlanTemplateItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js'
|
||||
import type {
|
||||
InstructorCreateInput,
|
||||
InstructorUpdateInput,
|
||||
@@ -19,6 +19,9 @@ import type {
|
||||
LessonPlanItemUpdateInput,
|
||||
GradeCreateInput,
|
||||
SessionPlanItemsInput,
|
||||
LessonPlanTemplateCreateInput,
|
||||
LessonPlanTemplateUpdateInput,
|
||||
TemplateInstantiateInput,
|
||||
InstructorBlockedDateCreateInput,
|
||||
StoreClosureCreateInput,
|
||||
PaginationInput,
|
||||
@@ -959,6 +962,216 @@ export const LessonPlanService = {
|
||||
},
|
||||
}
|
||||
|
||||
export const LessonPlanTemplateService = {
|
||||
async create(db: PostgresJsDatabase<any>, input: LessonPlanTemplateCreateInput, createdBy?: string) {
|
||||
const [template] = await db
|
||||
.insert(lessonPlanTemplates)
|
||||
.values({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
instrument: input.instrument,
|
||||
skillLevel: input.skillLevel,
|
||||
createdBy,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const sections: any[] = []
|
||||
for (const sectionInput of input.sections ?? []) {
|
||||
const [section] = await db
|
||||
.insert(lessonPlanTemplateSections)
|
||||
.values({
|
||||
templateId: template.id,
|
||||
title: sectionInput.title,
|
||||
description: sectionInput.description,
|
||||
sortOrder: sectionInput.sortOrder,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const items: any[] = []
|
||||
if (sectionInput.items?.length) {
|
||||
const createdItems = await db
|
||||
.insert(lessonPlanTemplateItems)
|
||||
.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 { ...template, sections }
|
||||
},
|
||||
|
||||
async getById(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(lessonPlanTemplates)
|
||||
.where(eq(lessonPlanTemplates.id, id))
|
||||
.limit(1)
|
||||
if (!template) return null
|
||||
|
||||
const sections = await db
|
||||
.select()
|
||||
.from(lessonPlanTemplateSections)
|
||||
.where(eq(lessonPlanTemplateSections.templateId, id))
|
||||
.orderBy(lessonPlanTemplateSections.sortOrder)
|
||||
|
||||
const sectionIds = sections.map((s) => s.id)
|
||||
const items = sectionIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(lessonPlanTemplateItems)
|
||||
.where(inArray(lessonPlanTemplateItems.sectionId, sectionIds))
|
||||
.orderBy(lessonPlanTemplateItems.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)
|
||||
}
|
||||
|
||||
return {
|
||||
...template,
|
||||
sections: sections.map((s) => ({
|
||||
...s,
|
||||
items: itemsBySection.get(s.id) ?? [],
|
||||
})),
|
||||
}
|
||||
},
|
||||
|
||||
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
|
||||
instrument?: string
|
||||
skillLevel?: string
|
||||
}) {
|
||||
const conditions: SQL[] = [eq(lessonPlanTemplates.isActive, true)]
|
||||
|
||||
if (params.q) {
|
||||
const search = buildSearchCondition(params.q, [lessonPlanTemplates.name, lessonPlanTemplates.instrument])
|
||||
if (search) conditions.push(search)
|
||||
}
|
||||
if (filters?.instrument) {
|
||||
conditions.push(eq(lessonPlanTemplates.instrument, filters.instrument))
|
||||
}
|
||||
if (filters?.skillLevel) {
|
||||
conditions.push(eq(lessonPlanTemplates.skillLevel, filters.skillLevel as any))
|
||||
}
|
||||
|
||||
const where = and(...conditions)
|
||||
|
||||
const sortableColumns: Record<string, Column> = {
|
||||
name: lessonPlanTemplates.name,
|
||||
instrument: lessonPlanTemplates.instrument,
|
||||
skill_level: lessonPlanTemplates.skillLevel,
|
||||
created_at: lessonPlanTemplates.createdAt,
|
||||
}
|
||||
|
||||
let query = db.select().from(lessonPlanTemplates).where(where).$dynamic()
|
||||
query = withSort(query, params.sort, params.order, sortableColumns, lessonPlanTemplates.name)
|
||||
query = withPagination(query, params.page, params.limit)
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
query,
|
||||
db.select({ total: count() }).from(lessonPlanTemplates).where(where),
|
||||
])
|
||||
|
||||
return paginatedResponse(data, total, params.page, params.limit)
|
||||
},
|
||||
|
||||
async update(db: PostgresJsDatabase<any>, id: string, input: LessonPlanTemplateUpdateInput) {
|
||||
const [template] = await db
|
||||
.update(lessonPlanTemplates)
|
||||
.set({ ...input, updatedAt: new Date() })
|
||||
.where(eq(lessonPlanTemplates.id, id))
|
||||
.returning()
|
||||
return template ?? null
|
||||
},
|
||||
|
||||
async delete(db: PostgresJsDatabase<any>, id: string) {
|
||||
const [template] = await db
|
||||
.update(lessonPlanTemplates)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(eq(lessonPlanTemplates.id, id))
|
||||
.returning()
|
||||
return template ?? null
|
||||
},
|
||||
|
||||
/**
|
||||
* Deep-copy a template into a new member_lesson_plan for the given enrollment.
|
||||
* Template changes after this point do not affect the created plan.
|
||||
*/
|
||||
async instantiate(db: PostgresJsDatabase<any>, templateId: string, input: TemplateInstantiateInput, createdBy?: string) {
|
||||
const template = await LessonPlanTemplateService.getById(db, templateId)
|
||||
if (!template) return null
|
||||
|
||||
// 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 ?? template.name,
|
||||
description: template.description,
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const sections: any[] = []
|
||||
for (const tSection of template.sections) {
|
||||
const [section] = await db
|
||||
.insert(lessonPlanSections)
|
||||
.values({
|
||||
lessonPlanId: plan.id,
|
||||
title: tSection.title,
|
||||
description: tSection.description,
|
||||
sortOrder: tSection.sortOrder,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const items: any[] = []
|
||||
if (tSection.items.length > 0) {
|
||||
const createdItems = await db
|
||||
.insert(lessonPlanItems)
|
||||
.values(
|
||||
tSection.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 }
|
||||
},
|
||||
}
|
||||
|
||||
export const GradeHistoryService = {
|
||||
async create(db: PostgresJsDatabase<any>, lessonPlanItemId: string, input: GradeCreateInput, gradedBy?: string) {
|
||||
// Get the item to check current status and validate grading scale
|
||||
|
||||
Reference in New Issue
Block a user