- 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
1066 lines
34 KiB
TypeScript
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
|
|
},
|
|
}
|