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:
Ryan Moon
2026-03-30 09:29:03 -05:00
parent 93405af3b2
commit 73360cd478
8 changed files with 587 additions and 3 deletions

View File

@@ -0,0 +1,22 @@
-- Phase 4: Lesson sessions — individual lesson occurrences
CREATE TYPE "lesson_session_status" AS ENUM ('scheduled', 'attended', 'missed', 'makeup', 'cancelled');
CREATE TABLE "lesson_session" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"enrollment_id" uuid NOT NULL REFERENCES "enrollment"("id"),
"scheduled_date" date NOT NULL,
"scheduled_time" time NOT NULL,
"actual_start_time" time,
"actual_end_time" time,
"status" lesson_session_status NOT NULL DEFAULT 'scheduled',
"instructor_notes" text,
"member_notes" text,
"homework_assigned" text,
"next_lesson_goals" text,
"topics_covered" text[],
"makeup_for_session_id" uuid,
"notes_completed_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

View File

@@ -218,6 +218,13 @@
"when": 1774900000000,
"tag": "0030_enrollments",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1774910000000,
"tag": "0031_lesson_sessions",
"breakpoints": true
}
]
}

View File

@@ -91,6 +91,35 @@ export const enrollments = pgTable('enrollment', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const lessonSessionStatusEnum = pgEnum('lesson_session_status', [
'scheduled',
'attended',
'missed',
'makeup',
'cancelled',
])
export const lessonSessions = pgTable('lesson_session', {
id: uuid('id').primaryKey().defaultRandom(),
enrollmentId: uuid('enrollment_id')
.notNull()
.references(() => enrollments.id),
scheduledDate: date('scheduled_date').notNull(),
scheduledTime: time('scheduled_time').notNull(),
actualStartTime: time('actual_start_time'),
actualEndTime: time('actual_end_time'),
status: lessonSessionStatusEnum('status').notNull().default('scheduled'),
instructorNotes: text('instructor_notes'),
memberNotes: text('member_notes'),
homeworkAssigned: text('homework_assigned'),
nextLessonGoals: text('next_lesson_goals'),
topicsCovered: text('topics_covered').array(),
makeupForSessionId: uuid('makeup_for_session_id'),
notesCompletedAt: timestamp('notes_completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
// --- Type exports ---
export type Instructor = typeof instructors.$inferSelect
@@ -101,3 +130,5 @@ export type ScheduleSlot = typeof scheduleSlots.$inferSelect
export type ScheduleSlotInsert = typeof scheduleSlots.$inferInsert
export type Enrollment = typeof enrollments.$inferSelect
export type EnrollmentInsert = typeof enrollments.$inferInsert
export type LessonSession = typeof lessonSessions.$inferSelect
export type LessonSessionInsert = typeof lessonSessions.$inferInsert

View File

@@ -10,8 +10,11 @@ import {
EnrollmentCreateSchema,
EnrollmentUpdateSchema,
EnrollmentStatusUpdateSchema,
LessonSessionStatusUpdateSchema,
LessonSessionNotesSchema,
LessonSessionUpdateSchema,
} from '@lunarfront/shared/schemas'
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService } from '../../services/lesson.service.js'
import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService } from '../../services/lesson.service.js'
export const lessonRoutes: FastifyPluginAsync = async (app) => {
// --- Instructors ---
@@ -206,4 +209,68 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => {
if (!enrollment) return reply.status(404).send({ error: { message: 'Enrollment not found', statusCode: 404 } })
return reply.send(enrollment)
})
app.post('/enrollments/:id/generate-sessions', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const query = request.query as Record<string, string | undefined>
const weeks = query.weeks ? Number(query.weeks) : 4
const sessions = await LessonSessionService.generateSessions(app.db, id, weeks)
return reply.send({ generated: sessions.length, sessions })
})
// --- Lesson Sessions ---
app.get('/lesson-sessions', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const filters = {
enrollmentId: query.enrollmentId,
instructorId: query.instructorId,
status: query.status?.split(',').filter(Boolean),
dateFrom: query.dateFrom,
dateTo: query.dateTo,
}
const result = await LessonSessionService.list(app.db, params, filters)
return reply.send(result)
})
app.get('/lesson-sessions/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const session = await LessonSessionService.getById(app.db, id)
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
return reply.send(session)
})
app.patch('/lesson-sessions/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LessonSessionUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await LessonSessionService.update(app.db, id, parsed.data)
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
return reply.send(session)
})
app.post('/lesson-sessions/:id/status', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LessonSessionStatusUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await LessonSessionService.updateStatus(app.db, id, parsed.data.status)
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
return reply.send(session)
})
app.post('/lesson-sessions/:id/notes', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LessonSessionNotesSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await LessonSessionService.updateNotes(app.db, id, parsed.data)
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
return reply.send(session)
})
}

View File

@@ -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
},
}