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
This commit is contained in:
Ryan Moon
2026-03-30 10:29:13 -05:00
parent aae5a022a8
commit 5cd2d05983
8 changed files with 541 additions and 6 deletions

View File

@@ -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()
);

View File

@@ -239,6 +239,13 @@
"when": 1774930000000,
"tag": "0033_lesson_plans",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1774940000000,
"tag": "0034_blocked_dates",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

@@ -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<any>, 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<any>, 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<any>, 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<any>, instructorId: string) {
return db
.select()
.from(instructorBlockedDates)
.where(eq(instructorBlockedDates.instructorId, instructorId))
.orderBy(instructorBlockedDates.startDate)
},
async delete(db: PostgresJsDatabase<any>, 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<any>, 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<any>) {
return db
.select()
.from(storeClosures)
.orderBy(storeClosures.startDate)
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [row] = await db
.delete(storeClosures)
.where(eq(storeClosures.id, id))
.returning()
return row ?? null
},
}
export const LessonPlanItemService = {
async update(db: PostgresJsDatabase<any>, id: string, input: LessonPlanItemUpdateInput) {
const existing = await db