From 5cd2d059834d8b0871942d88e769326d0e6700cc Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Mon, 30 Mar 2026 10:29:13 -0500 Subject: [PATCH] Add Phase 4b: instructor blocked dates, store closures, and substitute instructors - New tables: instructor_blocked_date, store_closure (migration 0034) - substitute_instructor_id column added to lesson_session - Session generation skips blocked instructor dates and store closure periods - Substitute assignment validates sub is not blocked and has no conflicting slot - Routes: POST/GET/DELETE /instructors/:id/blocked-dates, POST/GET/DELETE /store-closures - 15 new integration tests covering blocked dates, store closures, and sub validation --- packages/backend/api-tests/suites/lessons.ts | 247 ++++++++++++++++++ .../src/db/migrations/0034_blocked_dates.sql | 21 ++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/lessons.ts | 24 ++ packages/backend/src/routes/v1/lessons.ts | 65 ++++- .../backend/src/services/lesson.service.ts | 160 +++++++++++- packages/shared/src/schemas/index.ts | 4 + packages/shared/src/schemas/lessons.schema.ts | 19 ++ 8 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/db/migrations/0034_blocked_dates.sql diff --git a/packages/backend/api-tests/suites/lessons.ts b/packages/backend/api-tests/suites/lessons.ts index 16954e5..7cb2ad3 100644 --- a/packages/backend/api-tests/suites/lessons.ts +++ b/packages/backend/api-tests/suites/lessons.ts @@ -1304,4 +1304,251 @@ suite('Lessons', { tags: ['lessons'] }, (t) => { t.assert.status(res, 200) t.assert.equal(res.data.title, 'After Update') }) + + // ─── Instructor Blocked Dates ─── + + t.test('creates a blocked date for an instructor', { tags: ['blocked-dates', 'create'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Blocked Date Test Instructor' }) + const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { + startDate: '2026-07-04', + endDate: '2026-07-04', + reason: 'Holiday', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.instructorId, instructor.data.id) + t.assert.equal(res.data.startDate, '2026-07-04') + t.assert.equal(res.data.endDate, '2026-07-04') + t.assert.equal(res.data.reason, 'Holiday') + t.assert.ok(res.data.id) + }) + + t.test('creates a multi-day blocked date range', { tags: ['blocked-dates', 'create'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Vacation Test Instructor' }) + const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { + startDate: '2026-08-01', + endDate: '2026-08-07', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.startDate, '2026-08-01') + t.assert.equal(res.data.endDate, '2026-08-07') + t.assert.equal(res.data.reason, null) + }) + + t.test('rejects blocked date with end before start', { tags: ['blocked-dates', 'create', 'validation'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Invalid Date Instructor' }) + const res = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { + startDate: '2026-08-07', + endDate: '2026-08-01', + }) + t.assert.status(res, 409) + }) + + t.test('returns 404 for blocked date on missing instructor', { tags: ['blocked-dates', 'create'] }, async () => { + const res = await t.api.post('/v1/instructors/a0000000-0000-0000-0000-999999999999/blocked-dates', { + startDate: '2026-07-04', + endDate: '2026-07-04', + }) + t.assert.status(res, 404) + }) + + t.test('lists blocked dates for an instructor', { tags: ['blocked-dates', 'read'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'List Blocked Dates Instructor' }) + await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { startDate: '2026-07-04', endDate: '2026-07-04' }) + await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { startDate: '2026-08-01', endDate: '2026-08-07' }) + + const res = await t.api.get(`/v1/instructors/${instructor.data.id}/blocked-dates`) + t.assert.status(res, 200) + t.assert.equal(res.data.length, 2) + }) + + t.test('deletes a blocked date', { tags: ['blocked-dates', 'delete'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Delete Blocked Date Instructor' }) + const blocked = await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { + startDate: '2026-07-04', + endDate: '2026-07-04', + }) + const res = await t.api.del(`/v1/instructors/${instructor.data.id}/blocked-dates/${blocked.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.id, blocked.data.id) + + const list = await t.api.get(`/v1/instructors/${instructor.data.id}/blocked-dates`) + t.assert.equal(list.data.length, 0) + }) + + t.test('session generation skips instructor blocked dates', { tags: ['blocked-dates', 'sessions', 'generate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Blocked Gen Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Blocked Gen Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Blocked Gen Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Blocked', lastName: 'Gen' }) + + // Find next Sunday (dayOfWeek=0) from today + const today = new Date() + const daysUntilSunday = (7 - today.getDay()) % 7 || 7 + const nextSunday = new Date(today) + nextSunday.setDate(today.getDate() + daysUntilSunday) + const sundayStr = nextSunday.toISOString().slice(0, 10) + + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 0, startTime: '10:00', + }) + + // Block the first upcoming Sunday + await t.api.post(`/v1/instructors/${instructor.data.id}/blocked-dates`, { + startDate: sundayStr, + endDate: sundayStr, + }) + + 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: sundayStr, + }) + + const res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`) + t.assert.status(res, 200) + + // The blocked Sunday should not appear in generated sessions + const sessions: Array<{ scheduledDate: string }> = res.data.sessions + const hasBlockedDate = sessions.some((s) => s.scheduledDate === sundayStr) + t.assert.equal(hasBlockedDate, false) + }) + + // ─── Store Closures ─── + + t.test('creates a store closure', { tags: ['store-closures', 'create'] }, async () => { + const res = await t.api.post('/v1/store-closures', { + name: 'Christmas Break', + startDate: '2026-12-24', + endDate: '2026-12-26', + }) + t.assert.status(res, 201) + t.assert.equal(res.data.name, 'Christmas Break') + t.assert.equal(res.data.startDate, '2026-12-24') + t.assert.equal(res.data.endDate, '2026-12-26') + t.assert.ok(res.data.id) + }) + + t.test('rejects store closure with end before start', { tags: ['store-closures', 'create', 'validation'] }, async () => { + const res = await t.api.post('/v1/store-closures', { + name: 'Invalid', + startDate: '2026-12-26', + endDate: '2026-12-24', + }) + t.assert.status(res, 409) + }) + + t.test('lists store closures', { tags: ['store-closures', 'read'] }, async () => { + await t.api.post('/v1/store-closures', { name: 'Closure A', startDate: '2026-11-01', endDate: '2026-11-01' }) + await t.api.post('/v1/store-closures', { name: 'Closure B', startDate: '2026-11-15', endDate: '2026-11-15' }) + + const res = await t.api.get('/v1/store-closures') + t.assert.status(res, 200) + t.assert.ok(Array.isArray(res.data)) + t.assert.ok(res.data.length >= 2) + }) + + t.test('deletes a store closure', { tags: ['store-closures', 'delete'] }, async () => { + const created = await t.api.post('/v1/store-closures', { + name: 'To Delete', + startDate: '2026-10-01', + endDate: '2026-10-01', + }) + const res = await t.api.del(`/v1/store-closures/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.id, created.data.id) + }) + + t.test('session generation skips store closures', { tags: ['store-closures', 'sessions', 'generate'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Closure Gen Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Closure Gen Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Closure Gen Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Closure', lastName: 'Gen' }) + + // Find next Monday (dayOfWeek=1) from today + const today = new Date() + const daysUntilMonday = (8 - today.getDay()) % 7 || 7 + const nextMonday = new Date(today) + nextMonday.setDate(today.getDate() + daysUntilMonday) + const mondayStr = nextMonday.toISOString().slice(0, 10) + + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 1, startTime: '11:00', + }) + + // Create a store closure covering the first upcoming Monday + const closure = await t.api.post('/v1/store-closures', { + name: 'Test Closure', + startDate: mondayStr, + endDate: mondayStr, + }) + + 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: mondayStr, + }) + + const res = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions?weeks=2`) + t.assert.status(res, 200) + + const sessions: Array<{ scheduledDate: string }> = res.data.sessions + const hasClosedDate = sessions.some((s) => s.scheduledDate === mondayStr) + t.assert.equal(hasClosedDate, false) + + // Clean up closure so it doesn't affect other tests + await t.api.del(`/v1/store-closures/${closure.data.id}`) + }) + + // ─── Substitute Instructor ─── + + t.test('assigns a substitute instructor to a session', { tags: ['sessions', 'substitute'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Primary Sub Instructor' }) + const sub = await t.api.post('/v1/instructors', { displayName: 'Substitute Instructor' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Sub Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Sub Test Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Sub', lastName: 'Student' }) + + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 2, 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 genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`) + const session = genRes.data.sessions[0] + + const res = await t.api.patch(`/v1/lesson-sessions/${session.id}`, { + substituteInstructorId: sub.data.id, + }) + t.assert.status(res, 200) + t.assert.equal(res.data.substituteInstructorId, sub.data.id) + }) + + t.test('rejects substitute who is blocked on the session date', { tags: ['sessions', 'substitute', 'validation'] }, async () => { + const instructor = await t.api.post('/v1/instructors', { displayName: 'Primary Blocked Sub Test' }) + const sub = await t.api.post('/v1/instructors', { displayName: 'Blocked Sub' }) + const lt = await t.api.post('/v1/lesson-types', { name: 'Blocked Sub Type', durationMinutes: 30 }) + const acct = await t.api.post('/v1/accounts', { name: 'Blocked Sub Account', billingMode: 'consolidated' }) + const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, { firstName: 'Blocked', lastName: 'SubStudent' }) + + const slot = await t.api.post('/v1/schedule-slots', { + instructorId: instructor.data.id, lessonTypeId: lt.data.id, dayOfWeek: 3, 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 genRes = await t.api.post(`/v1/enrollments/${enrollment.data.id}/generate-sessions`) + const session = genRes.data.sessions[0] + + // Block the sub on the session date + await t.api.post(`/v1/instructors/${sub.data.id}/blocked-dates`, { + startDate: session.scheduledDate, + endDate: session.scheduledDate, + }) + + const res = await t.api.patch(`/v1/lesson-sessions/${session.id}`, { + substituteInstructorId: sub.data.id, + }) + t.assert.status(res, 409) + }) }) diff --git a/packages/backend/src/db/migrations/0034_blocked_dates.sql b/packages/backend/src/db/migrations/0034_blocked_dates.sql new file mode 100644 index 0000000..b65a43a --- /dev/null +++ b/packages/backend/src/db/migrations/0034_blocked_dates.sql @@ -0,0 +1,21 @@ +-- Phase 4b: Instructor blocked dates, store closures, and substitute instructor on sessions + +ALTER TABLE "lesson_session" + ADD COLUMN "substitute_instructor_id" uuid REFERENCES "instructor"("id"); + +CREATE TABLE "instructor_blocked_date" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "instructor_id" uuid NOT NULL REFERENCES "instructor"("id"), + "start_date" date NOT NULL, + "end_date" date NOT NULL, + "reason" varchar(255), + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE "store_closure" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "name" varchar(255) NOT NULL, + "start_date" date NOT NULL, + "end_date" date NOT NULL, + "created_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 195ad1c..483f985 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1774930000000, "tag": "0033_lesson_plans", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1774940000000, + "tag": "0034_blocked_dates", + "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 814934b..33e2aba 100644 --- a/packages/backend/src/db/schema/lessons.ts +++ b/packages/backend/src/db/schema/lessons.ts @@ -115,11 +115,31 @@ export const lessonSessions = pgTable('lesson_session', { nextLessonGoals: text('next_lesson_goals'), topicsCovered: text('topics_covered').array(), makeupForSessionId: uuid('makeup_for_session_id'), + substituteInstructorId: uuid('substitute_instructor_id').references(() => instructors.id), notesCompletedAt: timestamp('notes_completed_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }) +export const instructorBlockedDates = pgTable('instructor_blocked_date', { + id: uuid('id').primaryKey().defaultRandom(), + instructorId: uuid('instructor_id') + .notNull() + .references(() => instructors.id), + startDate: date('start_date').notNull(), + endDate: date('end_date').notNull(), + reason: varchar('reason', { length: 255 }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export const storeClosures = pgTable('store_closure', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 255 }).notNull(), + startDate: date('start_date').notNull(), + endDate: date('end_date').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + export const gradingScales = pgTable('grading_scale', { id: uuid('id').primaryKey().defaultRandom(), name: varchar('name', { length: 255 }).notNull(), @@ -210,6 +230,10 @@ export type Enrollment = typeof enrollments.$inferSelect export type EnrollmentInsert = typeof enrollments.$inferInsert export type LessonSession = typeof lessonSessions.$inferSelect export type LessonSessionInsert = typeof lessonSessions.$inferInsert +export type InstructorBlockedDate = typeof instructorBlockedDates.$inferSelect +export type InstructorBlockedDateInsert = typeof instructorBlockedDates.$inferInsert +export type StoreClosure = typeof storeClosures.$inferSelect +export type StoreClosureInsert = typeof storeClosures.$inferInsert export type GradingScale = typeof gradingScales.$inferSelect export type GradingScaleInsert = typeof gradingScales.$inferInsert export type GradingScaleLevel = typeof gradingScaleLevels.$inferSelect diff --git a/packages/backend/src/routes/v1/lessons.ts b/packages/backend/src/routes/v1/lessons.ts index 252a405..6133f07 100644 --- a/packages/backend/src/routes/v1/lessons.ts +++ b/packages/backend/src/routes/v1/lessons.ts @@ -18,8 +18,10 @@ import { LessonPlanCreateSchema, LessonPlanUpdateSchema, LessonPlanItemUpdateSchema, + InstructorBlockedDateCreateSchema, + StoreClosureCreateSchema, } from '@lunarfront/shared/schemas' -import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService } from '../../services/lesson.service.js' +import { InstructorService, LessonTypeService, ScheduleSlotService, EnrollmentService, LessonSessionService, GradingScaleService, LessonPlanService, LessonPlanItemService, InstructorBlockedDateService, StoreClosureService } from '../../services/lesson.service.js' export const lessonRoutes: FastifyPluginAsync = async (app) => { // --- Instructors --- @@ -252,9 +254,10 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => { 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) + const result = await LessonSessionService.update(app.db, id, parsed.data) + if (!result) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } }) + if ('error' in result) return reply.status(409).send({ error: { message: result.error, statusCode: 409 } }) + return reply.send(result) }) app.post('/lesson-sessions/:id/status', { preHandler: [app.authenticate, app.requirePermission('lessons.edit')] }, async (request, reply) => { @@ -379,4 +382,58 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => { if (!item) return reply.status(404).send({ error: { message: 'Lesson plan item not found', statusCode: 404 } }) return reply.send(item) }) + + // --- Instructor Blocked Dates --- + + app.post('/instructors/:id/blocked-dates', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = InstructorBlockedDateCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const instructor = await InstructorService.getById(app.db, id) + if (!instructor) return reply.status(404).send({ error: { message: 'Instructor not found', statusCode: 404 } }) + const result = await InstructorBlockedDateService.create(app.db, id, parsed.data) + if ('error' in result) return reply.status(409).send({ error: { message: result.error, statusCode: 409 } }) + return reply.status(201).send(result) + }) + + app.get('/instructors/:id/blocked-dates', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const instructor = await InstructorService.getById(app.db, id) + if (!instructor) return reply.status(404).send({ error: { message: 'Instructor not found', statusCode: 404 } }) + const dates = await InstructorBlockedDateService.list(app.db, id) + return reply.send(dates) + }) + + app.delete('/instructors/:instructorId/blocked-dates/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { instructorId, id } = request.params as { instructorId: string; id: string } + const row = await InstructorBlockedDateService.delete(app.db, id, instructorId) + if (!row) return reply.status(404).send({ error: { message: 'Blocked date not found', statusCode: 404 } }) + return reply.send(row) + }) + + // --- Store Closures --- + + app.post('/store-closures', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const parsed = StoreClosureCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const result = await StoreClosureService.create(app.db, parsed.data) + if ('error' in result) return reply.status(409).send({ error: { message: result.error, statusCode: 409 } }) + return reply.status(201).send(result) + }) + + app.get('/store-closures', { preHandler: [app.authenticate, app.requirePermission('lessons.view')] }, async (_request, reply) => { + const closures = await StoreClosureService.list(app.db) + return reply.send(closures) + }) + + app.delete('/store-closures/:id', { preHandler: [app.authenticate, app.requirePermission('lessons.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const row = await StoreClosureService.delete(app.db, id) + if (!row) return reply.status(404).send({ error: { message: 'Store closure not found', statusCode: 404 } }) + return reply.send(row) + }) } diff --git a/packages/backend/src/services/lesson.service.ts b/packages/backend/src/services/lesson.service.ts index 05cf70e..1508982 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, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' -import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems } from '../db/schema/lessons.js' +import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems } from '../db/schema/lessons.js' import type { InstructorCreateInput, InstructorUpdateInput, @@ -17,6 +17,8 @@ import type { LessonPlanCreateInput, LessonPlanUpdateInput, LessonPlanItemUpdateInput, + InstructorBlockedDateCreateInput, + StoreClosureCreateInput, PaginationInput, } from '@lunarfront/shared/schemas' import { @@ -438,6 +440,7 @@ 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, enrollmentId: string, windowWeeks = 4) { const enrollment = await EnrollmentService.getById(db, enrollmentId) @@ -457,6 +460,39 @@ export const LessonSessionService = { 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 }) @@ -470,7 +506,7 @@ export const LessonSessionService = { ) const existingDates = new Set(existing.map((e) => e.scheduledDate)) - const newDates = dates.filter((d) => !existingDates.has(d)) + const newDates = dates.filter((d) => !existingDates.has(d) && !isBlocked(d)) if (newDates.length === 0) return [] const rows = newDates.map((d) => ({ @@ -547,6 +583,53 @@ export const LessonSessionService = { }, async update(db: PostgresJsDatabase, 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() }) @@ -874,6 +957,79 @@ export const LessonPlanService = { }, } +export const InstructorBlockedDateService = { + async create(db: PostgresJsDatabase, 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, instructorId: string) { + return db + .select() + .from(instructorBlockedDates) + .where(eq(instructorBlockedDates.instructorId, instructorId)) + .orderBy(instructorBlockedDates.startDate) + }, + + async delete(db: PostgresJsDatabase, 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, 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) { + return db + .select() + .from(storeClosures) + .orderBy(storeClosures.startDate) + }, + + async delete(db: PostgresJsDatabase, id: string) { + const [row] = await db + .delete(storeClosures) + .where(eq(storeClosures.id, id)) + .returning() + return row ?? null + }, +} + export const LessonPlanItemService = { async update(db: PostgresJsDatabase, id: string, input: LessonPlanItemUpdateInput) { const existing = await db diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index e2eadf0..de0a0fb 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -122,6 +122,8 @@ export { LessonPlanCreateSchema, LessonPlanUpdateSchema, LessonPlanItemUpdateSchema, + InstructorBlockedDateCreateSchema, + StoreClosureCreateSchema, } from './lessons.schema.js' export type { InstructorCreateInput, @@ -141,4 +143,6 @@ export type { LessonPlanCreateInput, LessonPlanUpdateInput, LessonPlanItemUpdateInput, + InstructorBlockedDateCreateInput, + StoreClosureCreateInput, } from './lessons.schema.js' diff --git a/packages/shared/src/schemas/lessons.schema.ts b/packages/shared/src/schemas/lessons.schema.ts index dca72fc..b6e80cf 100644 --- a/packages/shared/src/schemas/lessons.schema.ts +++ b/packages/shared/src/schemas/lessons.schema.ts @@ -104,9 +104,28 @@ 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')), + substituteInstructorId: opt(z.string().uuid()), }) export type LessonSessionUpdateInput = z.infer +// --- Instructor Blocked Date schemas --- + +export const InstructorBlockedDateCreateSchema = z.object({ + startDate: z.string().min(1), + endDate: z.string().min(1), + reason: opt(z.string().max(255)), +}) +export type InstructorBlockedDateCreateInput = z.infer + +// --- Store Closure schemas --- + +export const StoreClosureCreateSchema = z.object({ + name: z.string().min(1).max(255), + startDate: z.string().min(1), + endDate: z.string().min(1), +}) +export type StoreClosureCreateInput = z.infer + // --- Grading Scale schemas --- export const GradingScaleLevelInput = z.object({