feat: 支持管理员消息推送

This commit is contained in:
richarjiang
2026-04-12 22:18:34 +08:00
parent c60821c5ff
commit 6cee28bf66
26 changed files with 780 additions and 161 deletions

View File

@@ -5,7 +5,7 @@ import {
ForbiddenException,
NotFoundException,
} from '@nestjs/common'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared'
import { BookingService } from '../booking.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipService } from '../../membership/membership.service'
@@ -152,7 +152,7 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -179,6 +179,7 @@ describe('BookingService', () => {
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
},
},
@@ -200,6 +201,7 @@ describe('BookingService', () => {
provide: SubscriptionMessageService,
useValue: {
sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(),
},
},
],
@@ -284,6 +286,7 @@ describe('BookingService', () => {
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
const result = await service.createBooking(MOCK_USER_ID, dto)
@@ -323,6 +326,7 @@ describe('BookingService', () => {
timeSlot: nearFullSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
@@ -353,6 +357,7 @@ describe('BookingService', () => {
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, durationDto)
@@ -379,12 +384,61 @@ describe('BookingService', () => {
timeSlot: mockOpenSlot,
membership: mockMembershipNoTimes,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.membership.update).not.toHaveBeenCalled()
})
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.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(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([
{ openid: 'admin-openid-1' },
])
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true)
await service.createBooking(MOCK_USER_ID, dto)
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
role: UserRole.ADMIN,
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({
openid: 'admin-openid-1',
bookingId: MOCK_BOOKING_ID,
bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
})
})
it('throws BadRequestException when slot is FULL', async () => {
const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID }

View File

@@ -140,7 +140,9 @@ export class BookingService {
})
// Re-fetch with relations after transaction
return this.fetchBookingWithRelations(booking.id)
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
return bookingWithRelations
}
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
@@ -600,4 +602,63 @@ export class BookingService {
console.error('Send booking confirmed subscription message failed:', error)
}
}
private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations,
): Promise<void> {
try {
const admins = await this.prisma.user.findMany({
where: {
role: 'ADMIN',
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
if (admins.length === 0) {
return
}
const student = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { nickname: true, phone: true },
})
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
.filter((admin) => admin.openid)
.map((admin) => this.subscriptionMessageService.sendAdminBookingCreatedMessage({
openid: admin.openid,
bookingId: booking.id,
bookingContent: `${studentLabel}已预约`.slice(0, 20),
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingPeriod: `${dateLabel} ${periodLabel}`,
})),
)
} catch (error) {
console.error('Send admin booking created subscription message failed:', error)
}
}
private buildAdminBookingStudentLabel(student: { nickname: string; phone: string | null } | null): string {
const nickname = (student?.nickname || '').trim()
if (nickname) {
return nickname.slice(0, 8)
}
const phone = student?.phone || ''
if (phone.length >= 4) {
return `尾号${phone.slice(-4)}`
}
return '学员'
}
}