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

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