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:
@@ -686,4 +686,257 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.every((e: any) => e.status === 'active'))
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Generation ───
|
||||
|
||||
t.test('generates sessions for an enrollment', { tags: ['sessions', 'generate'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Session Gen Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Session', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Session Gen Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Session Gen Type', durationMinutes: 30 })
|
||||
// Use Tuesday (2) for predictable scheduling
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, startTime: '16:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.generated >= 1, 'should generate at least 1 session')
|
||||
t.assert.equal(res.data.sessions.length, res.data.generated)
|
||||
// All sessions should be on Tuesday
|
||||
for (const s of res.data.sessions) {
|
||||
t.assert.equal(s.status, 'scheduled')
|
||||
t.assert.equal(s.scheduledTime, '16:00:00')
|
||||
const dayOfWeek = new Date(s.scheduledDate + 'T00:00:00').getDay()
|
||||
t.assert.equal(dayOfWeek, 2, `session date ${s.scheduledDate} should be Tuesday`)
|
||||
}
|
||||
})
|
||||
|
||||
t.test('session generation is idempotent', { tags: ['sessions', 'generate'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Idempotent Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Idemp', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Idempotent Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Idempotent Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '14:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
|
||||
const first = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.ok(first.data.generated >= 1)
|
||||
|
||||
const second = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
t.assert.equal(second.data.generated, 0, 'second call should generate 0 new sessions')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: CRUD ───
|
||||
|
||||
t.test('gets lesson session by id', { tags: ['sessions', 'read'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Get Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Get', lastName: 'Session' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Get Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Get Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '10:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
t.assert.ok(gen.data.sessions.length >= 1)
|
||||
|
||||
const res = await t.api.get(`/v1/lesson-sessions/${gen.data.sessions[0].id}`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.enrollmentId, enrollment.data.id)
|
||||
t.assert.equal(res.data.status, 'scheduled')
|
||||
})
|
||||
|
||||
t.test('returns 404 for missing lesson session', { tags: ['sessions', 'read'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions/a0000000-0000-0000-0000-999999999999')
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('lists lesson sessions with filters', { tags: ['sessions', 'read', 'filter'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'List Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'List', lastName: 'Session' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'List Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'List Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 5, startTime: '15:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=4`)
|
||||
|
||||
const res = await t.api.get('/v1/lesson-sessions', { enrollmentId: enrollment.data.id, limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.length >= 1)
|
||||
t.assert.ok(res.data.data.every((s: any) => s.enrollmentId === enrollment.data.id))
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Status ───
|
||||
|
||||
t.test('marks a session as attended', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Attend Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Attend', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Attend Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Attend Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 1, startTime: '09:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'attended' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'attended')
|
||||
})
|
||||
|
||||
t.test('marks a session as missed', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Missed Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Missed', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Missed Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Missed Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '11:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'missed' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'missed')
|
||||
})
|
||||
|
||||
t.test('cancels a session', { tags: ['sessions', 'status'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Cancel Session Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Cancel', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Cancel Session Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Cancel Session Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 6, startTime: '13:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${gen.data.sessions[0].id}/status`, { status: 'cancelled' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.status, 'cancelled')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Notes ───
|
||||
|
||||
t.test('saves post-lesson notes and auto-sets notesCompletedAt', { tags: ['sessions', 'notes'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Notes Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Notes', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Notes Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Notes Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, startTime: '16:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
const sessionId = gen.data.sessions[0].id
|
||||
|
||||
// Mark as attended first
|
||||
await t.api.post(`/v1/lesson-sessions/${sessionId}/status`, { status: 'attended' })
|
||||
|
||||
const res = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, {
|
||||
instructorNotes: 'Emma was distracted today',
|
||||
memberNotes: 'Great focus on scales. Left hand needs work.',
|
||||
homeworkAssigned: 'Practice Fur Elise bars 1-8 hands together',
|
||||
nextLessonGoals: 'Start bars 9-16',
|
||||
topicsCovered: ['Fur Elise', 'C Major Scale'],
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.instructorNotes, 'Emma was distracted today')
|
||||
t.assert.equal(res.data.memberNotes, 'Great focus on scales. Left hand needs work.')
|
||||
t.assert.equal(res.data.homeworkAssigned, 'Practice Fur Elise bars 1-8 hands together')
|
||||
t.assert.equal(res.data.topicsCovered.length, 2)
|
||||
t.assert.ok(res.data.notesCompletedAt, 'notesCompletedAt should be auto-set')
|
||||
})
|
||||
|
||||
t.test('second notes save does not reset notesCompletedAt', { tags: ['sessions', 'notes'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Notes Idempotent Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Idemp', lastName: 'Notes' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Notes Idemp Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Notes Idemp Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 4, startTime: '11:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
const sessionId = gen.data.sessions[0].id
|
||||
|
||||
const first = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, { instructorNotes: 'First notes' })
|
||||
t.assert.ok(first.data.notesCompletedAt)
|
||||
const originalTimestamp = first.data.notesCompletedAt
|
||||
|
||||
const second = await t.api.post(`/v1/lesson-sessions/${sessionId}/notes`, { memberNotes: 'Updated member notes' })
|
||||
t.assert.equal(second.data.notesCompletedAt, originalTimestamp, 'notesCompletedAt should not change')
|
||||
t.assert.equal(second.data.memberNotes, 'Updated member notes')
|
||||
})
|
||||
|
||||
// ─── Lesson Sessions: Update ───
|
||||
|
||||
t.test('updates actual start/end times', { tags: ['sessions', 'update'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Times Account', billingMode: 'consolidated' })
|
||||
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Times', lastName: 'Student' })
|
||||
const instructor = await t.api.post('/v1/instructors', { displayName: 'Times Instructor' })
|
||||
const lt = await t.api.post('/v1/lesson-types', { name: 'Times Type', durationMinutes: 30 })
|
||||
const slot = await t.api.post('/v1/schedule-slots', {
|
||||
instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, startTime: '15:00',
|
||||
})
|
||||
const enrollment = await t.api.post('/v1/enrollments', {
|
||||
memberId: member.data.id, accountId: acct.data.id,
|
||||
scheduleSlotId: slot.data.id, instructorId: instructor.data.id, startDate: '2026-01-01',
|
||||
})
|
||||
const gen = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`)
|
||||
|
||||
const res = await t.api.patch(`/v1/lesson-sessions/${gen.data.sessions[0].id}`, {
|
||||
actualStartTime: '15:05',
|
||||
actualEndTime: '15:32',
|
||||
})
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.actualStartTime, '15:05:00')
|
||||
t.assert.equal(res.data.actualEndTime, '15:32:00')
|
||||
})
|
||||
|
||||
t.test('filters sessions by date range', { tags: ['sessions', 'filter'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions', { dateFrom: '2026-01-01', dateTo: '2026-12-31', limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
t.test('filters sessions by status', { tags: ['sessions', 'filter'] }, async () => {
|
||||
const res = await t.api.get('/v1/lesson-sessions', { status: 'scheduled', limit: 100 })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.ok(res.data.data.every((s: any) => s.status === 'scheduled'))
|
||||
})
|
||||
})
|
||||
|
||||
22
packages/backend/src/db/migrations/0031_lesson_sessions.sql
Normal file
22
packages/backend/src/db/migrations/0031_lesson_sessions.sql
Normal 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()
|
||||
);
|
||||
@@ -218,6 +218,13 @@
|
||||
"when": 1774900000000,
|
||||
"tag": "0030_enrollments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1774910000000,
|
||||
"tag": "0031_lesson_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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