Files
lunarfront-app/packages/backend/src/services/lesson.service.ts
Ryan Moon 5ad27bc196 Add lessons module, rate cycles, EC2 deploy scripts, and help content
- Lessons module: lesson types, instructors, schedule slots, enrollments,
  sessions (list + week grid view), lesson plans, grading scales, templates
- Rate cycles: replace monthly_rate with billing_interval + billing_unit on
  enrollments; add weekly/monthly/quarterly rate presets to lesson types and
  schedule slots with auto-fill on enrollment form
- Member detail page: tabbed layout for details, identity documents, enrollments
- Sessions week view: custom 7-column grid replacing react-big-calendar
- Music store seed: instructors, lesson types, slots, enrollments, sessions,
  grading scale, lesson plan template
- Scrollbar styling: themed to match sidebar/app palette
- deploy/: EC2 setup and redeploy scripts, nginx config, systemd service
- Help: add Lessons category (overview, types, instructors, slots, enrollments,
  sessions, plans/grading); collapsible sidebar with independent scroll;
  remove POS/accounting references from docs
2026-03-30 18:52:57 -05:00

1452 lines
47 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, 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<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,
rateWeekly: input.rateWeekly?.toString(),
rateMonthly: input.rateMonthly?.toString(),
rateQuarterly: input.rateQuarterly?.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.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<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,
rateWeekly: input.rateWeekly?.toString(),
rateMonthly: input.rateMonthly?.toString(),
rateQuarterly: input.rateQuarterly?.toString(),
})
.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,
rate: input.rate?.toString(),
billingInterval: input.billingInterval,
billingUnit: input.billingUnit,
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) {
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,
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<any>, id: string, input: EnrollmentUpdateInput) {
const values: Record<string, unknown> = { ...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<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) 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<string, Column> = {
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<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 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
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<string, unknown> = {
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<any>, lessonPlanItemId: string) {
return db
.select()
.from(lessonPlanItemGradeHistory)
.where(eq(lessonPlanItemGradeHistory.lessonPlanItemId, lessonPlanItemId))
.orderBy(lessonPlanItemGradeHistory.createdAt)
},
}
export const SessionPlanItemService = {
async linkItems(db: PostgresJsDatabase<any>, 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<any>, sessionId: string) {
return db
.select()
.from(lessonSessionPlanItems)
.where(eq(lessonSessionPlanItems.sessionId, sessionId))
},
}
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
},
}