fix(server): hide CLOSED rows from preview for legacy mini-program compatibility

The previous fix made publishDaySlots CLOSE rows instead of DELETE them.
That works correctly on the server, but the live mini-program still ships
the old schedule.vue which only knows about isPublished (no status
field). It would re-render the CLOSED rows with the '已发布' badge,
making it look like 'clear day' did nothing.

Hide CLOSED rows from getSchedulePreview so the legacy client shows
'当日暂无排课' for rest days — the same UX it had before this fix
shipped. All-CLOSED days return []; days with a mix return only the
non-CLOSED rows; days the cron hasn't touched still return the ghost
template.

The republished mini-program (with status-aware rendering) will see the
same [] for rest days, which is consistent. Tests: 4 new specs covering
all-OPEN / all-CLOSED / mixed / empty-day cases.
This commit is contained in:
richarjiang
2026-09-10 22:36:41 +08:00
parent 806f3ee770
commit b8c0dd6781
2 changed files with 93 additions and 5 deletions

View File

@@ -231,6 +231,69 @@ describe('TimeSlotService', () => {
}) })
}) })
// -------------------------------------------------------------------------
// getSchedulePreview
// -------------------------------------------------------------------------
describe('getSchedulePreview', () => {
const date = '2026-04-07'
it('returns OPEN/FULL rows with isPublished: true', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'slot-1', startTime: '09:00', endTime: '10:00', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'slot-2', startTime: '10:30', endTime: '11:30', status: TimeSlotStatus.FULL }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.every((s) => s.isPublished === true)).toBe(true)
})
it('HIDES CLOSED rows so the legacy client does not render them as "已发布"', async () => {
// Simulates an admin "clear day" — DB now has 13 CLOSED rows.
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(
Array.from({ length: 13 }, (_, i) =>
makeSlot({
id: `slot-${i}`,
startTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`,
endTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '30' : '30'}`,
status: TimeSlotStatus.CLOSED,
}),
),
)
const result = await service.getSchedulePreview(date)
// Critical for online compatibility: the legacy client must see []
// so it shows "当日暂无排课", not 13 phantom "已发布" slots.
expect(result).toEqual([])
})
it('returns the OPEN subset and hides CLOSED when mixed', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'open-1', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'closed-1', status: TimeSlotStatus.CLOSED, startTime: '10:00', endTime: '11:00' }),
makeSlot({ id: 'open-2', status: TimeSlotStatus.OPEN, startTime: '10:30', endTime: '11:30' }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.map((s) => s.id).sort()).toEqual(['open-1', 'open-2'])
expect(result.find((s) => s.id === 'closed-1')).toBeUndefined()
})
it('returns the ghost template when the day has no rows at all', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
const result = await service.getSchedulePreview(date)
// Ghost = isPublished false, id null.
expect(result.length).toBeGreaterThan(0)
expect(result.every((s) => s.isPublished === false && s.id === null)).toBe(true)
})
})
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// createManualSlot // createManualSlot
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -148,13 +148,26 @@ export class TimeSlotService {
/** /**
* Return a schedule preview for a given date. * Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true). *
* Otherwise → derive from the fixed default schedule (isPublished: false). * Visibility rules (driven by the online mini-program's existing client):
* - OPEN/FULL slots are returned with `isPublished: true`.
* - CLOSED slots are HIDDEN from preview entirely. The pre-fix admin flow
* used to DELETE these rows on "clear day", but a non-deleting flow
* leaves CLOSED rows behind — the existing mini-program UI does not
* understand the CLOSED status and would render them as "已发布",
* making it look like the clear did not take effect. Hiding them
* preserves the legacy "empty list = rest day" UX until the client
* is republished with CLOSED-aware rendering.
* - When the day is entirely CLOSED (a "rest day"), return an empty
* array rather than the default ghost template, so the client shows
* "当日暂无排课" instead of inviting another publish-loop cycle.
* - When the day has no rows at all (never generated), return the
* default template as ghost previews (`isPublished: false`).
*/ */
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> { async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date) const parsedDate = new Date(date)
// 1. Check for existing TimeSlot records (all statuses) // 1. Check for existing TimeSlot records (all statuses).
const existingSlots = await this.prisma.timeSlot.findMany({ const existingSlots = await this.prisma.timeSlot.findMany({
where: { where: {
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) }, date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
@@ -162,8 +175,14 @@ export class TimeSlotService {
orderBy: { startTime: 'asc' }, orderBy: { startTime: 'asc' },
}) })
if (existingSlots.length > 0) { // 2. Filter out CLOSED — they are admin-closed (typically "rest day")
return existingSlots.map((slot) => ({ // and must not surface to the legacy mini-program client.
const openSlots = existingSlots.filter(
(slot) => slot.status !== TimeSlotStatus.CLOSED,
)
if (openSlots.length > 0) {
return openSlots.map((slot) => ({
id: slot.id, id: slot.id,
date: date, date: date,
startTime: slot.startTime, startTime: slot.startTime,
@@ -177,6 +196,12 @@ export class TimeSlotService {
})) }))
} }
// 3. All rows for the day are CLOSED → admin declared a rest day.
// Return [] so the client shows the "no slots" empty state.
if (existingSlots.length > 0) {
return []
}
// 2. No existing slots — use fixed default schedule // 2. No existing slots — use fixed default schedule
const defaultSlots = getDefaultTimeSlots() const defaultSlots = getDefaultTimeSlots()