From b8c0dd6781fafb6f453602f5b1de815ce9a995ef Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 10 Sep 2026 22:36:41 +0800 Subject: [PATCH] fix(server): hide CLOSED rows from preview for legacy mini-program compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/time-slot.service.spec.ts | 63 +++++++++++++++++++ .../server/src/time-slot/time-slot.service.ts | 35 +++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts index 8cb8fea..b43d3f7 100644 --- a/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts +++ b/packages/server/src/time-slot/__tests__/time-slot.service.spec.ts @@ -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 // ------------------------------------------------------------------------- diff --git a/packages/server/src/time-slot/time-slot.service.ts b/packages/server/src/time-slot/time-slot.service.ts index 47b6796..a97bfe0 100644 --- a/packages/server/src/time-slot/time-slot.service.ts +++ b/packages/server/src/time-slot/time-slot.service.ts @@ -148,13 +148,26 @@ export class TimeSlotService { /** * 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 { 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({ where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) }, @@ -162,8 +175,14 @@ export class TimeSlotService { orderBy: { startTime: 'asc' }, }) - if (existingSlots.length > 0) { - return existingSlots.map((slot) => ({ + // 2. Filter out CLOSED — they are admin-closed (typically "rest day") + // 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, date: date, 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 const defaultSlots = getDefaultTimeSlots()