fix(server): preserve admin 'rest day' intent across cron runs
publishDaySlots used to DELETE the TimeSlot rows of a day that an admin just 'cleared', which made the nightly slot-generation cron resurrect the default schedule the next morning — a real production bug where classes would reappear on a teacher's rest day. Changes: - publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes the default template for days with no rows so an empty publish still records intent. update on existing rows preserves their current status, so re-publishing the page cannot silently reopen a CLOSED rest day. - SlotGeneratorService.generateSlots pre-fetches dates that already have any TimeSlot row and skips them, so the cron no longer overwrites admin-touched days even when admin only set status without changing times. - SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule above does not leak rows forever. - schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and card style. Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots behaviour) and slot-generator.service.spec.ts (skip touched dates + prune conditions). Full suite: 315 / 315 passing.
This commit is contained in:
@@ -160,6 +160,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||
import { TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from './stores/admin'
|
||||
@@ -181,6 +182,7 @@ interface EditableSlot {
|
||||
endTime: string
|
||||
capacity: number
|
||||
bookedCount: number
|
||||
status: TimeSlotStatus
|
||||
isPublished: boolean
|
||||
isNew: boolean
|
||||
isRemoved: boolean
|
||||
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
|
||||
endTime: p.endTime,
|
||||
capacity: p.capacity,
|
||||
bookedCount: p.bookedCount,
|
||||
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
|
||||
isPublished: p.isPublished,
|
||||
isNew: false,
|
||||
isRemoved: false,
|
||||
@@ -323,6 +326,7 @@ function submitAdd() {
|
||||
endTime: addForm.value.endTime,
|
||||
capacity,
|
||||
bookedCount: 0,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
isPublished: false,
|
||||
isNew: true,
|
||||
isRemoved: false,
|
||||
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
|
||||
// ── Style helpers ─────────────────────────────────────────
|
||||
|
||||
function slotCardClass(slot: EditableSlot): string {
|
||||
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
|
||||
if (slot.isNew) return 'slot-card--new'
|
||||
if (slot.isPublished) return 'slot-card--published'
|
||||
return 'slot-card--template'
|
||||
}
|
||||
|
||||
function slotBadgeClass(slot: EditableSlot): string {
|
||||
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
|
||||
if (slot.isNew) return 'badge--new'
|
||||
if (slot.isPublished) return 'badge--published'
|
||||
return 'badge--template'
|
||||
}
|
||||
|
||||
function slotBadgeText(slot: EditableSlot): string {
|
||||
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
if (slot.isNew) return '新增'
|
||||
if (slot.isPublished) return '已发布'
|
||||
return '默认时段'
|
||||
@@ -495,6 +502,12 @@ onMounted(() => {
|
||||
border-color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.04);
|
||||
}
|
||||
|
||||
&--closed {
|
||||
opacity: 0.55;
|
||||
background: #fafafa;
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Slot header ─────────────────────────── */
|
||||
@@ -516,6 +529,8 @@ onMounted(() => {
|
||||
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
|
||||
.badge--new { background: rgba(52, 152, 219, 0.1); }
|
||||
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
|
||||
.badge--closed { background: rgba(0, 0, 0, 0.06); }
|
||||
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
|
||||
|
||||
.booked-info { }
|
||||
.booked-text { font-size: 22rpx; color: #e67e22; }
|
||||
|
||||
@@ -32,6 +32,17 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 02:35 daily — delete past CLOSED slots with no bookings attached */
|
||||
@Cron('35 2 * * *')
|
||||
async handlePrunePastClosedSlots(): Promise<void> {
|
||||
try {
|
||||
const count = await this.slotGenerator.prunePastClosedSlots()
|
||||
this.logger.log(`[handlePrunePastClosedSlots] Pruned ${count} past closed slots`)
|
||||
} catch (err) {
|
||||
this.logger.error('[handlePrunePastClosedSlots] Failed to prune slots', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** 03:00 daily — expire memberships past their end date or with 0 sessions */
|
||||
@Cron('0 3 * * *')
|
||||
async handleCheckMemberships(): Promise<void> {
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
|
||||
const mockPrisma = {
|
||||
timeSlot: {
|
||||
findMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
updateMany: jest.fn(),
|
||||
@@ -35,6 +37,8 @@ describe('SlotGeneratorService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
// Default: no dates are pre-touched by an admin. Individual tests override.
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValue([])
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -110,6 +114,78 @@ describe('SlotGeneratorService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips dates that already have any TimeSlot row (admin-touched)', async () => {
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// Pre-mark day 0 and day 2 as touched (any status).
|
||||
const touchedDay0 = new Date(tomorrow)
|
||||
const touchedDay2 = new Date(tomorrow)
|
||||
touchedDay2.setDate(touchedDay2.getDate() + 2)
|
||||
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
{ date: touchedDay0 },
|
||||
{ date: touchedDay2 },
|
||||
])
|
||||
// createMany mock reports whatever it would actually insert.
|
||||
// With 1 day not touched (13 default slots), that's defaultSlots.length.
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
|
||||
|
||||
const count = await service.generateSlots(3)
|
||||
|
||||
// Day 1 should be the only day generated (3 - 2 touched = 1).
|
||||
expect(count).toBe(defaultSlots.length)
|
||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
data: Array<{ date: Date }>
|
||||
}
|
||||
expect(data).toHaveLength(defaultSlots.length)
|
||||
|
||||
// All generated dates must be UTC midnights within the requested window.
|
||||
const rangeEnd = new Date(tomorrow)
|
||||
rangeEnd.setDate(rangeEnd.getDate() + 2)
|
||||
rangeEnd.setUTCHours(23, 59, 59, 999)
|
||||
for (const row of data) {
|
||||
expect(row.date.getTime()).toBeGreaterThanOrEqual(tomorrow.getTime())
|
||||
expect(row.date.getTime()).toBeLessThanOrEqual(rangeEnd.getTime())
|
||||
}
|
||||
|
||||
// The two touched dates must not appear in the generated batch.
|
||||
const generatedKeys = new Set(data.map((r) => r.date.toISOString().slice(0, 10)))
|
||||
expect(generatedKeys.has(touchedDay0.toISOString().slice(0, 10))).toBe(false)
|
||||
expect(generatedKeys.has(touchedDay2.toISOString().slice(0, 10))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 0 and skips createMany when every date in the window is already touched', async () => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const allDays = [0, 1, 2].map((offset) => {
|
||||
const d = new Date(tomorrow)
|
||||
d.setDate(d.getDate() + offset)
|
||||
return { date: d }
|
||||
})
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(allDays)
|
||||
|
||||
const count = await service.generateSlots(3)
|
||||
|
||||
expect(count).toBe(0)
|
||||
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still uses skipDuplicates on createMany when it does run', async () => {
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
|
||||
|
||||
await service.generateSlots(1)
|
||||
|
||||
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
skipDuplicates: boolean
|
||||
}
|
||||
expect(call.skipDuplicates).toBe(true)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// cleanupExpiredSlots
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -148,6 +224,53 @@ describe('SlotGeneratorService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// prunePastClosedSlots
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('prunePastClosedSlots', () => {
|
||||
it('deletes past CLOSED slots that have no bookings', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 4 })
|
||||
|
||||
const count = await service.prunePastClosedSlots()
|
||||
|
||||
expect(count).toBe(4)
|
||||
expect(mockPrisma.timeSlot.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('only deletes slots with date strictly before today', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
await service.prunePastClosedSlots()
|
||||
|
||||
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
|
||||
where: { date: { lt: Date } }
|
||||
}).where
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
|
||||
expect(diff).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('keeps CLOSED slots that still have bookings attached', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
await service.prunePastClosedSlots()
|
||||
|
||||
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
|
||||
where: { bookings: { none: Record<string, never> } }
|
||||
}).where
|
||||
expect(where.bookings).toEqual({ none: {} })
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkExpiredMemberships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -39,6 +39,8 @@ const mockPrisma = {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
weekTemplate: {
|
||||
findMany: jest.fn(),
|
||||
@@ -48,6 +50,27 @@ const mockPrisma = {
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward $transaction's callback to a tx object that shares the timeSlot
|
||||
* mock implementations, so transactional calls are observable the same way
|
||||
* as top-level ones.
|
||||
*/
|
||||
function bindTransaction() {
|
||||
const mockTx = {
|
||||
timeSlot: {
|
||||
findMany: mockPrisma.timeSlot.findMany,
|
||||
findUnique: mockPrisma.timeSlot.findUnique,
|
||||
create: mockPrisma.timeSlot.create,
|
||||
update: mockPrisma.timeSlot.update,
|
||||
createMany: mockPrisma.timeSlot.createMany,
|
||||
delete: mockPrisma.timeSlot.delete,
|
||||
},
|
||||
}
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (cb: (tx: typeof mockTx) => unknown) => cb(mockTx),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,6 +80,7 @@ describe('TimeSlotService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
bindTransaction()
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -270,4 +294,173 @@ describe('TimeSlotService', () => {
|
||||
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// publishDaySlots — admin schedule management
|
||||
// -------------------------------------------------------------------------
|
||||
describe('publishDaySlots', () => {
|
||||
const date = '2026-04-07'
|
||||
|
||||
it('materializes the default template when the day has no rows yet', async () => {
|
||||
// First findMany (existing check) → empty; second findMany (re-read) → materialized rows.
|
||||
const materialized = [
|
||||
makeSlot({ id: 'slot-A', startTime: '08:00', endTime: '09:00', source: TimeSlotSource.TEMPLATE }),
|
||||
makeSlot({ id: 'slot-B', startTime: '09:30', endTime: '10:30', source: TimeSlotSource.TEMPLATE }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([]) // initial check
|
||||
.mockResolvedValueOnce(materialized) // after materialize
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(materialized) // final state
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
||||
const createCall = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
data: Array<{ source: string; status: string; date: Date }>
|
||||
skipDuplicates: boolean
|
||||
}
|
||||
expect(createCall.skipDuplicates).toBe(true)
|
||||
for (const row of createCall.data) {
|
||||
expect(row.source).toBe(TimeSlotSource.TEMPLATE)
|
||||
expect(row.status).toBe(TimeSlotStatus.OPEN)
|
||||
}
|
||||
})
|
||||
|
||||
it('CLOSEs (never deletes) orphaned rows when admin publishes empty list', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 0 }),
|
||||
makeSlot({ id: 'slot-2', bookedCount: 0, startTime: '09:30', endTime: '10:30' }),
|
||||
]
|
||||
// Initial check + final-state read both return the (now-CLOSED) rows.
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValue({})
|
||||
mockPrisma.timeSlot.delete = jest.fn() // safety: ensure delete is never used here
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
|
||||
// Each orphan is updated to CLOSED (no bookedCount discrimination).
|
||||
const updateCalls = mockPrisma.timeSlot.update.mock.calls
|
||||
expect(updateCalls.length).toBe(2)
|
||||
for (const call of updateCalls) {
|
||||
const data = (call[0] as { data: { status: string } }).data
|
||||
expect(data.status).toBe(TimeSlotStatus.CLOSED)
|
||||
}
|
||||
})
|
||||
|
||||
it('CLOSEs (never deletes) orphaned rows even when they have bookings', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 3 }), // has bookings
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValue({})
|
||||
mockPrisma.timeSlot.delete = jest.fn()
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
|
||||
const updateCall = mockPrisma.timeSlot.update.mock.calls[0]
|
||||
const data = (updateCall[0] as { data: { status: string } }).data
|
||||
expect(data.status).toBe(TimeSlotStatus.CLOSED)
|
||||
})
|
||||
|
||||
it('updates existing slot referenced by existingSlotId and keeps its capacity >= bookedCount', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 5 }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
const updated = makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 4 })
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce(updated)
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 2 }],
|
||||
})
|
||||
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
where: { id: string }
|
||||
data: { capacity: number; status?: string }
|
||||
})
|
||||
expect(updateData.where.id).toBe('slot-1')
|
||||
// capacity clamped up to bookedCount (3), not the requested 2.
|
||||
expect(updateData.data.capacity).toBe(3)
|
||||
// status is NOT touched on update — existing CLOSED rows stay CLOSED
|
||||
// so that re-publishing the day cannot silently undo "rest day" intent.
|
||||
expect(updateData.data.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves CLOSED status when admin re-publishes the day unchanged', async () => {
|
||||
// Scenario: admin cleared the day, then reloaded the page. The page
|
||||
// re-sends all CLOSED rows via existingSlotId. They must remain CLOSED.
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', status: TimeSlotStatus.CLOSED, bookedCount: 0 }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce(existing[0])
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 1 }],
|
||||
})
|
||||
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
data: { status?: string }
|
||||
})
|
||||
expect(updateData.data.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates new slots for entries without an existingSlotId', async () => {
|
||||
const existing: typeof makeSlot extends (...a: any) => infer R ? R : never = [] as never
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
||||
mockPrisma.timeSlot.create.mockResolvedValueOnce(
|
||||
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
|
||||
)
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
|
||||
])
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ startTime: '14:00', endTime: '15:00', capacity: 4 }],
|
||||
})
|
||||
|
||||
expect(mockPrisma.timeSlot.create).toHaveBeenCalledTimes(1)
|
||||
const createData = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
|
||||
data: { source: string; status: string; capacity: number }
|
||||
}).data
|
||||
expect(createData.source).toBe(TimeSlotSource.MANUAL)
|
||||
expect(createData.status).toBe(TimeSlotStatus.OPEN)
|
||||
expect(createData.capacity).toBe(4)
|
||||
})
|
||||
|
||||
it('returns the final state of the day ordered by startTime', async () => {
|
||||
const finalState = [
|
||||
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
|
||||
makeSlot({ id: 'b', startTime: '10:30', endTime: '11:30' }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([]) // initial check
|
||||
.mockResolvedValueOnce([]) // after materialize
|
||||
.mockResolvedValueOnce(finalState) // final state
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
const result = await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].startTime).toBe('09:00')
|
||||
expect(result[1].startTime).toBe('10:30')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,18 @@ function toUtcMidnight(date: Date): Date {
|
||||
return d
|
||||
}
|
||||
|
||||
/** Add `days` whole days to a UTC date, returning a new Date. */
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
/** Normalise a Date (UTC midnight) to its `YYYY-MM-DD` key for set membership. */
|
||||
function toDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SlotGeneratorService {
|
||||
private readonly logger = new Logger(SlotGeneratorService.name)
|
||||
@@ -26,7 +38,13 @@ export class SlotGeneratorService {
|
||||
/**
|
||||
* Generate time slots for the next `daysAhead` days based on the fixed
|
||||
* default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly).
|
||||
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Days that already have any TimeSlot row (any status, including CLOSED)
|
||||
* are treated as "admin-touched" and skipped, so that an explicit "rest
|
||||
* day" intent cannot be silently overwritten by the nightly cron.
|
||||
* - The final `createMany` call still uses `skipDuplicates` so that
|
||||
* re-runs against partially-existing dates remain safe.
|
||||
*
|
||||
* @returns Number of newly created slots
|
||||
*/
|
||||
@@ -37,6 +55,18 @@ export class SlotGeneratorService {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const rangeStart = toUtcMidnight(tomorrow)
|
||||
const rangeEnd = toUtcMidnight(addDays(tomorrow, daysAhead - 1))
|
||||
|
||||
// Pre-fetch dates inside the window that already have at least one row.
|
||||
// Any status (OPEN / FULL / CLOSED) counts as "admin touched this day".
|
||||
const touchedRows = await this.prisma.timeSlot.findMany({
|
||||
where: { date: { gte: rangeStart, lte: rangeEnd } },
|
||||
select: { date: true },
|
||||
distinct: ['date'],
|
||||
})
|
||||
const touchedKeys = new Set(touchedRows.map((r) => toDateKey(r.date)))
|
||||
|
||||
const slotsToCreate: Array<{
|
||||
date: Date
|
||||
startTime: string
|
||||
@@ -46,12 +76,15 @@ export class SlotGeneratorService {
|
||||
}> = []
|
||||
|
||||
for (let offset = 0; offset < daysAhead; offset++) {
|
||||
const target = new Date(tomorrow)
|
||||
target.setDate(target.getDate() + offset)
|
||||
const target = toUtcMidnight(addDays(tomorrow, offset))
|
||||
|
||||
if (touchedKeys.has(toDateKey(target))) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const slot of defaultSlots) {
|
||||
slotsToCreate.push({
|
||||
date: toUtcMidnight(target),
|
||||
date: target,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: DEFAULT_SLOT_CAPACITY,
|
||||
@@ -61,6 +94,9 @@ export class SlotGeneratorService {
|
||||
}
|
||||
|
||||
if (slotsToCreate.length === 0) {
|
||||
this.logger.log(
|
||||
`Skipped ${touchedKeys.size} admin-touched date(s); nothing to generate`,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -69,7 +105,9 @@ export class SlotGeneratorService {
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
this.logger.log(`Generated ${result.count} new time slots`)
|
||||
this.logger.log(
|
||||
`Generated ${result.count} new time slots (skipped ${touchedKeys.size} admin-touched date(s))`,
|
||||
)
|
||||
return result.count
|
||||
}
|
||||
|
||||
@@ -94,6 +132,34 @@ export class SlotGeneratorService {
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete past TimeSlot rows that are CLOSED and have no bookings attached.
|
||||
*
|
||||
* Without this, every admin "clear" leaves 13 CLOSED/TEMPLATE rows in the
|
||||
* DB forever (no member ever queries past dates, but they pile up).
|
||||
* Rows with `bookedCount > 0` or any Booking record are kept so we never
|
||||
* break the Booking.timeSlotId foreign key.
|
||||
*
|
||||
* Runs nightly alongside cleanupExpiredSlots.
|
||||
*
|
||||
* @returns Number of slots deleted
|
||||
*/
|
||||
async prunePastClosedSlots(): Promise<number> {
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const result = await this.prisma.timeSlot.deleteMany({
|
||||
where: {
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
date: { lt: today },
|
||||
bookings: { none: {} },
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`Pruned ${result.count} past closed time slots`)
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire memberships whose end date has passed or whose remaining sessions
|
||||
* have been exhausted.
|
||||
|
||||
@@ -195,19 +195,61 @@ export class TimeSlotService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish (create/update/remove) time slots for a specific date.
|
||||
* - Slots with existingSlotId → update
|
||||
* - New slots → create
|
||||
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
|
||||
* Publish (create/update/close) time slots for a specific date.
|
||||
*
|
||||
* Behavior:
|
||||
* - If the day has no TimeSlot rows yet (e.g. admin opened a future date
|
||||
* that the cron has not generated), materialize the default template
|
||||
* first so that any subsequent close intent persists.
|
||||
* - Slots referenced via existingSlotId → updated in place; existing
|
||||
* status (OPEN / CLOSED) is preserved. Reopening a CLOSED slot is
|
||||
* intentionally NOT possible through this endpoint — the front-end
|
||||
* re-sends unchanged rows on every publish, so an explicit reopen
|
||||
* would silently undo "rest day" intent. Use the dedicated reopen
|
||||
* endpoint (TODO) or delete + re-add via the UI instead.
|
||||
* - Slots with no existingSlotId → created as MANUAL/OPEN.
|
||||
* - Existing rows that the request drops are set to CLOSED rather than
|
||||
* deleted, so that the admin's intent ("this is a rest day") survives
|
||||
* the nightly slot-generation cron (skipDuplicates / touched-date skip).
|
||||
* - Materialize + orphan-close interaction: when the day has no rows yet,
|
||||
* every default template slot is written first as OPEN/TEMPLATE, then
|
||||
* any slot not referenced by the request is closed. So if an admin
|
||||
* edits a ghost preview and publishes only a subset, the unused default
|
||||
* slots end up CLOSED/TEMPLATE rather than OPEN/TEMPLATE.
|
||||
*
|
||||
* @returns Final TimeSlot rows for the day, ordered by startTime.
|
||||
*/
|
||||
async publishDaySlots(dto: PublishDaySlotsDto) {
|
||||
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 1. Get existing slots for this date
|
||||
const existing = await tx.timeSlot.findMany({
|
||||
where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
|
||||
const dayRange = {
|
||||
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
|
||||
}
|
||||
|
||||
// 1. Look up the existing rows for the day.
|
||||
let existing = await tx.timeSlot.findMany({ where: dayRange })
|
||||
|
||||
// 2. Materialize the default template if the admin is touching a day
|
||||
// that has never been generated. This records their intent (the row
|
||||
// now exists), and any empty publish will leave the day in a
|
||||
// CLOSED-everything state that the nightly cron will not undo.
|
||||
if (existing.length === 0) {
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
await tx.timeSlot.createMany({
|
||||
data: defaultSlots.map((slot) => ({
|
||||
date: this.toDateOfDay(parsedDate),
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: DEFAULT_SLOT_CAPACITY,
|
||||
source: TimeSlotSource.TEMPLATE,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
existing = await tx.timeSlot.findMany({ where: dayRange })
|
||||
}
|
||||
|
||||
const existingMap = new Map(existing.map((s) => [s.id, s]))
|
||||
const keptIds = new Set<string>()
|
||||
|
||||
@@ -222,10 +264,10 @@ export class TimeSlotService {
|
||||
source: string
|
||||
}> = []
|
||||
|
||||
// 2. Process each slot in the request
|
||||
// 3. Process each slot in the request.
|
||||
for (const item of dto.slots) {
|
||||
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
|
||||
// Update existing slot
|
||||
// Update existing slot. Never reduce capacity below bookedCount.
|
||||
const existingSlot = existingMap.get(item.existingSlotId)!
|
||||
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
|
||||
|
||||
@@ -235,12 +277,15 @@ export class TimeSlotService {
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
capacity: safeCapacity,
|
||||
// Existing status is preserved: CLOSED rows stay CLOSED, so an
|
||||
// admin's "rest day" intent cannot be undone by a no-op
|
||||
// re-publish. Reopening must go through a dedicated endpoint.
|
||||
},
|
||||
})
|
||||
keptIds.add(item.existingSlotId)
|
||||
results.push(updated)
|
||||
} else {
|
||||
// Create new slot
|
||||
// Create a new slot.
|
||||
const created = await tx.timeSlot.create({
|
||||
data: {
|
||||
date: parsedDate,
|
||||
@@ -255,22 +300,27 @@ export class TimeSlotService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle orphaned existing slots (not in request)
|
||||
// 4. Close orphaned existing rows. We never delete — keeping the row
|
||||
// preserves the admin's "rest day" intent across cron runs.
|
||||
for (const slot of existing) {
|
||||
if (!keptIds.has(slot.id)) {
|
||||
if (slot.bookedCount > 0) {
|
||||
// Has bookings → close instead of delete
|
||||
if (slot.status !== TimeSlotStatus.CLOSED) {
|
||||
await tx.timeSlot.update({
|
||||
where: { id: slot.id },
|
||||
data: { status: TimeSlotStatus.CLOSED },
|
||||
})
|
||||
} else {
|
||||
await tx.timeSlot.delete({ where: { id: slot.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.map((slot) => ({
|
||||
// 5. Return the final state of the day so the admin UI can refresh
|
||||
// without a second round-trip.
|
||||
const finalState = await tx.timeSlot.findMany({
|
||||
where: dayRange,
|
||||
orderBy: { startTime: 'asc' },
|
||||
})
|
||||
|
||||
return finalState.map((slot) => ({
|
||||
id: slot.id,
|
||||
date: slot.date.toISOString().split('T')[0],
|
||||
startTime: slot.startTime,
|
||||
|
||||
Reference in New Issue
Block a user