perf: 优化订阅刷新逻辑
This commit is contained in:
@@ -25,6 +25,7 @@ const mockUser = {
|
||||
avatarUrl: null,
|
||||
role: UserRole.MEMBER,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
@@ -109,7 +110,12 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||
data: {
|
||||
openid: OPENID,
|
||||
nickname: TEST_NICKNAME,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
})
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
@@ -148,12 +154,19 @@ describe('AuthService', () => {
|
||||
await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||
data: {
|
||||
openid: OPENID,
|
||||
unionid,
|
||||
nickname: TEST_NICKNAME,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns existing user when openid already exists', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
@@ -161,6 +174,10 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { id: USER_ID },
|
||||
data: { lastLoginAt: expect.any(Date) },
|
||||
})
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
nickname: mockUser.nickname,
|
||||
@@ -171,6 +188,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('returns a valid JWT token', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
@@ -183,6 +201,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('returns both token and user in result', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
@@ -203,6 +222,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('includes active membership count and invite eligibility in login response', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
mockPrismaService.membership.count.mockResolvedValue(2)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
@@ -232,6 +252,7 @@ describe('AuthService', () => {
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
await authService.login('login_code')
|
||||
})
|
||||
|
||||
@@ -126,30 +126,26 @@ export class AuthService {
|
||||
})
|
||||
|
||||
const isNewUser = existingUser === null
|
||||
const now = new Date()
|
||||
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
adminBookingSubscriptionCount: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
// Update avatar for existing users if new avatar is provided
|
||||
if (existingUser && avatarUrl) {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { avatarUrl, ...(nickname && { nickname }) },
|
||||
})
|
||||
sessionKeyStore.set(updated.id, sessionKey)
|
||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
|
||||
}
|
||||
const user = isNewUser
|
||||
? await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
})
|
||||
: await this.prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
lastLoginAt: now,
|
||||
...(avatarUrl && { avatarUrl, ...(nickname && { nickname }) }),
|
||||
},
|
||||
})
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -140,6 +142,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -1001,4 +1006,412 @@ describe('BookingService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminArrangeBooking', () => {
|
||||
const MOCK_ADMIN_ID = 'admin-001'
|
||||
const dto = {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
}
|
||||
|
||||
const mockTrialCardType = {
|
||||
...mockTimesCardType,
|
||||
id: 'ct-trial-001',
|
||||
name: '体验卡',
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
}
|
||||
|
||||
const mockTrialMembership = {
|
||||
...mockActiveMembership,
|
||||
id: 'mem-trial-001',
|
||||
cardTypeId: mockTrialCardType.id,
|
||||
remainingTimes: 1,
|
||||
cardType: mockTrialCardType,
|
||||
}
|
||||
|
||||
function stubArrangeSuccess(
|
||||
tx: ReturnType<typeof buildTxMock>,
|
||||
options?: {
|
||||
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
|
||||
slot?: typeof mockOpenSlot
|
||||
existing?: typeof mockConfirmedBooking | null
|
||||
},
|
||||
) {
|
||||
const membership = options?.membership ?? mockActiveMembership
|
||||
const slot = options?.slot ?? mockOpenSlot
|
||||
const existing = options?.existing ?? null
|
||||
const arranged = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: membership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}
|
||||
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.timeSlot.findUnique
|
||||
.mockResolvedValueOnce(slot)
|
||||
.mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 })
|
||||
tx.booking.findUnique.mockResolvedValue(existing)
|
||||
tx.membership.findUnique.mockResolvedValue(membership)
|
||||
tx.booking.create.mockResolvedValue(arranged)
|
||||
tx.booking.update.mockResolvedValue(arranged)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
||||
tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...arranged,
|
||||
timeSlot: slot,
|
||||
membership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue({
|
||||
...mockStudioConfig,
|
||||
name: 'FocusCore Pilates',
|
||||
})
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
return arranged
|
||||
}
|
||||
|
||||
it('creates a confirmed times-card booking and deducts one session', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx)
|
||||
|
||||
const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
|
||||
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }),
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: MOCK_SLOT_ID,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
bookedCount: { lt: mockOpenSlot.capacity },
|
||||
}),
|
||||
data: { bookedCount: { increment: 1 } },
|
||||
}),
|
||||
)
|
||||
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
toStatus: BookingStatus.CONFIRMED,
|
||||
remark: '老师代为安排',
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled()
|
||||
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled()
|
||||
expect(result.status).toBe(BookingStatus.CONFIRMED)
|
||||
})
|
||||
|
||||
it('does not deduct remaining times for duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockDurationMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockDurationMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deducts a trial card session', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockTrialMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockTrialMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 0,
|
||||
status: MembershipStatus.USED_UP,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the times card has no remaining sessions', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when a duration card has expired', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockDurationMembership,
|
||||
expireDate: new Date('2020-01-01'),
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockDurationMembership.id,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
})
|
||||
|
||||
it('rejects when the time slot is full', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.membership.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate active bookings for the same slot', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects arranging a past time slot', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue({
|
||||
...mockOpenSlot,
|
||||
date: new Date('2020-01-01T00:00:00Z'),
|
||||
startTime: '09:00',
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
})
|
||||
|
||||
it('revives a cancelled booking instead of creating a new row', async () => {
|
||||
const tx = buildTxMock()
|
||||
const cancelled = {
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CANCELLED,
|
||||
}
|
||||
stubArrangeSuccess(tx, { existing: cancelled })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
|
||||
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: cancelled.id },
|
||||
data: expect.objectContaining({
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).toHaveBeenCalled()
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ remainingTimes: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the member does not exist', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue(null)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the membership belongs to another member', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
userId: 'other-user',
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an expired times card even if remaining sessions exist', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
expireDate: new Date('2020-01-01'),
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when occupancy update races and the slot is already full', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses an existing slot when arranging by date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx)
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
date_startTime_endTime: {
|
||||
date: new Date('2099-12-31T00:00:00.000Z'),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a manual slot when arranging a missing date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
|
||||
tx.timeSlot.findUnique
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
|
||||
tx.timeSlot.create.mockResolvedValue(createdSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: createdSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue({
|
||||
...mockStudioConfig,
|
||||
name: 'FocusCore Pilates',
|
||||
})
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
source: 'MANUAL',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects custom slots whose end time is not after start time', async () => {
|
||||
const tx = buildTxMock()
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '23:00',
|
||||
endTime: '00:00',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { BookingService } from './booking.service'
|
||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||
|
||||
@Controller()
|
||||
export class BookingController {
|
||||
@@ -92,6 +93,16 @@ export class BookingController {
|
||||
)
|
||||
}
|
||||
|
||||
@Post('admin/bookings')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async arrangeBooking(
|
||||
@CurrentUser('sub') operatorId: string,
|
||||
@Body() dto: AdminArrangeBookingDto,
|
||||
) {
|
||||
return this.bookingService.adminArrangeBooking(operatorId, dto)
|
||||
}
|
||||
|
||||
@Get('admin/teaching-schedule')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import {
|
||||
BookingStatus,
|
||||
CardTypeCategory,
|
||||
DEFAULT_SLOT_CAPACITY,
|
||||
MembershipStatus,
|
||||
TimeSlotSource,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
} from '@mp-pilates/shared'
|
||||
@@ -18,6 +20,7 @@ import { MembershipService } from '../membership/membership.service'
|
||||
import { StudioService } from '../studio/studio.service'
|
||||
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
@@ -48,6 +51,23 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function normalizeClock(time: string): string {
|
||||
return time.slice(0, 5)
|
||||
}
|
||||
|
||||
function clockToMinutes(time: string): number {
|
||||
const [hours, minutes] = normalizeClock(time).split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function parseSlotDate(date: string): Date {
|
||||
const parsed = new Date(`${date}T00:00:00.000Z`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException('Invalid date')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// ─── Service ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Injectable()
|
||||
@@ -273,6 +293,195 @@ export class BookingService {
|
||||
return confirmedBooking
|
||||
}
|
||||
|
||||
async adminArrangeBooking(
|
||||
operatorId: string,
|
||||
dto: AdminArrangeBookingDto,
|
||||
): Promise<BookingWithRelations> {
|
||||
const booking = await this.prisma.$transaction(async (tx) => {
|
||||
const timeSlot = await this.resolveArrangeSlot(tx, dto)
|
||||
if (timeSlot.status !== TimeSlotStatus.OPEN) {
|
||||
throw new BadRequestException(
|
||||
`TimeSlot is not available (status: ${timeSlot.status})`,
|
||||
)
|
||||
}
|
||||
if (Date.now() >= buildSlotStartMs(timeSlot.date, timeSlot.startTime)) {
|
||||
throw new BadRequestException('Cannot arrange a past time slot')
|
||||
}
|
||||
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User ${dto.userId} not found`)
|
||||
}
|
||||
|
||||
const existing = await tx.booking.findUnique({
|
||||
where: {
|
||||
userId_timeSlotId: {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing && existing.status !== BookingStatus.CANCELLED) {
|
||||
throw new ConflictException('Member already has a booking for this time slot')
|
||||
}
|
||||
|
||||
const membership = await tx.membership.findUnique({
|
||||
where: { id: dto.membershipId },
|
||||
include: { cardType: true },
|
||||
})
|
||||
if (!membership) {
|
||||
throw new NotFoundException(`Membership ${dto.membershipId} not found`)
|
||||
}
|
||||
if (membership.userId !== dto.userId) {
|
||||
throw new ForbiddenException('This membership does not belong to the member')
|
||||
}
|
||||
if (membership.status !== MembershipStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Membership is not active (status: ${membership.status})`,
|
||||
)
|
||||
}
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (isTimeBased && (membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
|
||||
const occupied = await tx.timeSlot.updateMany({
|
||||
where: {
|
||||
id: timeSlot.id,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
bookedCount: { lt: timeSlot.capacity },
|
||||
},
|
||||
data: {
|
||||
bookedCount: { increment: 1 },
|
||||
},
|
||||
})
|
||||
if (occupied.count !== 1) {
|
||||
throw new BadRequestException('Time slot is full')
|
||||
}
|
||||
|
||||
const occupiedSlot = await tx.timeSlot.findUnique({ where: { id: timeSlot.id } })
|
||||
if (occupiedSlot && occupiedSlot.bookedCount >= occupiedSlot.capacity) {
|
||||
await tx.timeSlot.update({
|
||||
where: { id: timeSlot.id },
|
||||
data: { status: TimeSlotStatus.FULL },
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const arranged = existing
|
||||
? await tx.booking.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
confirmedAt: now,
|
||||
completedAt: null,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
: await tx.booking.create({
|
||||
data: {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
membershipId: dto.membershipId,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: now,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
|
||||
if (isTimeBased) {
|
||||
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: {
|
||||
remainingTimes: newRemainingTimes,
|
||||
status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.bookingStatusHistory.create({
|
||||
data: {
|
||||
bookingId: arranged.id,
|
||||
fromStatus: existing?.status === BookingStatus.CANCELLED
|
||||
? BookingStatus.CANCELLED
|
||||
: null,
|
||||
toStatus: BookingStatus.CONFIRMED,
|
||||
operatorId,
|
||||
remark: '老师代为安排',
|
||||
},
|
||||
})
|
||||
|
||||
return arranged
|
||||
})
|
||||
|
||||
const arrangedBooking = await this.fetchBookingWithRelations(booking.id)
|
||||
await this.trySendBookingConfirmedSubscriptionMessage(arrangedBooking)
|
||||
return arrangedBooking
|
||||
}
|
||||
|
||||
private async resolveArrangeSlot(
|
||||
tx: Prisma.TransactionClient,
|
||||
dto: AdminArrangeBookingDto,
|
||||
): Promise<TimeSlot> {
|
||||
if (dto.timeSlotId) {
|
||||
const slot = await tx.timeSlot.findUnique({
|
||||
where: { id: dto.timeSlotId },
|
||||
})
|
||||
if (!slot) {
|
||||
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
if (!dto.date || !dto.startTime || !dto.endTime) {
|
||||
throw new BadRequestException('timeSlotId or date+startTime+endTime is required')
|
||||
}
|
||||
|
||||
const startTime = normalizeClock(dto.startTime)
|
||||
const endTime = normalizeClock(dto.endTime)
|
||||
if (clockToMinutes(endTime) <= clockToMinutes(startTime)) {
|
||||
throw new BadRequestException('End time must be after start time')
|
||||
}
|
||||
|
||||
const date = parseSlotDate(dto.date)
|
||||
const existing = await tx.timeSlot.findUnique({
|
||||
where: {
|
||||
date_startTime_endTime: {
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return tx.timeSlot.create({
|
||||
data: {
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
||||
source: TimeSlotSource.MANUAL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||
|
||||
async completeBooking(
|
||||
|
||||
40
packages/server/src/booking/dto/admin-arrange-booking.dto.ts
Normal file
40
packages/server/src/booking/dto/admin-arrange-booking.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator'
|
||||
|
||||
export class AdminArrangeBookingDto {
|
||||
@IsUUID()
|
||||
userId!: string
|
||||
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
timeSlotId?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@IsDateString()
|
||||
date?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
||||
startTime?: string
|
||||
|
||||
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
|
||||
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
|
||||
endTime?: string
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
capacity?: number
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { NotFoundException } from '@nestjs/common'
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common'
|
||||
import { UserService } from '../user.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import {
|
||||
MembershipStatus,
|
||||
BookingStatus,
|
||||
UserRole,
|
||||
CardTypeCategory,
|
||||
SubscriptionMessageScene,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
@@ -23,6 +24,7 @@ const makeUser = (overrides: Record<string, unknown> = {}) => ({
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: new Date('2024-06-01T08:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
_count: { memberships: 2 },
|
||||
@@ -53,6 +55,8 @@ const makeBooking = (
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
subscriptionMessageConsent: {
|
||||
@@ -61,6 +65,13 @@ const mockPrisma = {
|
||||
},
|
||||
booking: {
|
||||
findMany: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -120,6 +131,7 @@ describe('UserService', () => {
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
activeMembershipCount: 3,
|
||||
inviteShareEligible: true,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
subscriptionMessageTemplates: {
|
||||
templates: [
|
||||
@@ -423,4 +435,153 @@ describe('UserService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMemberDetail', () => {
|
||||
const cardType = {
|
||||
id: 'ct-1',
|
||||
name: '10次卡',
|
||||
type: CardTypeCategory.TIMES,
|
||||
totalTimes: 10,
|
||||
durationDays: 180,
|
||||
price: 150000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
|
||||
const membership = {
|
||||
id: 'mem-1',
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'ct-1',
|
||||
remainingTimes: 6,
|
||||
totalTimes: 10,
|
||||
startDate: new Date('2024-01-01T00:00:00Z'),
|
||||
expireDate: new Date('2099-01-01T00:00:00Z'),
|
||||
status: MembershipStatus.ACTIVE,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
cardType,
|
||||
}
|
||||
|
||||
it('returns profile, memberships, stats and upcoming bookings', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue({
|
||||
...makeUser(),
|
||||
memberships: [membership],
|
||||
})
|
||||
mockPrisma.booking.groupBy.mockResolvedValue([
|
||||
{ userId: 'user-1', status: BookingStatus.COMPLETED, _count: { id: 3 } },
|
||||
{ userId: 'user-1', status: BookingStatus.CANCELLED, _count: { id: 1 } },
|
||||
{ userId: 'user-1', status: BookingStatus.NO_SHOW, _count: { id: 1 } },
|
||||
])
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'booking-up-1',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: {
|
||||
date: new Date('2099-12-31T00:00:00Z'),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
},
|
||||
membership: { cardType: { name: '10次卡' } },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await service.getMemberDetail('user-1')
|
||||
|
||||
expect(result.user.userId).toBe('user-1')
|
||||
expect(result.user.lastLoginAt).toBe('2024-06-01T08:00:00.000Z')
|
||||
expect(result.memberships).toHaveLength(1)
|
||||
expect(result.memberships[0].cardType.name).toBe('10次卡')
|
||||
expect(result.stats).toEqual({
|
||||
totalBookings: 5,
|
||||
completedBookings: 3,
|
||||
cancelledBookings: 1,
|
||||
noShowBookings: 1,
|
||||
})
|
||||
expect(result.upcomingBookings).toEqual([
|
||||
{
|
||||
id: 'booking-up-1',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
cardName: '10次卡',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('throws NotFoundException when member does not exist', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(service.getMemberDetail('missing')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMemberProfile', () => {
|
||||
it('updates nickname and phone then returns the dossier', async () => {
|
||||
mockPrisma.user.findUnique
|
||||
.mockResolvedValueOnce(makeUser())
|
||||
.mockResolvedValueOnce({ ...makeUser({ nickname: 'Bob', phone: '13900000000' }), memberships: [] })
|
||||
mockPrisma.user.update.mockResolvedValue(makeUser({ nickname: 'Bob', phone: '13900000000' }))
|
||||
mockPrisma.booking.groupBy.mockResolvedValue([])
|
||||
mockPrisma.booking.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await service.updateMemberProfile('user-1', {
|
||||
nickname: 'Bob',
|
||||
phone: '13900000000',
|
||||
})
|
||||
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { nickname: 'Bob', phone: '13900000000' },
|
||||
})
|
||||
expect(result.user.nickname).toBe('Bob')
|
||||
expect(result.user.phone).toBe('13900000000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUserMembership', () => {
|
||||
it('expires only the selected membership', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue({
|
||||
id: 'mem-1',
|
||||
userId: 'user-1',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
mockPrisma.membership.update.mockResolvedValue({
|
||||
id: 'mem-1',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
})
|
||||
|
||||
await service.deleteUserMembership('user-1', 'mem-1')
|
||||
|
||||
expect(mockPrisma.membership.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'mem-1', userId: 'user-1' },
|
||||
})
|
||||
expect(mockPrisma.membership.update).toHaveBeenCalledWith({
|
||||
where: { id: 'mem-1' },
|
||||
data: { status: MembershipStatus.EXPIRED },
|
||||
})
|
||||
expect(mockPrisma.membership.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the membership is missing or belongs to another user', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue(null)
|
||||
|
||||
await expect(service.deleteUserMembership('user-1', 'mem-other')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
)
|
||||
expect(mockPrisma.membership.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing membershipId', async () => {
|
||||
await expect(service.deleteUserMembership('user-1', '')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator'
|
||||
|
||||
export class UpdateAdminMemberProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
readonly nickname?: string
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value: unknown) => value !== null)
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
readonly phone?: string | null
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
|
||||
import { Type } from 'class-transformer'
|
||||
|
||||
export class UpdateUserMembershipDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
membershipId?: string
|
||||
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Query,
|
||||
Post,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common'
|
||||
import { UserRole, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
@@ -17,6 +18,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { UserService } from './user.service'
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto'
|
||||
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
import { UpdateAdminMemberProfileDto } from './dto/update-admin-member-profile.dto'
|
||||
import { ReportSubscriptionMessageRequestDto } from './dto/report-subscription-message.dto'
|
||||
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
@@ -105,7 +107,30 @@ export class UserController {
|
||||
@Delete('admin/members/:userId/membership')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
deleteUserMembership(@Param('userId') userId: string) {
|
||||
return this.userService.deleteUserMembership(userId)
|
||||
deleteUserMembership(
|
||||
@Param('userId') userId: string,
|
||||
@Query('membershipId') membershipId?: string,
|
||||
) {
|
||||
if (!membershipId) {
|
||||
throw new BadRequestException('membershipId is required')
|
||||
}
|
||||
return this.userService.deleteUserMembership(userId, membershipId)
|
||||
}
|
||||
|
||||
@Get('admin/members/:userId')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
getMemberDetail(@Param('userId') userId: string) {
|
||||
return this.userService.getMemberDetail(userId)
|
||||
}
|
||||
|
||||
@Put('admin/members/:userId')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
updateMemberProfile(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateAdminMemberProfileDto,
|
||||
) {
|
||||
return this.userService.updateMemberProfile(userId, dto)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
|
||||
import {
|
||||
MembershipStatus,
|
||||
BookingStatus,
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
SubscriptionMessageRequestResult,
|
||||
SubscriptionMessageTemplate,
|
||||
SubscriptionMessageTemplateConfig,
|
||||
AdminMemberSummary,
|
||||
AdminMemberDetail,
|
||||
MembershipWithCardType,
|
||||
UpdateAdminMemberProfileDto,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
@@ -23,6 +27,63 @@ import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
|
||||
const VALID_CARD_TYPES = new Set<string>(Object.values(CardTypeCategory))
|
||||
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
|
||||
|
||||
function serializeMembership(membership: {
|
||||
id: string
|
||||
userId: string
|
||||
cardTypeId: string
|
||||
remainingTimes: number | null
|
||||
totalTimes: number | null
|
||||
startDate: Date
|
||||
expireDate: Date
|
||||
status: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
cardType: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
totalTimes: number | null
|
||||
durationDays: number
|
||||
price: number | { toString(): string }
|
||||
originalPrice: number | { toString(): string } | null
|
||||
description: string | null
|
||||
coverUrl: string | null
|
||||
isActive: boolean
|
||||
sortOrder: number
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
}): MembershipWithCardType {
|
||||
const { cardType, ...rest } = membership
|
||||
return {
|
||||
id: rest.id,
|
||||
userId: rest.userId,
|
||||
cardTypeId: rest.cardTypeId,
|
||||
remainingTimes: rest.remainingTimes,
|
||||
totalTimes: rest.totalTimes,
|
||||
startDate: rest.startDate.toISOString(),
|
||||
expireDate: rest.expireDate.toISOString(),
|
||||
status: rest.status as MembershipStatus,
|
||||
createdAt: rest.createdAt.toISOString(),
|
||||
updatedAt: rest.updatedAt.toISOString(),
|
||||
cardType: {
|
||||
id: cardType.id,
|
||||
name: cardType.name,
|
||||
type: cardType.type as CardTypeCategory,
|
||||
totalTimes: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
price: Number(cardType.price),
|
||||
originalPrice: cardType.originalPrice == null ? null : Number(cardType.originalPrice),
|
||||
description: cardType.description,
|
||||
coverUrl: cardType.coverUrl,
|
||||
isActive: cardType.isActive,
|
||||
sortOrder: cardType.sortOrder,
|
||||
createdAt: cardType.createdAt.toISOString(),
|
||||
updatedAt: cardType.updatedAt.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
|
||||
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
|
||||
|
||||
@@ -298,16 +359,7 @@ export class UserService {
|
||||
limit: number,
|
||||
search?: string,
|
||||
cardType?: string,
|
||||
): Promise<PaginatedData<{
|
||||
userId: string
|
||||
openid: string
|
||||
nickname: string
|
||||
phone: string | null
|
||||
avatarUrl: string | null
|
||||
totalBookings: number
|
||||
completedBookings: number
|
||||
cancelledBookings: number
|
||||
}>> {
|
||||
): Promise<PaginatedData<AdminMemberSummary>> {
|
||||
const where: {
|
||||
OR?: Array<{ [key: string]: unknown }>
|
||||
memberships?: {
|
||||
@@ -348,6 +400,14 @@ export class UserService {
|
||||
nickname: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
memberships: {
|
||||
where: { status: MembershipStatus.ACTIVE },
|
||||
include: { cardType: { select: { name: true, type: true } } },
|
||||
orderBy: { expireDate: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true,
|
||||
@@ -383,12 +443,18 @@ export class UserService {
|
||||
|
||||
const items = users.map((u) => {
|
||||
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
|
||||
const active = u.memberships[0]
|
||||
return {
|
||||
userId: u.id,
|
||||
openid: u.openid,
|
||||
nickname: u.nickname,
|
||||
phone: u.phone,
|
||||
avatarUrl: u.avatarUrl,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
|
||||
activeCard: active
|
||||
? { name: active.cardType.name, type: active.cardType.type as CardTypeCategory }
|
||||
: null,
|
||||
totalBookings: s.total,
|
||||
completedBookings: s.completed,
|
||||
cancelledBookings: s.cancelled,
|
||||
@@ -398,6 +464,109 @@ export class UserService {
|
||||
return { items, total, page, limit }
|
||||
}
|
||||
|
||||
async getMemberDetail(userId: string): Promise<AdminMemberDetail> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
memberships: {
|
||||
include: { cardType: true },
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const [bookingStats, upcoming] = await Promise.all([
|
||||
this.prisma.booking.groupBy({
|
||||
by: ['status'],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
this.prisma.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: {
|
||||
in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED],
|
||||
},
|
||||
timeSlot: { date: { gte: today } },
|
||||
},
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [
|
||||
{ timeSlot: { date: 'asc' } },
|
||||
{ timeSlot: { startTime: 'asc' } },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const stats = { total: 0, completed: 0, cancelled: 0, noShow: 0 }
|
||||
for (const row of bookingStats) {
|
||||
stats.total += row._count.id
|
||||
if (row.status === BookingStatus.COMPLETED) stats.completed += row._count.id
|
||||
if (row.status === BookingStatus.CANCELLED) stats.cancelled += row._count.id
|
||||
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: user.id,
|
||||
openid: user.openid,
|
||||
nickname: user.nickname,
|
||||
phone: user.phone,
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
},
|
||||
memberships: user.memberships.map((membership) => serializeMembership(membership)),
|
||||
stats: {
|
||||
totalBookings: stats.total,
|
||||
completedBookings: stats.completed,
|
||||
cancelledBookings: stats.cancelled,
|
||||
noShowBookings: stats.noShow,
|
||||
},
|
||||
upcomingBookings: upcoming.map((booking) => ({
|
||||
id: booking.id,
|
||||
status: booking.status as BookingStatus,
|
||||
date: booking.timeSlot.date.toISOString().slice(0, 10),
|
||||
startTime: booking.timeSlot.startTime,
|
||||
endTime: booking.timeSlot.endTime,
|
||||
cardName: booking.membership.cardType.name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async updateMemberProfile(
|
||||
userId: string,
|
||||
dto: UpdateAdminMemberProfileDto,
|
||||
): Promise<AdminMemberDetail> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id: userId } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
const phone = dto.phone === undefined
|
||||
? undefined
|
||||
: (dto.phone?.trim() ? dto.phone.trim() : null)
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(dto.nickname !== undefined && { nickname: dto.nickname.trim() }),
|
||||
...(phone !== undefined && { phone }),
|
||||
},
|
||||
})
|
||||
|
||||
return this.getMemberDetail(userId)
|
||||
}
|
||||
|
||||
// ─── Membership management ────────────────────────────────────────────────
|
||||
|
||||
async getUserMembership(userId: string) {
|
||||
@@ -429,7 +598,15 @@ export class UserService {
|
||||
status,
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({ where: { userId } })
|
||||
const existing = dto.membershipId
|
||||
? await this.prisma.membership.findFirst({
|
||||
where: { id: dto.membershipId, userId },
|
||||
})
|
||||
: await this.prisma.membership.findFirst({ where: { userId } })
|
||||
|
||||
if (dto.membershipId && !existing) {
|
||||
throw new NotFoundException('Membership not found')
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return this.prisma.membership.update({
|
||||
@@ -445,9 +622,20 @@ export class UserService {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteUserMembership(userId: string): Promise<void> {
|
||||
await this.prisma.membership.updateMany({
|
||||
where: { userId },
|
||||
async deleteUserMembership(userId: string, membershipId: string): Promise<void> {
|
||||
if (!membershipId) {
|
||||
throw new BadRequestException('membershipId is required')
|
||||
}
|
||||
|
||||
const existing = await this.prisma.membership.findFirst({
|
||||
where: { id: membershipId, userId },
|
||||
})
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Membership not found')
|
||||
}
|
||||
|
||||
await this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: MembershipStatus.EXPIRED },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user