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 @@
+
+
+
+
+
+
+
+
+ {{ (detail.user.nickname || '?').slice(0, 1) }}
+
+
+
+ 为学员安排
+ {{ detail.user.nickname || '未知用户' }}
+
+
+
+
+
+
+
+ {{ card.cardType.name }}
+
+ {{ card.remainingTimes == null ? '月卡不扣次' : `剩 ${card.remainingTimes} 次` }}
+
+
+
+
+
+
+
+ 该会员没有可扣课的有效卡,请先开卡
+
+
+
+
+
+
+
+
+
+
+ 这天还没有可安排的课表
+ 可以去排课管理发布,或在下方加开一个时段
+
+ 前往排课管理
+
+
+
+
+
+
+ {{ slot.startTime.slice(0, 5) }}
+ {{ slot.endTime.slice(0, 5) }}
+
+
+ {{ slotLabel(slot) }}
+ {{ slot.bookedCount }}/{{ slot.capacity }} 人
+
+ {{ slotActionLabel(slot) }}
+
+
+
+
+ 加开此时段
+
+
+
+ {{ customStart }} – {{ customEnd }}
+ 选择开始时间
+
+
+
+ 加开并安排
+
+
+
+
+
+
+ 立即确认
+ 安排给 {{ detail?.user.nickname || '该会员' }}
+
+
+ 时间
+ {{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}–{{ pendingSlot.endTime.slice(0, 5) }}
+
+
+ 扣卡
+ {{ selectedMembership?.cardType.name }}
+
+
+ {{ deductHint }}
+
+
+ 取消
+
+
+ {{ arranging ? '安排中...' : '确认安排' }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ (detail.user.nickname || '?').slice(0, 1) }}
+
+
+
+ STUDIO LEDGER
+ {{ detail.user.nickname || '未知用户' }}
+ {{ detail.user.phone || '未绑定手机' }}
+ {{ detail.user.openid }}
+
+
+
+
+ 注册时间
+ {{ formatDateTimeFull(detail.user.createdAt) }}
+
+
+
+ 最近登录
+ {{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}
+
+
+
+
+
+ 上课情况
+
+
+ {{ detail.stats.totalBookings }}
+ 累计预约
+
+
+ {{ detail.stats.completedBookings }}
+ 已完成
+
+
+ {{ detail.stats.cancelledBookings }}
+ 已取消
+
+
+ 未到 {{ detail.stats.noShowBookings }} 次
+
+
+
+ 会员卡
+
+
+
+
+
+ {{ card.cardType.name }}
+ {{ getCardTypeLabel(card.cardType.type) }}
+
+
+ {{ membershipStatusLabel(card.status) }}
+
+
+
+ {{ card.remainingTimes }}
+ 次剩余
+
+
+
+
+
+
+ 已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
+
+
+
+ {{ formatDate(card.startDate) }} 起
+ {{ formatDate(card.expireDate) }} 止
+
+
+
+
+ 尚未开卡
+ 开通体验卡、次卡或月卡后即可安排课程
+
+ 去开卡
+
+
+
+
+
+ 即将上课
+
+
+
+ {{ item.date.slice(5) }}
+ {{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}
+
+
+ {{ item.cardName }}
+ {{ bookingStatusLabel(item.status) }}
+
+
+
+
+ 近期没有待上的课
+
+
+
+
+
+
+ 编辑资料
+
+
+ 安排课程
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ 加载中...
+
+
+
+
+ 基本信息
+
+ 昵称
+
+
+
+ 手机号
+
+
+
+
+
+ {{ editingMembership ? '会员卡' : '开通会员卡' }}
+
+
+ 选择卡片
+
+
+ {{ membershipPickerLabels[membershipIndex] }}
+ ▾
+
+
+
+
+
+ 卡类型
+
+
+ {{ cardTypes[editForm.cardTypeIndex]?.name || '请选择' }}
+ ▾
+
+
+
+
+
+ 剩余次数
+
+
+
+
+ 开始日期
+
+
+ {{ editForm.startDate || '请选择' }}
+ ▾
+
+
+
+
+
+ 到期日期
+
+
+ {{ editForm.expireDate || '请选择' }}
+ ▾
+
+
+
+
+
+ 解除该会员卡
+
+
+
+
+ {{ submitting ? '保存中...' : '保存' }}
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
- ×
-
-
-
-
-
-
- 详情
-
-
- 编辑
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ (detailMember.nickname || '?').slice(0, 1) }}
-
-
-
- {{ detailMember.nickname || '未知用户' }}
-
- {{ detailMember.openid }}
-
- {{ detailMember.phone || '未绑定手机' }}
-
-
-
-
-
-
- {{ detailMember.totalBookings }}
- 总预约
-
-
- {{ detailMember.completedBookings }}
- 已完成
-
-
- {{ detailMember.cancelledBookings }}
- 已取消
-
-
-
-
-
-
-
-
-
-
- 剩余课时
-
- {{ detailMembership.remainingTimes }}
- 次
-
-
-
-
-
-
-
-
- 已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }} 次
-
-
-
-
- 开始日期
- {{ formatDate(detailMembership.startDate) }}
-
-
- 到期日期
- {{ formatDate(detailMembership.expireDate) }}
-
-
-
-
-
-
-
-
- 💳
- 暂无会员卡
- 为该用户开通会员卡
-
- 去开卡
-
-
-
-
-
-
-
-
- 加载中...
-
-
-
-
-
-
-
-
-
- 卡类型
-
-
- {{ 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
+}