diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json index bc1de1f..b00b98e 100644 --- a/packages/app/src/pages.json +++ b/packages/app/src/pages.json @@ -99,6 +99,24 @@ "navigationStyle": "custom" } }, + { + "path": "pages/admin/member-detail", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/admin/member-edit", + "style": { + "navigationStyle": "custom" + } + }, + { + "path": "pages/admin/member-arrange", + "style": { + "navigationStyle": "custom" + } + }, { "path": "pages/admin/orders", "style": { diff --git a/packages/app/src/pages/admin/bookings.vue b/packages/app/src/pages/admin/bookings.vue index eba0f06..f2afcea 100644 --- a/packages/app/src/pages/admin/bookings.vue +++ b/packages/app/src/pages/admin/bookings.vue @@ -146,6 +146,7 @@ diff --git a/packages/app/src/pages/admin/member-arrange.vue b/packages/app/src/pages/admin/member-arrange.vue new file mode 100644 index 0000000..48d544a --- /dev/null +++ b/packages/app/src/pages/admin/member-arrange.vue @@ -0,0 +1,694 @@ + + + + + diff --git a/packages/app/src/pages/admin/member-detail.vue b/packages/app/src/pages/admin/member-detail.vue new file mode 100644 index 0000000..9543ab5 --- /dev/null +++ b/packages/app/src/pages/admin/member-detail.vue @@ -0,0 +1,680 @@ + + + + + diff --git a/packages/app/src/pages/admin/member-edit.vue b/packages/app/src/pages/admin/member-edit.vue new file mode 100644 index 0000000..15e8fa4 --- /dev/null +++ b/packages/app/src/pages/admin/member-edit.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/packages/app/src/pages/admin/members.vue b/packages/app/src/pages/admin/members.vue index 8bc0fd7..021cba5 100644 --- a/packages/app/src/pages/admin/members.vue +++ b/packages/app/src/pages/admin/members.vue @@ -2,12 +2,11 @@ - - {{ total }} - 总会员 + 位会员 - - @@ -54,7 +50,6 @@ {{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }} - - {{ m.nickname || '未知用户' }} - {{ m.openid }} + + {{ m.nickname || '未知用户' }} + + {{ getCardTypeLabel(m.activeCard.type) }} + + + 无卡 + + + {{ m.phone || m.openid }} {{ m.totalBookings }} @@ -80,286 +87,34 @@ - {{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '— 已加载全部 —' }} - - - - - - - - - × - - - - - - - 详情 - - - 编辑 - - - - - - - - - - - - - - {{ (detailMember.nickname || '?').slice(0, 1) }} - - - - - - - - - {{ detailMember.totalBookings }} - 总预约 - - - {{ detailMember.completedBookings }} - 已完成 - - - {{ detailMember.cancelledBookings }} - 已取消 - - - - - - - - - {{ detailMembership.cardType?.name || '未知卡类型' }} - - {{ getCardTypeLabel(detailMembership.cardType?.type) }} - - - - {{ statusLabel(detailMembership.status) }} - - - - - - 剩余课时 - - {{ detailMembership.remainingTimes }} - - - - - - - - - - 已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }} 次 - - - - - 开始日期 - {{ formatDate(detailMembership.startDate) }} - - - 到期日期 - {{ formatDate(detailMembership.expireDate) }} - - - - - 解除会员卡 - - - - - - 💳 - 暂无会员卡 - 为该用户开通会员卡 - - 去开卡 - - - - - - - - - 加载中... - - - - - - - - {{ detailMembership ? '编辑会员卡' : '开通会员卡' }} - - - - - 卡类型 - - - {{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }} - - - - - - - - 剩余次数 - - - - - - 开始日期 - - - {{ editForm.startDate || '请选择' }} - - - - - - - - 到期日期 - - - {{ editForm.expireDate || '请选择' }} - - - - - - - - - {{ editSubmitting ? '保存中...' : '保存' }} - - - - - - - - diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue index 41ad7df..40e6469 100644 --- a/packages/app/src/pages/profile/bookings.vue +++ b/packages/app/src/pages/profile/bookings.vue @@ -163,6 +163,7 @@ diff --git a/packages/app/src/stores/admin.ts b/packages/app/src/stores/admin.ts index 14312a9..a05f086 100644 --- a/packages/app/src/stores/admin.ts +++ b/packages/app/src/stores/admin.ts @@ -18,6 +18,12 @@ import type { UpdateFlashSaleDto, CreateStudioUploadCredentialDto, StudioUploadCredential, + AdminMemberSummary, + AdminMemberDetail, + UpdateAdminMemberProfileDto, + AdminArrangeBookingDto, + MembershipWithCardType, + BookingWithDetails, } from '@mp-pilates/shared' interface LegacyPaginatedData { @@ -53,16 +59,7 @@ export interface AdminStats { totalBookings: number } -export interface MemberSummary { - userId: string - openid: string - nickname: string - phone: string | null - avatarUrl: string | null - totalBookings: number - completedBookings: number - cancelledBookings: number -} +export type MemberSummary = AdminMemberSummary export interface UserMembership { userId: string @@ -176,20 +173,38 @@ export const useAdminStore = defineStore('admin', () => { return get(`/admin/members/${userId}/membership`) } + async function fetchMemberDetail(userId: string): Promise { + return get(`/admin/members/${userId}`) + } + + async function updateMemberProfile( + userId: string, + dto: UpdateAdminMemberProfileDto, + ): Promise { + return put(`/admin/members/${userId}`, dto as unknown as Record) + } + + async function arrangeMemberBooking(dto: AdminArrangeBookingDto): Promise { + return post('/admin/bookings', dto as unknown as Record) + } + async function updateUserMembership( userId: string, dto: { + membershipId?: string cardTypeId: string remainingTimes?: number | null startDate: string expireDate: string }, - ): Promise { - return put(`/admin/members/${userId}/membership`, dto) + ): Promise { + return put(`/admin/members/${userId}/membership`, dto) } - async function deleteUserMembership(userId: string): Promise { - return del(`/admin/members/${userId}/membership`) + async function deleteUserMembership(userId: string, membershipId: string): Promise { + return del( + `/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`, + ) } // ── Time slots ─────────────────────────────────────────────────── @@ -216,7 +231,7 @@ export const useAdminStore = defineStore('admin', () => { async function fetchSchedulePreview(date: string): Promise { scheduleLoading.value = true try { - const data = await get('/admin/schedule/preview', { date }) + const data = await previewScheduleByDate(date) schedulePreview.value = data return data } finally { @@ -224,6 +239,10 @@ export const useAdminStore = defineStore('admin', () => { } } + async function previewScheduleByDate(date: string): Promise { + return get('/admin/schedule/preview', { date }) + } + async function publishDaySlots(dto: PublishDaySlotsDto): Promise { await post('/admin/schedule/publish', dto as unknown as Record) await fetchSchedulePreview(dto.date) @@ -275,6 +294,9 @@ export const useAdminStore = defineStore('admin', () => { fetchAdminBookings, // Members fetchMembers, + fetchMemberDetail, + updateMemberProfile, + arrangeMemberBooking, getUserMembership, updateUserMembership, deleteUserMembership, @@ -285,6 +307,7 @@ export const useAdminStore = defineStore('admin', () => { generateSlots, // Schedule fetchSchedulePreview, + previewScheduleByDate, publishDaySlots, // Stats fetchDashboardStats, diff --git a/packages/app/src/stores/booking.ts b/packages/app/src/stores/booking.ts index c017684..dccf79e 100644 --- a/packages/app/src/stores/booking.ts +++ b/packages/app/src/stores/booking.ts @@ -44,13 +44,27 @@ export const useBookingStore = defineStore('booking', () => { return result } + /** + * Replace a booking in `myBookings` by id. Preserves immutability: always + * returns a new array reference so Vue's computed/watchers pick up the change. + * If the booking isn't in the list (e.g. paginated out), leaves state untouched. + */ + function replaceBooking(updated: BookingWithDetails) { + const idx = myBookings.value.findIndex((b) => b.id === updated.id) + if (idx === -1) return + const next = myBookings.value.slice() + next[idx] = updated + myBookings.value = next + } + async function cancelBooking(bookingId: string) { const result = await put(`/booking/${bookingId}/cancel`) + replaceBooking(result) return result } - async function fetchMyBookings(status?: string) { - loadingBookings.value = true + async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) { + if (!opts.silent) loadingBookings.value = true try { const params: Record = status ? { status } : {} const paginated = await get>('/booking/my', params) @@ -59,7 +73,7 @@ export const useBookingStore = defineStore('booking', () => { console.error('Fetch bookings failed:', err) myBookings.value = [] } finally { - loadingBookings.value = false + if (!opts.silent) loadingBookings.value = false } } @@ -106,6 +120,7 @@ export const useBookingStore = defineStore('booking', () => { const result = await put(`/booking/${bookingId}/confirm`, { remark, }) + replaceBooking(result) return result } @@ -113,6 +128,7 @@ export const useBookingStore = defineStore('booking', () => { const result = await put(`/booking/${bookingId}/complete`, { remark, }) + replaceBooking(result) return result } @@ -120,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => { const result = await put(`/booking/${bookingId}/noshow`, { remark, }) + replaceBooking(result) return result } @@ -159,5 +176,6 @@ export const useBookingStore = defineStore('booking', () => { fetchBookingHistory, fetchSlotById, fetchBookingById, + replaceBooking, } }) diff --git a/packages/app/src/utils/format.ts b/packages/app/src/utils/format.ts index 51236ae..7f623d4 100644 --- a/packages/app/src/utils/format.ts +++ b/packages/app/src/utils/format.ts @@ -143,6 +143,18 @@ export function getStockPercent(soldCount: number, totalStock: number): string { return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%` } +/** 格式化日期时间为 YYYY-MM-DD HH:mm */ +export function formatDateTimeFull(dateStr: string): string { + const d = new Date(dateStr) + if (Number.isNaN(d.getTime())) return '-' + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hour}:${min}` +} + /** 格式化日期时间为 MM-DD HH:mm:ss */ export function formatDateTime(dateStr: string): string { const d = new Date(dateStr) diff --git a/packages/server/prisma/migrations/20260907070000_add_user_last_login_at/migration.sql b/packages/server/prisma/migrations/20260907070000_add_user_last_login_at/migration.sql new file mode 100644 index 0000000..dd08e02 --- /dev/null +++ b/packages/server/prisma/migrations/20260907070000_add_user_last_login_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME(3) NULL; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 7d2bd7a..b5caef2 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -80,6 +80,7 @@ model User { avatarUrl String? @map("avatar_url") role UserRole @default(MEMBER) adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count") + lastLoginAt DateTime? @map("last_login_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts index 6694211..8ab1d4d 100644 --- a/packages/server/src/auth/__tests__/auth.service.spec.ts +++ b/packages/server/src/auth/__tests__/auth.service.spec.ts @@ -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') }) diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 3aa10c0..bbaa622 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -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) diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index e6a392c..59d98e5 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -129,6 +129,8 @@ function buildTxMock(overrides: Record = {}) { 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 = {}) { 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, + 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() + }) + }) }) diff --git a/packages/server/src/booking/booking.controller.ts b/packages/server/src/booking/booking.controller.ts index 909c7c7..55e6c9c 100644 --- a/packages/server/src/booking/booking.controller.ts +++ b/packages/server/src/booking/booking.controller.ts @@ -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) diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 48d7567..15c0313 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -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 { + 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 { + 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( diff --git a/packages/server/src/booking/dto/admin-arrange-booking.dto.ts b/packages/server/src/booking/dto/admin-arrange-booking.dto.ts new file mode 100644 index 0000000..9153ae9 --- /dev/null +++ b/packages/server/src/booking/dto/admin-arrange-booking.dto.ts @@ -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 +} diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts index ee4981f..f113134 100644 --- a/packages/server/src/user/__tests__/user.service.spec.ts +++ b/packages/server/src/user/__tests__/user.service.spec.ts @@ -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 = {}) => ({ 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() + }) + }) }) diff --git a/packages/server/src/user/dto/update-admin-member-profile.dto.ts b/packages/server/src/user/dto/update-admin-member-profile.dto.ts new file mode 100644 index 0000000..81465e3 --- /dev/null +++ b/packages/server/src/user/dto/update-admin-member-profile.dto.ts @@ -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 +} diff --git a/packages/server/src/user/dto/update-user-membership.dto.ts b/packages/server/src/user/dto/update-user-membership.dto.ts index f760302..c2d0413 100644 --- a/packages/server/src/user/dto/update-user-membership.dto.ts +++ b/packages/server/src/user/dto/update-user-membership.dto.ts @@ -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 diff --git a/packages/server/src/user/user.controller.ts b/packages/server/src/user/user.controller.ts index 40a18a0..3f61fca 100644 --- a/packages/server/src/user/user.controller.ts +++ b/packages/server/src/user/user.controller.ts @@ -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(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) } } diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts index 311602b..4459e46 100644 --- a/packages/server/src/user/user.service.ts +++ b/packages/server/src/user/user.service.ts @@ -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(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>[number] @@ -298,16 +359,7 @@ export class UserService { limit: number, search?: string, cardType?: string, - ): Promise> { + ): Promise> { 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 { + 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 { + 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 { - await this.prisma.membership.updateMany({ - where: { userId }, + async deleteUserMembership(userId: string, membershipId: string): Promise { + 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 }, }) } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dffa5af..992861e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -57,6 +57,12 @@ export type { UserProfileResponse, UpdateProfileDto, UserStatsResponse, + AdminMemberActiveCardSummary, + AdminMemberSummary, + AdminMemberUpcomingBooking, + AdminMemberBookingStats, + AdminMemberDetail, + UpdateAdminMemberProfileDto, CardType, CreateCardTypeDto, UpdateCardTypeDto, @@ -77,6 +83,7 @@ export type { TeachingScheduleSlot, BookingStatusHistory, CreateBookingDto, + AdminArrangeBookingDto, Order, OrderWithDetails, CreateOrderDto, diff --git a/packages/shared/src/types/booking.ts b/packages/shared/src/types/booking.ts index b3935e4..d3ba9bb 100644 --- a/packages/shared/src/types/booking.ts +++ b/packages/shared/src/types/booking.ts @@ -69,3 +69,13 @@ export interface CreateBookingDto { readonly timeSlotId: string readonly membershipId: string } + +export interface AdminArrangeBookingDto { + readonly userId: string + readonly membershipId: string + readonly timeSlotId?: string + readonly date?: string + readonly startTime?: string + readonly endTime?: string + readonly capacity?: number +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 095841e..8de15ea 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,4 +1,15 @@ -export type { User, UserProfileResponse, UpdateProfileDto, UserStatsResponse } from './user' +export type { + User, + UserProfileResponse, + UpdateProfileDto, + UserStatsResponse, + AdminMemberActiveCardSummary, + AdminMemberSummary, + AdminMemberUpcomingBooking, + AdminMemberBookingStats, + AdminMemberDetail, + UpdateAdminMemberProfileDto, +} from './user' export type { SubscriptionMessageRequestResult, SubscriptionMessageRequestItem, @@ -19,6 +30,7 @@ export type { TeachingScheduleSlot, BookingStatusHistory, CreateBookingDto, + AdminArrangeBookingDto, } from './booking' export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order' export type { diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 260c9b1..f3f49ed 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -1,4 +1,5 @@ -import { UserRole } from '../enums' +import { BookingStatus, CardTypeCategory, UserRole } from '../enums' +import type { MembershipWithCardType } from './membership' import type { SubscriptionMessageTemplateConfig } from './subscription' export interface User { @@ -9,6 +10,7 @@ export interface User { readonly nickname: string readonly avatarUrl: string | null readonly role: UserRole + readonly lastLoginAt: string | null readonly createdAt: string readonly updatedAt: string } @@ -38,3 +40,58 @@ export interface UserStatsResponse { readonly monthDays: number readonly monthHours: number } + +export interface AdminMemberActiveCardSummary { + readonly name: string + readonly type: CardTypeCategory +} + +export interface AdminMemberSummary { + readonly userId: string + readonly openid: string + readonly nickname: string + readonly phone: string | null + readonly avatarUrl: string | null + readonly createdAt: string + readonly lastLoginAt: string | null + readonly activeCard: AdminMemberActiveCardSummary | null + readonly totalBookings: number + readonly completedBookings: number + readonly cancelledBookings: number +} + +export interface AdminMemberUpcomingBooking { + readonly id: string + readonly status: BookingStatus + readonly date: string + readonly startTime: string + readonly endTime: string + readonly cardName: string +} + +export interface AdminMemberBookingStats { + readonly totalBookings: number + readonly completedBookings: number + readonly cancelledBookings: number + readonly noShowBookings: number +} + +export interface AdminMemberDetail { + readonly user: { + readonly userId: string + readonly openid: string + readonly nickname: string + readonly phone: string | null + readonly avatarUrl: string | null + readonly createdAt: string + readonly lastLoginAt: string | null + } + readonly memberships: readonly MembershipWithCardType[] + readonly stats: AdminMemberBookingStats + readonly upcomingBookings: readonly AdminMemberUpcomingBooking[] +} + +export interface UpdateAdminMemberProfileDto { + readonly nickname?: string + readonly phone?: string | null +}