Add lessons Phase 4: lesson sessions with hybrid calendar generation
Individual lesson occurrences generated from schedule slot patterns. Idempotent session generation with configurable rolling window. Post-lesson notes workflow with auto-set notesCompletedAt. Status tracking (scheduled/attended/missed/makeup/cancelled) and date/time filtering. 13 new tests (64 total lessons tests).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { eq, and, ne, count, type Column, type SQL } from 'drizzle-orm'
|
||||
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 } from '../db/schema/lessons.js'
|
||||
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions } from '../db/schema/lessons.js'
|
||||
import type {
|
||||
InstructorCreateInput,
|
||||
InstructorUpdateInput,
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
ScheduleSlotUpdateInput,
|
||||
EnrollmentCreateInput,
|
||||
EnrollmentUpdateInput,
|
||||
LessonSessionNotesInput,
|
||||
LessonSessionUpdateInput,
|
||||
PaginationInput,
|
||||
} from '@lunarfront/shared/schemas'
|
||||
import {
|
||||
@@ -405,3 +407,173 @@ export const EnrollmentService = {
|
||||
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.
|
||||
*/
|
||||
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 []
|
||||
|
||||
// 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))
|
||||
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) {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user