- 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
1452 lines
47 KiB
TypeScript
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
|
|
},
|
|
}
|