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, lessonPlanTemplates, lessonPlanTemplateSections, lessonPlanTemplateItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js' import { members } from '../db/schema/accounts.js' import type { InstructorCreateInput, InstructorUpdateInput, LessonTypeCreateInput, LessonTypeUpdateInput, ScheduleSlotCreateInput, ScheduleSlotUpdateInput, EnrollmentCreateInput, EnrollmentUpdateInput, LessonSessionNotesInput, LessonSessionUpdateInput, GradingScaleCreateInput, GradingScaleUpdateInput, LessonPlanCreateInput, LessonPlanUpdateInput, LessonPlanItemUpdateInput, GradeCreateInput, SessionPlanItemsInput, LessonPlanTemplateCreateInput, LessonPlanTemplateUpdateInput, TemplateInstantiateInput, InstructorBlockedDateCreateInput, StoreClosureCreateInput, PaginationInput, } from '@lunarfront/shared/schemas' import { withPagination, withSort, buildSearchCondition, paginatedResponse, } from '../utils/pagination.js' export const InstructorService = { async create(db: PostgresJsDatabase, 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, id: string) { const [instructor] = await db .select() .from(instructors) .where(eq(instructors.id, id)) .limit(1) return instructor ?? null }, async list(db: PostgresJsDatabase, 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 = { 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, 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, 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, input: LessonTypeCreateInput) { const [lessonType] = await db .insert(lessonTypes) .values({ name: input.name, instrument: input.instrument, durationMinutes: input.durationMinutes, lessonFormat: input.lessonFormat, rateWeekly: input.rateWeekly?.toString(), rateMonthly: input.rateMonthly?.toString(), rateQuarterly: input.rateQuarterly?.toString(), }) .returning() return lessonType }, async getById(db: PostgresJsDatabase, id: string) { const [lessonType] = await db .select() .from(lessonTypes) .where(eq(lessonTypes.id, id)) .limit(1) return lessonType ?? null }, async list(db: PostgresJsDatabase, 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 = { 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, id: string, input: LessonTypeUpdateInput) { const values: Record = { ...input, updatedAt: new Date() } if (input.rateWeekly !== undefined) values.rateWeekly = input.rateWeekly?.toString() ?? null if (input.rateMonthly !== undefined) values.rateMonthly = input.rateMonthly?.toString() ?? null if (input.rateQuarterly !== undefined) values.rateQuarterly = input.rateQuarterly?.toString() ?? null const [lessonType] = await db .update(lessonTypes) .set(values) .where(eq(lessonTypes.id, id)) .returning() return lessonType ?? null }, async delete(db: PostgresJsDatabase, 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, 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, rateWeekly: input.rateWeekly?.toString(), rateMonthly: input.rateMonthly?.toString(), rateQuarterly: input.rateQuarterly?.toString(), }) .returning() return slot }, async getById(db: PostgresJsDatabase, id: string) { const [slot] = await db .select() .from(scheduleSlots) .where(eq(scheduleSlots.id, id)) .limit(1) return slot ?? null }, async list(db: PostgresJsDatabase, 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 = { 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, 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, 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, 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, rate: input.rate?.toString(), billingInterval: input.billingInterval, billingUnit: input.billingUnit, notes: input.notes, }) .returning() return enrollment }, async getById(db: PostgresJsDatabase, id: string) { const [enrollment] = await db .select() .from(enrollments) .where(eq(enrollments.id, id)) .limit(1) return enrollment ?? null }, async list(db: PostgresJsDatabase, 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) { conditions.push(inArray(enrollments.status, filters.status as any)) } const where = conditions.length > 0 ? and(...conditions) : undefined const sortableColumns: Record = { start_date: enrollments.startDate, status: enrollments.status, created_at: enrollments.createdAt, member_name: members.firstName, } let query = db .select({ id: enrollments.id, memberId: enrollments.memberId, accountId: enrollments.accountId, scheduleSlotId: enrollments.scheduleSlotId, instructorId: enrollments.instructorId, status: enrollments.status, startDate: enrollments.startDate, endDate: enrollments.endDate, rate: enrollments.rate, billingInterval: enrollments.billingInterval, billingUnit: enrollments.billingUnit, makeupCredits: enrollments.makeupCredits, notes: enrollments.notes, createdAt: enrollments.createdAt, updatedAt: enrollments.updatedAt, memberName: members.firstName, memberLastName: members.lastName, instructorName: instructors.displayName, slotDayOfWeek: scheduleSlots.dayOfWeek, slotStartTime: scheduleSlots.startTime, lessonTypeName: lessonTypes.name, }) .from(enrollments) .leftJoin(members, eq(enrollments.memberId, members.id)) .leftJoin(instructors, eq(enrollments.instructorId, instructors.id)) .leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id)) .leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id)) .where(where) .$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, enrollments.createdAt) query = withPagination(query, params.page, params.limit) const [rows, [{ total }]] = await Promise.all([ query, db.select({ total: count() }).from(enrollments).where(where), ]) const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const data = rows.map((r) => ({ ...r, memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined, slotInfo: r.slotDayOfWeek != null && r.slotStartTime ? `${DAYS[r.slotDayOfWeek]} ${r.slotStartTime.slice(0, 5)}` : undefined, })) return paginatedResponse(data, total, params.page, params.limit) }, async update(db: PostgresJsDatabase, id: string, input: EnrollmentUpdateInput) { const values: Record = { ...input, updatedAt: new Date() } if (input.rate !== undefined) values.rate = input.rate?.toString() ?? null if (input.billingInterval !== undefined) values.billingInterval = input.billingInterval if (input.billingUnit !== undefined) values.billingUnit = input.billingUnit const [enrollment] = await db .update(enrollments) .set(values) .where(eq(enrollments.id, id)) .returning() return enrollment ?? null }, async updateStatus(db: PostgresJsDatabase, 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, 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, id: string) { const [session] = await db .select() .from(lessonSessions) .where(eq(lessonSessions.id, id)) .limit(1) return session ?? null }, async list(db: PostgresJsDatabase, 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) conditions.push(eq(enrollments.instructorId, filters.instructorId)) 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 = { scheduled_date: lessonSessions.scheduledDate, scheduled_time: lessonSessions.scheduledTime, status: lessonSessions.status, created_at: lessonSessions.createdAt, } let query = db .select({ id: lessonSessions.id, enrollmentId: lessonSessions.enrollmentId, scheduledDate: lessonSessions.scheduledDate, scheduledTime: lessonSessions.scheduledTime, actualStartTime: lessonSessions.actualStartTime, actualEndTime: lessonSessions.actualEndTime, status: lessonSessions.status, instructorNotes: lessonSessions.instructorNotes, memberNotes: lessonSessions.memberNotes, homeworkAssigned: lessonSessions.homeworkAssigned, nextLessonGoals: lessonSessions.nextLessonGoals, topicsCovered: lessonSessions.topicsCovered, makeupForSessionId: lessonSessions.makeupForSessionId, substituteInstructorId: lessonSessions.substituteInstructorId, notesCompletedAt: lessonSessions.notesCompletedAt, createdAt: lessonSessions.createdAt, updatedAt: lessonSessions.updatedAt, memberName: members.firstName, memberLastName: members.lastName, instructorName: instructors.displayName, lessonTypeName: lessonTypes.name, }) .from(lessonSessions) .leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id)) .leftJoin(members, eq(enrollments.memberId, members.id)) .leftJoin(instructors, eq(enrollments.instructorId, instructors.id)) .leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id)) .leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id)) .where(where) .$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, lessonSessions.scheduledDate) query = withPagination(query, params.page, params.limit) const [rows, [{ total }]] = await Promise.all([ query, db.select({ total: count() }) .from(lessonSessions) .leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id)) .where(where), ]) const data = rows.map((r) => ({ ...r, memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined, })) return paginatedResponse(data, total, params.page, params.limit) }, async update(db: PostgresJsDatabase, 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, 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, id: string, input: LessonSessionNotesInput) { const existing = await LessonSessionService.getById(db, id) if (!existing) return null const updates: Record = { ...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, 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, 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, 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 = { 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) { 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() 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, 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, 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, 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, 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() 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, 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 = { 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, 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 LessonPlanTemplateService = { async create(db: PostgresJsDatabase, 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, 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() 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, 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 = { 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, 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, 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, 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, lessonPlanItemId: string, input: GradeCreateInput, gradedBy?: string) { // Get the item to check current status and validate grading scale const [item] = await db .select() .from(lessonPlanItems) .where(eq(lessonPlanItems.id, lessonPlanItemId)) .limit(1) if (!item) return null // Create the grade history record const [record] = await db .insert(lessonPlanItemGradeHistory) .values({ lessonPlanItemId, gradingScaleId: input.gradingScaleId, gradeValue: input.gradeValue, gradedBy, sessionId: input.sessionId, notes: input.notes, }) .returning() // Update the item's current grade and auto-transition status const updates: Record = { currentGradeValue: input.gradeValue, updatedAt: new Date(), } if (input.gradingScaleId) updates.gradingScaleId = input.gradingScaleId if (item.status === 'not_started') { updates.status = 'in_progress' updates.startedDate = new Date().toISOString().slice(0, 10) } const [updatedItem] = await db .update(lessonPlanItems) .set(updates) .where(eq(lessonPlanItems.id, lessonPlanItemId)) .returning() return { record, item: updatedItem } }, async list(db: PostgresJsDatabase, lessonPlanItemId: string) { return db .select() .from(lessonPlanItemGradeHistory) .where(eq(lessonPlanItemGradeHistory.lessonPlanItemId, lessonPlanItemId)) .orderBy(lessonPlanItemGradeHistory.createdAt) }, } export const SessionPlanItemService = { async linkItems(db: PostgresJsDatabase, sessionId: string, input: SessionPlanItemsInput) { // Verify session exists const [session] = await db .select() .from(lessonSessions) .where(eq(lessonSessions.id, sessionId)) .limit(1) if (!session) return null // Find already-linked item ids to avoid duplicates const existing = await db .select({ lessonPlanItemId: lessonSessionPlanItems.lessonPlanItemId }) .from(lessonSessionPlanItems) .where(eq(lessonSessionPlanItems.sessionId, sessionId)) const existingIds = new Set(existing.map((r) => r.lessonPlanItemId)) const newIds = input.lessonPlanItemIds.filter((id) => !existingIds.has(id)) if (newIds.length === 0) return [] // Auto-transition any not_started items to in_progress if (newIds.length > 0) { const today = new Date().toISOString().slice(0, 10) await db .update(lessonPlanItems) .set({ status: 'in_progress', startedDate: today, updatedAt: new Date() }) .where( and( inArray(lessonPlanItems.id, newIds), eq(lessonPlanItems.status, 'not_started'), ), ) } const rows = newIds.map((id) => ({ sessionId, lessonPlanItemId: id })) const created = await db.insert(lessonSessionPlanItems).values(rows).returning() return created }, async listForSession(db: PostgresJsDatabase, sessionId: string) { return db .select() .from(lessonSessionPlanItems) .where(eq(lessonSessionPlanItems.sessionId, sessionId)) }, } export const InstructorBlockedDateService = { async create(db: PostgresJsDatabase, 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, instructorId: string) { return db .select() .from(instructorBlockedDates) .where(eq(instructorBlockedDates.instructorId, instructorId)) .orderBy(instructorBlockedDates.startDate) }, async delete(db: PostgresJsDatabase, 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, 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) { return db .select() .from(storeClosures) .orderBy(storeClosures.startDate) }, async delete(db: PostgresJsDatabase, id: string) { const [row] = await db .delete(storeClosures) .where(eq(storeClosures.id, id)) .returning() return row ?? null }, } export const LessonPlanItemService = { async update(db: PostgresJsDatabase, 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 = { ...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 }, }