diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index d51f82f..ea87671 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -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')) + }) }) diff --git a/packages/backend/src/db/migrations/0031_lesson_sessions.sql b/packages/backend/src/db/migrations/0031_lesson_sessions.sql new file mode 100644 index 0000000..c02d8ea --- /dev/null +++ b/packages/backend/src/db/migrations/0031_lesson_sessions.sql @@ -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() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index e5056e4..bff9db4 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1774900000000, "tag": "0030_enrollments", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1774910000000, + "tag": "0031_lesson_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/lessons.ts b/packages/backend/src/db/schema/lessons.ts index f16db90..8f5522b 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -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 diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index e9143da..7ffe084 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -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 + 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 + 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) + }) } diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index e5de401..227cfa7 100644 --- a/packages/backend/src/services/lesson.service.ts +++ b/packages/backend/src/services/lesson.service.ts @@ -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, 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, id: string) { + const [session] = await db + .select() + .from(lessonSessions) + .where(eq(lessonSessions.id, id)) + .limit(1) + return session ?? null + }, + + async list(db: PostgresJsDatabase, 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 = { + 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, 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, 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, id: string, input: LessonSessionNotesInput) { + const existing = await LessonSessionService.getById(db, id) + if (!existing) return null + + const updates: Record = { ...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 + }, +} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 877bdff..c3b659b 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -112,6 +112,10 @@ export { EnrollmentCreateSchema, EnrollmentUpdateSchema, EnrollmentStatusUpdateSchema, + LessonSessionStatus, + LessonSessionStatusUpdateSchema, + LessonSessionNotesSchema, + LessonSessionUpdateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -123,4 +127,7 @@ export type { EnrollmentCreateInput, EnrollmentUpdateInput, EnrollmentStatusUpdateInput, + LessonSessionStatusUpdateInput, + LessonSessionNotesInput, + LessonSessionUpdateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index 53580b4..48eb968 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -81,3 +81,28 @@ export const EnrollmentStatusUpdateSchema = z.object({ status: EnrollmentStatus, }) export type EnrollmentStatusUpdateInput = z.infer + +// --- Lesson Session schemas --- + +export const LessonSessionStatus = z.enum(['scheduled', 'attended', 'missed', 'makeup', 'cancelled']) +export type LessonSessionStatus = z.infer + +export const LessonSessionStatusUpdateSchema = z.object({ + status: LessonSessionStatus, +}) +export type LessonSessionStatusUpdateInput = z.infer + +export const LessonSessionNotesSchema = z.object({ + instructorNotes: opt(z.string()), + memberNotes: opt(z.string()), + homeworkAssigned: opt(z.string()), + nextLessonGoals: opt(z.string()), + topicsCovered: z.array(z.string()).optional(), +}) +export type LessonSessionNotesInput = z.infer + +export const LessonSessionUpdateSchema = z.object({ + actualStartTime: opt(z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')), + actualEndTime: opt(z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')), +}) +export type LessonSessionUpdateInput = z.infer