fix(server): support rebooking cancelled slots

This commit is contained in:
richarjiang
2026-04-13 17:50:14 +08:00
parent f78cdcc9d1
commit d45a5b2c14
3 changed files with 113 additions and 36 deletions

View File

@@ -257,7 +257,7 @@ describe('BookingService', () => {
bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
bookingEndTime: '2099-12-31 10:00',
})
})
})
@@ -270,7 +270,7 @@ describe('BookingService', () => {
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
tx.booking.findUnique.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -312,7 +312,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -345,7 +345,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
@@ -368,7 +368,7 @@ describe('BookingService', () => {
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -394,7 +394,7 @@ describe('BookingService', () => {
it('sends admin booking created subscription message to admins with remaining count', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -435,7 +435,7 @@ describe('BookingService', () => {
bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
bookingEndTime: '2099-12-31 10:00',
})
})
@@ -455,7 +455,7 @@ describe('BookingService', () => {
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(mockConfirmedBooking) // duplicate exists
tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -467,7 +467,7 @@ describe('BookingService', () => {
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -493,7 +493,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -502,6 +502,65 @@ describe('BookingService', () => {
ForbiddenException,
)
})
it('reuses a cancelled booking record when booking the same slot again', async () => {
const cancelledBooking = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
membershipId: 'mem-old-001',
cancelledAt: new Date('2099-12-30T00:00:00Z'),
confirmedAt: new Date('2099-12-29T00:00:00Z'),
completedAt: null,
operatorId: 'admin-001',
}
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(cancelledBooking)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.update.mockResolvedValue({
...cancelledBooking,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
operatorId: null,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith({
where: { id: MOCK_BOOKING_ID },
data: {
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
bookingId: MOCK_BOOKING_ID,
fromStatus: BookingStatus.CANCELLED,
toStatus: BookingStatus.PENDING_CONFIRMATION,
remark: '学员重新发起预约',
}),
}),
)
})
})
// ─── cancelBooking ────────────────────────────────────────────────────────

View File

@@ -72,15 +72,20 @@ export class BookingService {
)
}
// 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking
const existing = await tx.booking.findFirst({
// 2. Find existing booking record for this user + slot.
// The DB keeps a unique key on this pair, so cancelled bookings must be revived instead of recreated.
const existing = await tx.booking.findUnique({
where: {
userId,
timeSlotId: dto.timeSlotId,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
userId_timeSlotId: {
userId,
timeSlotId: dto.timeSlotId,
},
},
})
if (existing) {
if (
existing &&
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
) {
throw new ConflictException('You have already booked this time slot')
}
@@ -115,24 +120,40 @@ export class BookingService {
}
}
// 5. Create booking with PENDING_CONFIRMATION status
const newBooking = await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 5. Create booking or revive a previously cancelled booking.
const newBooking = existing?.status === BookingStatus.CANCELLED
? await tx.booking.update({
where: { id: existing.id },
data: {
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
: await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 6. Record status history: created
// 6. Record status history: created or re-created from cancelled state.
await tx.bookingStatusHistory.create({
data: {
bookingId: newBooking.id,
fromStatus: null,
fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: userId,
remark: '学员发起预约',
remark: existing?.status === BookingStatus.CANCELLED
? '学员重新发起预约'
: '学员发起预约',
},
})
@@ -588,7 +609,6 @@ export class BookingService {
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
@@ -596,7 +616,7 @@ export class BookingService {
bookingContent: '预约已确认',
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`,
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})
} catch (error) {
console.error('Send booking confirmed subscription message failed:', error)
@@ -628,8 +648,6 @@ export class BookingService {
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled(
admins
@@ -640,7 +658,7 @@ export class BookingService {
bookingContent: `${studentLabel}已预约`.slice(0, 20),
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`,
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})),
)
} catch (error) {

View File

@@ -13,7 +13,7 @@ interface BookingConfirmedTemplatePayload {
readonly bookingContent: string
readonly bookingTime: string
readonly courseName: string
readonly bookingPeriod: string
readonly bookingEndTime: string
}
interface AdminBookingCreatedTemplatePayload {
@@ -22,7 +22,7 @@ interface AdminBookingCreatedTemplatePayload {
readonly bookingContent: string
readonly bookingTime: string
readonly courseName: string
readonly bookingPeriod: string
readonly bookingEndTime: string
}
interface WechatAccessTokenResponse {
@@ -188,7 +188,7 @@ export class SubscriptionMessageService {
thing1: { value: params.payload.bookingContent.slice(0, 20) },
time2: { value: params.payload.bookingTime.slice(0, 20) },
thing25: { value: params.payload.courseName.slice(0, 20) },
time35: { value: params.payload.bookingPeriod.slice(0, 20) },
time35: { value: params.payload.bookingEndTime.slice(0, 20) },
},
}