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:
@@ -1304,4 +1304,251 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
|
|||||||
t.assert.status(res, 200)
|
t.assert.status(res, 200)
|
||||||
t.assert.equal(res.data.title, 'After Update')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
21
packages/backend/src/db/migrations/0034_blocked_dates.sql
Normal file
21
packages/backend/src/db/migrations/0034_blocked_dates.sql
Normal 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()
|
||||||
|
);
|
||||||
@@ -239,6 +239,13 @@
|
|||||||
"when": 1774930000000,
|
"when": 1774930000000,
|
||||||
"tag": "0033_lesson_plans",
|
"tag": "0033_lesson_plans",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774940000000,
|
||||||
|
"tag": "0034_blocked_dates",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -115,11 +115,31 @@ export const lessonSessions = pgTable('lesson_session', {
|
|||||||
nextLessonGoals: text('next_lesson_goals'),
|
nextLessonGoals: text('next_lesson_goals'),
|
||||||
topicsCovered: text('topics_covered').array(),
|
topicsCovered: text('topics_covered').array(),
|
||||||
makeupForSessionId: uuid('makeup_for_session_id'),
|
makeupForSessionId: uuid('makeup_for_session_id'),
|
||||||
|
substituteInstructorId: uuid('substitute_instructor_id').references(() => instructors.id),
|
||||||
notesCompletedAt: timestamp('notes_completed_at', { withTimezone: true }),
|
notesCompletedAt: timestamp('notes_completed_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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', {
|
export const gradingScales = pgTable('grading_scale', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
name: varchar('name', { length: 255 }).notNull(),
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
@@ -210,6 +230,10 @@ export type Enrollment = typeof enrollments.$inferSelect
|
|||||||
export type EnrollmentInsert = typeof enrollments.$inferInsert
|
export type EnrollmentInsert = typeof enrollments.$inferInsert
|
||||||
export type LessonSession = typeof lessonSessions.$inferSelect
|
export type LessonSession = typeof lessonSessions.$inferSelect
|
||||||
export type LessonSessionInsert = typeof lessonSessions.$inferInsert
|
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 GradingScale = typeof gradingScales.$inferSelect
|
||||||
export type GradingScaleInsert = typeof gradingScales.$inferInsert
|
export type GradingScaleInsert = typeof gradingScales.$inferInsert
|
||||||
export type GradingScaleLevel = typeof gradingScaleLevels.$inferSelect
|
export type GradingScaleLevel = typeof gradingScaleLevels.$inferSelect
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
LessonPlanCreateSchema,
|
LessonPlanCreateSchema,
|
||||||
LessonPlanUpdateSchema,
|
LessonPlanUpdateSchema,
|
||||||
LessonPlanItemUpdateSchema,
|
LessonPlanItemUpdateSchema,
|
||||||
|
InstructorBlockedDateCreateSchema,
|
||||||
|
StoreClosureCreateSchema,
|
||||||
} from '@lunarfront/shared/schemas'
|
} 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) => {
|
export const lessonRoutes: FastifyPluginAsync = async (app) => {
|
||||||
// --- Instructors ---
|
// --- Instructors ---
|
||||||
@@ -252,9 +254,10 @@ export const lessonRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
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)
|
const result = await LessonSessionService.update(app.db, id, parsed.data)
|
||||||
if (!session) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
|
if (!result) return reply.status(404).send({ error: { message: 'Lesson session not found', statusCode: 404 } })
|
||||||
return reply.send(session)
|
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) => {
|
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 } })
|
if (!item) return reply.status(404).send({ error: { message: 'Lesson plan item not found', statusCode: 404 } })
|
||||||
return reply.send(item)
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, ne, count, gte, lte, inArray, 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 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 {
|
import type {
|
||||||
InstructorCreateInput,
|
InstructorCreateInput,
|
||||||
InstructorUpdateInput,
|
InstructorUpdateInput,
|
||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
LessonPlanCreateInput,
|
LessonPlanCreateInput,
|
||||||
LessonPlanUpdateInput,
|
LessonPlanUpdateInput,
|
||||||
LessonPlanItemUpdateInput,
|
LessonPlanItemUpdateInput,
|
||||||
|
InstructorBlockedDateCreateInput,
|
||||||
|
StoreClosureCreateInput,
|
||||||
PaginationInput,
|
PaginationInput,
|
||||||
} from '@lunarfront/shared/schemas'
|
} from '@lunarfront/shared/schemas'
|
||||||
import {
|
import {
|
||||||
@@ -438,6 +440,7 @@ export const LessonSessionService = {
|
|||||||
/**
|
/**
|
||||||
* Generate sessions for an enrollment within a rolling window.
|
* Generate sessions for an enrollment within a rolling window.
|
||||||
* Idempotent: skips dates that already have a session.
|
* 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) {
|
async generateSessions(db: PostgresJsDatabase<any>, enrollmentId: string, windowWeeks = 4) {
|
||||||
const enrollment = await EnrollmentService.getById(db, enrollmentId)
|
const enrollment = await EnrollmentService.getById(db, enrollmentId)
|
||||||
@@ -457,6 +460,39 @@ export const LessonSessionService = {
|
|||||||
const dates = generateDatesForDay(slot.dayOfWeek, fromDate, toDate)
|
const dates = generateDatesForDay(slot.dayOfWeek, fromDate, toDate)
|
||||||
if (dates.length === 0) return []
|
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
|
// Find existing sessions to avoid duplicates
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ scheduledDate: lessonSessions.scheduledDate })
|
.select({ scheduledDate: lessonSessions.scheduledDate })
|
||||||
@@ -470,7 +506,7 @@ export const LessonSessionService = {
|
|||||||
)
|
)
|
||||||
const existingDates = new Set(existing.map((e) => e.scheduledDate))
|
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 []
|
if (newDates.length === 0) return []
|
||||||
|
|
||||||
const rows = newDates.map((d) => ({
|
const rows = newDates.map((d) => ({
|
||||||
@@ -547,6 +583,53 @@ export const LessonSessionService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async update(db: PostgresJsDatabase<any>, id: string, input: LessonSessionUpdateInput) {
|
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
|
const [session] = await db
|
||||||
.update(lessonSessions)
|
.update(lessonSessions)
|
||||||
.set({ ...input, updatedAt: new Date() })
|
.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 = {
|
export const LessonPlanItemService = {
|
||||||
async update(db: PostgresJsDatabase<any>, id: string, input: LessonPlanItemUpdateInput) {
|
async update(db: PostgresJsDatabase<any>, id: string, input: LessonPlanItemUpdateInput) {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export {
|
|||||||
LessonPlanCreateSchema,
|
LessonPlanCreateSchema,
|
||||||
LessonPlanUpdateSchema,
|
LessonPlanUpdateSchema,
|
||||||
LessonPlanItemUpdateSchema,
|
LessonPlanItemUpdateSchema,
|
||||||
|
InstructorBlockedDateCreateSchema,
|
||||||
|
StoreClosureCreateSchema,
|
||||||
} from './lessons.schema.js'
|
} from './lessons.schema.js'
|
||||||
export type {
|
export type {
|
||||||
InstructorCreateInput,
|
InstructorCreateInput,
|
||||||
@@ -141,4 +143,6 @@ export type {
|
|||||||
LessonPlanCreateInput,
|
LessonPlanCreateInput,
|
||||||
LessonPlanUpdateInput,
|
LessonPlanUpdateInput,
|
||||||
LessonPlanItemUpdateInput,
|
LessonPlanItemUpdateInput,
|
||||||
|
InstructorBlockedDateCreateInput,
|
||||||
|
StoreClosureCreateInput,
|
||||||
} from './lessons.schema.js'
|
} from './lessons.schema.js'
|
||||||
|
|||||||
@@ -104,9 +104,28 @@ export type LessonSessionNotesInput = z.infer<typeof LessonSessionNotesSchema>
|
|||||||
export const LessonSessionUpdateSchema = z.object({
|
export const LessonSessionUpdateSchema = z.object({
|
||||||
actualStartTime: opt(z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')),
|
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')),
|
actualEndTime: opt(z.string().regex(/^\d{2}:\d{2}$/, 'Must be HH:MM format')),
|
||||||
|
substituteInstructorId: opt(z.string().uuid()),
|
||||||
})
|
})
|
||||||
export type LessonSessionUpdateInput = z.infer<typeof LessonSessionUpdateSchema>
|
export type LessonSessionUpdateInput = z.infer<typeof LessonSessionUpdateSchema>
|
||||||
|
|
||||||
|
// --- 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<typeof InstructorBlockedDateCreateSchema>
|
||||||
|
|
||||||
|
// --- 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<typeof StoreClosureCreateSchema>
|
||||||
|
|
||||||
// --- Grading Scale schemas ---
|
// --- Grading Scale schemas ---
|
||||||
|
|
||||||
export const GradingScaleLevelInput = z.object({
|
export const GradingScaleLevelInput = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user