Files
lunarfront-app/packages/backend/src/services/lesson.service.ts
Ryan Moon 5cd2d05983 Add Phase 4b: instructor blocked dates, store closures, and substitute instructors
- New tables: instructor_blocked_date, store_closure (migration 0034)
- substitute_instructor_id column added to lesson_session
- Session generation skips blocked instructor dates and store closure periods
- Substitute assignment validates sub is not blocked and has no conflicting slot
- Routes: POST/GET/DELETE /instructors/:id/blocked-dates, POST/GET/DELETE /store-closures
- 15 new integration tests covering blocked dates, store closures, and sub validation
2026-03-30 10:29:13 -05:00

1066 lines
34 KiB
TypeScript

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 } from '../db/schema/lessons.js'
import type {
InstructorCreateInput,
InstructorUpdateInput,
LessonTypeCreateInput,
LessonTypeUpdateInput,
ScheduleSlotCreateInput,
ScheduleSlotUpdateInput,
EnrollmentCreateInput,
EnrollmentUpdateInput,
LessonSessionNotesInput,
LessonSessionUpdateInput,
GradingScaleCreateInput,
GradingScaleUpdateInput,
LessonPlanCreateInput,
LessonPlanUpdateInput,
LessonPlanItemUpdateInput,
InstructorBlockedDateCreateInput,
StoreClosureCreateInput,
PaginationInput,
} from '@lunarfront/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const InstructorService = {
async create(db: PostgresJsDatabase<any>, input: InstructorCreateInput) {
const [instructor] = await db
.insert(instructors)
.values({
userId: input.userId,
displayName: input.displayName,
bio: input.bio,
instruments: input.instruments,
})
.returning()
return instructor
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [instructor] = await db
.select()
.from(instructors)
.where(eq(instructors.id, id))
.limit(1)
return instructor ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(instructors.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [instructors.displayName])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
display_name: instructors.displayName,
created_at: instructors.createdAt,
}
let query = db.select().from(instructors).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, instructors.displayName)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(instructors).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: InstructorUpdateInput) {
const [instructor] = await db
.update(instructors)
.set({ ...input, updatedAt: new Date() })
.where(eq(instructors.id, id))
.returning()
return instructor ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [instructor] = await db
.update(instructors)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(instructors.id, id))
.returning()
return instructor ?? null
},
}
export const LessonTypeService = {
async create(db: PostgresJsDatabase<any>, input: LessonTypeCreateInput) {
const [lessonType] = await db
.insert(lessonTypes)
.values({
name: input.name,
instrument: input.instrument,
durationMinutes: input.durationMinutes,
lessonFormat: input.lessonFormat,
baseRateMonthly: input.baseRateMonthly?.toString(),
})
.returning()
return lessonType
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [lessonType] = await db
.select()
.from(lessonTypes)
.where(eq(lessonTypes.id, id))
.limit(1)
return lessonType ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(lessonTypes.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [lessonTypes.name, lessonTypes.instrument])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: lessonTypes.name,
instrument: lessonTypes.instrument,
duration_minutes: lessonTypes.durationMinutes,
created_at: lessonTypes.createdAt,
}
let query = db.select().from(lessonTypes).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, lessonTypes.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(lessonTypes).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: LessonTypeUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.baseRateMonthly !== undefined) values.baseRateMonthly = input.baseRateMonthly.toString()
const [lessonType] = await db
.update(lessonTypes)
.set(values)
.where(eq(lessonTypes.id, id))
.returning()
return lessonType ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [lessonType] = await db
.update(lessonTypes)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(lessonTypes.id, id))
.returning()
return lessonType ?? null
},
}
export const ScheduleSlotService = {
async create(db: PostgresJsDatabase<any>, input: ScheduleSlotCreateInput) {
// Check for overlapping slot on same instructor, day, and time
const [conflict] = await db
.select({ id: scheduleSlots.id })
.from(scheduleSlots)
.where(
and(
eq(scheduleSlots.instructorId, input.instructorId),
eq(scheduleSlots.dayOfWeek, input.dayOfWeek),
eq(scheduleSlots.startTime, input.startTime),
eq(scheduleSlots.isActive, true),
),
)
.limit(1)
if (conflict) {
return { error: 'Instructor already has a slot at this day and time' }
}
const [slot] = await db
.insert(scheduleSlots)
.values({
instructorId: input.instructorId,
lessonTypeId: input.lessonTypeId,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
room: input.room,
maxStudents: input.maxStudents,
})
.returning()
return slot
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [slot] = await db
.select()
.from(scheduleSlots)
.where(eq(scheduleSlots.id, id))
.limit(1)
return slot ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
instructorId?: string
dayOfWeek?: number
}) {
const conditions: SQL[] = [eq(scheduleSlots.isActive, true)]
if (params.q) {
const search = buildSearchCondition(params.q, [scheduleSlots.room])
if (search) conditions.push(search)
}
if (filters?.instructorId) {
conditions.push(eq(scheduleSlots.instructorId, filters.instructorId))
}
if (filters?.dayOfWeek !== undefined) {
conditions.push(eq(scheduleSlots.dayOfWeek, filters.dayOfWeek))
}
const where = and(...conditions)
const sortableColumns: Record<string, Column> = {
day_of_week: scheduleSlots.dayOfWeek,
start_time: scheduleSlots.startTime,
room: scheduleSlots.room,
created_at: scheduleSlots.createdAt,
}
let query = db.select().from(scheduleSlots).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, scheduleSlots.dayOfWeek)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(scheduleSlots).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: ScheduleSlotUpdateInput) {
// If changing day/time/instructor, check for conflicts
if (input.instructorId || input.dayOfWeek !== undefined || input.startTime) {
const existing = await ScheduleSlotService.getById(db, id)
if (!existing) return null
const checkInstructorId = input.instructorId ?? existing.instructorId
const checkDay = input.dayOfWeek ?? existing.dayOfWeek
const checkTime = input.startTime ?? existing.startTime
const [conflict] = await db
.select({ id: scheduleSlots.id })
.from(scheduleSlots)
.where(
and(
eq(scheduleSlots.instructorId, checkInstructorId),
eq(scheduleSlots.dayOfWeek, checkDay),
eq(scheduleSlots.startTime, checkTime),
eq(scheduleSlots.isActive, true),
),
)
.limit(1)
if (conflict && conflict.id !== id) {
return { error: 'Instructor already has a slot at this day and time' }
}
}
const [slot] = await db
.update(scheduleSlots)
.set({ ...input, updatedAt: new Date() })
.where(eq(scheduleSlots.id, id))
.returning()
return slot ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [slot] = await db
.update(scheduleSlots)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(scheduleSlots.id, id))
.returning()
return slot ?? null
},
}
export const EnrollmentService = {
async create(db: PostgresJsDatabase<any>, input: EnrollmentCreateInput) {
// Check slot capacity for group lessons
const slot = await ScheduleSlotService.getById(db, input.scheduleSlotId)
if (!slot) return { error: 'Schedule slot not found' }
const [{ activeCount }] = await db
.select({ activeCount: count() })
.from(enrollments)
.where(
and(
eq(enrollments.scheduleSlotId, input.scheduleSlotId),
eq(enrollments.status, 'active'),
),
)
if (activeCount >= slot.maxStudents) {
return { error: 'Schedule slot is at capacity' }
}
// Check member not already enrolled in a slot at same day/time
const memberSlots = await db
.select({
dayOfWeek: scheduleSlots.dayOfWeek,
startTime: scheduleSlots.startTime,
})
.from(enrollments)
.innerJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id))
.where(
and(
eq(enrollments.memberId, input.memberId),
eq(enrollments.status, 'active'),
),
)
const hasConflict = memberSlots.some(
(s) => s.dayOfWeek === slot.dayOfWeek && s.startTime === slot.startTime,
)
if (hasConflict) {
return { error: 'Member already has a lesson at this day and time' }
}
const [enrollment] = await db
.insert(enrollments)
.values({
memberId: input.memberId,
accountId: input.accountId,
scheduleSlotId: input.scheduleSlotId,
instructorId: input.instructorId,
status: 'active',
startDate: input.startDate,
endDate: input.endDate,
monthlyRate: input.monthlyRate?.toString(),
notes: input.notes,
})
.returning()
return enrollment
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [enrollment] = await db
.select()
.from(enrollments)
.where(eq(enrollments.id, id))
.limit(1)
return enrollment ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
memberId?: string
accountId?: string
instructorId?: string
status?: string[]
}) {
const conditions: SQL[] = []
if (filters?.memberId) conditions.push(eq(enrollments.memberId, filters.memberId))
if (filters?.accountId) conditions.push(eq(enrollments.accountId, filters.accountId))
if (filters?.instructorId) conditions.push(eq(enrollments.instructorId, filters.instructorId))
if (filters?.status?.length) {
const { inArray } = await import('drizzle-orm')
conditions.push(inArray(enrollments.status, filters.status as any))
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const sortableColumns: Record<string, Column> = {
start_date: enrollments.startDate,
status: enrollments.status,
created_at: enrollments.createdAt,
}
let query = db.select().from(enrollments).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, enrollments.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(enrollments).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: EnrollmentUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.monthlyRate !== undefined) values.monthlyRate = input.monthlyRate.toString()
const [enrollment] = await db
.update(enrollments)
.set(values)
.where(eq(enrollments.id, id))
.returning()
return enrollment ?? null
},
async updateStatus(db: PostgresJsDatabase<any>, id: string, status: string) {
const [enrollment] = await db
.update(enrollments)
.set({ status: status as any, updatedAt: new Date() })
.where(eq(enrollments.id, id))
.returning()
return enrollment ?? null
},
}
/**
* Generate lesson session dates for a given day-of-week within a date range.
* Returns ISO date strings (YYYY-MM-DD).
*/
function generateDatesForDay(dayOfWeek: number, fromDate: string, toDate: string): string[] {
const dates: string[] = []
const start = new Date(fromDate + 'T00:00:00')
const end = new Date(toDate + 'T00:00:00')
// Find first occurrence of dayOfWeek on or after start
const current = new Date(start)
const diff = (dayOfWeek - current.getDay() + 7) % 7
current.setDate(current.getDate() + diff)
while (current <= end) {
dates.push(current.toISOString().slice(0, 10))
current.setDate(current.getDate() + 7)
}
return dates
}
export const LessonSessionService = {
/**
* Generate sessions for an enrollment within a rolling window.
* Idempotent: skips dates that already have a session.
* Skips dates blocked by instructor vacation or store closures.
*/
async generateSessions(db: PostgresJsDatabase<any>, enrollmentId: string, windowWeeks = 4) {
const enrollment = await EnrollmentService.getById(db, enrollmentId)
if (!enrollment || enrollment.status !== 'active') return []
const slot = await ScheduleSlotService.getById(db, enrollment.scheduleSlotId)
if (!slot) return []
const today = new Date().toISOString().slice(0, 10)
const fromDate = enrollment.startDate > today ? enrollment.startDate : today
const windowEnd = new Date()
windowEnd.setDate(windowEnd.getDate() + windowWeeks * 7)
const toDate = enrollment.endDate && enrollment.endDate < windowEnd.toISOString().slice(0, 10)
? enrollment.endDate
: windowEnd.toISOString().slice(0, 10)
const dates = generateDatesForDay(slot.dayOfWeek, fromDate, toDate)
if (dates.length === 0) return []
// Fetch instructor blocked dates and store closures that overlap the window
const [blockedDates, closures] = await Promise.all([
db
.select()
.from(instructorBlockedDates)
.where(
and(
eq(instructorBlockedDates.instructorId, enrollment.instructorId),
lte(instructorBlockedDates.startDate, toDate),
gte(instructorBlockedDates.endDate, fromDate),
),
),
db
.select()
.from(storeClosures)
.where(
and(
lte(storeClosures.startDate, toDate),
gte(storeClosures.endDate, fromDate),
),
),
])
const isBlocked = (date: string): boolean => {
for (const b of blockedDates) {
if (date >= b.startDate && date <= b.endDate) return true
}
for (const c of closures) {
if (date >= c.startDate && date <= c.endDate) return true
}
return false
}
// Find existing sessions to avoid duplicates
const existing = await db
.select({ scheduledDate: lessonSessions.scheduledDate })
.from(lessonSessions)
.where(
and(
eq(lessonSessions.enrollmentId, enrollmentId),
gte(lessonSessions.scheduledDate, fromDate),
lte(lessonSessions.scheduledDate, toDate),
),
)
const existingDates = new Set(existing.map((e) => e.scheduledDate))
const newDates = dates.filter((d) => !existingDates.has(d) && !isBlocked(d))
if (newDates.length === 0) return []
const rows = newDates.map((d) => ({
enrollmentId,
scheduledDate: d,
scheduledTime: slot.startTime,
}))
const created = await db.insert(lessonSessions).values(rows).returning()
return created
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [session] = await db
.select()
.from(lessonSessions)
.where(eq(lessonSessions.id, id))
.limit(1)
return session ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
enrollmentId?: string
instructorId?: string
status?: string[]
dateFrom?: string
dateTo?: string
}) {
const conditions: SQL[] = []
if (filters?.enrollmentId) {
conditions.push(eq(lessonSessions.enrollmentId, filters.enrollmentId))
}
if (filters?.instructorId) {
// Join through enrollment to filter by instructor
const enrollmentIds = await db
.select({ id: enrollments.id })
.from(enrollments)
.where(eq(enrollments.instructorId, filters.instructorId))
if (enrollmentIds.length === 0) {
return paginatedResponse([], 0, params.page, params.limit)
}
conditions.push(inArray(lessonSessions.enrollmentId, enrollmentIds.map((e) => e.id)))
}
if (filters?.status?.length) {
conditions.push(inArray(lessonSessions.status, filters.status as any))
}
if (filters?.dateFrom) {
conditions.push(gte(lessonSessions.scheduledDate, filters.dateFrom))
}
if (filters?.dateTo) {
conditions.push(lte(lessonSessions.scheduledDate, filters.dateTo))
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const sortableColumns: Record<string, Column> = {
scheduled_date: lessonSessions.scheduledDate,
scheduled_time: lessonSessions.scheduledTime,
status: lessonSessions.status,
created_at: lessonSessions.createdAt,
}
let query = db.select().from(lessonSessions).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, lessonSessions.scheduledDate)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(lessonSessions).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: LessonSessionUpdateInput) {
// Validate substitute instructor availability if provided
if (input.substituteInstructorId) {
const session = await LessonSessionService.getById(db, id)
if (!session) return null
const sessionDate = session.scheduledDate
// Check sub is not blocked on this date
const [blocked] = await db
.select({ id: instructorBlockedDates.id })
.from(instructorBlockedDates)
.where(
and(
eq(instructorBlockedDates.instructorId, input.substituteInstructorId),
lte(instructorBlockedDates.startDate, sessionDate),
gte(instructorBlockedDates.endDate, sessionDate),
),
)
.limit(1)
if (blocked) {
return { error: 'Substitute instructor is blocked on this date' }
}
// Check sub has no conflicting slot at the same day/time
const enrollment = await EnrollmentService.getById(db, session.enrollmentId)
if (enrollment) {
const slot = await ScheduleSlotService.getById(db, enrollment.scheduleSlotId)
if (slot) {
const [conflict] = await db
.select({ id: scheduleSlots.id })
.from(scheduleSlots)
.where(
and(
eq(scheduleSlots.instructorId, input.substituteInstructorId),
eq(scheduleSlots.dayOfWeek, slot.dayOfWeek),
eq(scheduleSlots.startTime, slot.startTime),
eq(scheduleSlots.isActive, true),
),
)
.limit(1)
if (conflict && conflict.id !== slot.id) {
return { error: 'Substitute instructor has a conflicting slot at this day and time' }
}
}
}
}
const [session] = await db
.update(lessonSessions)
.set({ ...input, updatedAt: new Date() })
.where(eq(lessonSessions.id, id))
.returning()
return session ?? null
},
async updateStatus(db: PostgresJsDatabase<any>, id: string, status: string) {
const [session] = await db
.update(lessonSessions)
.set({ status: status as any, updatedAt: new Date() })
.where(eq(lessonSessions.id, id))
.returning()
return session ?? null
},
async updateNotes(db: PostgresJsDatabase<any>, id: string, input: LessonSessionNotesInput) {
const existing = await LessonSessionService.getById(db, id)
if (!existing) return null
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
// Auto-set notesCompletedAt on first notes save
if (!existing.notesCompletedAt && (input.instructorNotes || input.memberNotes || input.homeworkAssigned)) {
updates.notesCompletedAt = new Date()
}
const [session] = await db
.update(lessonSessions)
.set(updates)
.where(eq(lessonSessions.id, id))
.returning()
return session ?? null
},
}
export const GradingScaleService = {
async create(db: PostgresJsDatabase<any>, input: GradingScaleCreateInput, createdBy?: string) {
// If setting as default, unset any existing default
if (input.isDefault) {
await db
.update(gradingScales)
.set({ isDefault: false, updatedAt: new Date() })
.where(eq(gradingScales.isDefault, true))
}
const [scale] = await db
.insert(gradingScales)
.values({
name: input.name,
description: input.description,
isDefault: input.isDefault,
createdBy,
})
.returning()
const levels = await db
.insert(gradingScaleLevels)
.values(
input.levels.map((l) => ({
gradingScaleId: scale.id,
value: l.value,
label: l.label,
numericValue: l.numericValue,
colorHex: l.colorHex,
sortOrder: l.sortOrder,
})),
)
.returning()
return { ...scale, levels }
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [scale] = await db
.select()
.from(gradingScales)
.where(eq(gradingScales.id, id))
.limit(1)
if (!scale) return null
const levels = await db
.select()
.from(gradingScaleLevels)
.where(eq(gradingScaleLevels.gradingScaleId, id))
.orderBy(gradingScaleLevels.sortOrder)
return { ...scale, levels }
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(gradingScales.isActive, true)
const searchCondition = params.q
? buildSearchCondition(params.q, [gradingScales.name])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: gradingScales.name,
created_at: gradingScales.createdAt,
}
let query = db.select().from(gradingScales).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, gradingScales.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(gradingScales).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>) {
const scales = await db
.select()
.from(gradingScales)
.where(eq(gradingScales.isActive, true))
.orderBy(gradingScales.name)
// Fetch levels for all scales in one query
if (scales.length === 0) return []
const allLevels = await db
.select()
.from(gradingScaleLevels)
.where(inArray(gradingScaleLevels.gradingScaleId, scales.map((s) => s.id)))
.orderBy(gradingScaleLevels.sortOrder)
const levelsByScale = new Map<string, typeof allLevels>()
for (const level of allLevels) {
const existing = levelsByScale.get(level.gradingScaleId) ?? []
existing.push(level)
levelsByScale.set(level.gradingScaleId, existing)
}
return scales.map((s) => ({ ...s, levels: levelsByScale.get(s.id) ?? [] }))
},
async update(db: PostgresJsDatabase<any>, id: string, input: GradingScaleUpdateInput) {
if (input.isDefault) {
await db
.update(gradingScales)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(eq(gradingScales.isDefault, true), ne(gradingScales.id, id)))
}
const [scale] = await db
.update(gradingScales)
.set({ ...input, updatedAt: new Date() })
.where(eq(gradingScales.id, id))
.returning()
return scale ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [scale] = await db
.update(gradingScales)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(gradingScales.id, id))
.returning()
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 InstructorBlockedDateService = {
async create(db: PostgresJsDatabase<any>, instructorId: string, input: InstructorBlockedDateCreateInput) {
if (input.startDate > input.endDate) {
return { error: 'Start date must be on or before end date' }
}
const [row] = await db
.insert(instructorBlockedDates)
.values({
instructorId,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
})
.returning()
return row
},
async list(db: PostgresJsDatabase<any>, instructorId: string) {
return db
.select()
.from(instructorBlockedDates)
.where(eq(instructorBlockedDates.instructorId, instructorId))
.orderBy(instructorBlockedDates.startDate)
},
async delete(db: PostgresJsDatabase<any>, id: string, instructorId: string) {
const [row] = await db
.delete(instructorBlockedDates)
.where(
and(
eq(instructorBlockedDates.id, id),
eq(instructorBlockedDates.instructorId, instructorId),
),
)
.returning()
return row ?? null
},
}
export const StoreClosureService = {
async create(db: PostgresJsDatabase<any>, input: StoreClosureCreateInput) {
if (input.startDate > input.endDate) {
return { error: 'Start date must be on or before end date' }
}
const [row] = await db
.insert(storeClosures)
.values({
name: input.name,
startDate: input.startDate,
endDate: input.endDate,
})
.returning()
return row
},
async list(db: PostgresJsDatabase<any>) {
return db
.select()
.from(storeClosures)
.orderBy(storeClosures.startDate)
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [row] = await db
.delete(storeClosures)
.where(eq(storeClosures.id, id))
.returning()
return row ?? 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
},
}