import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common' import { MembershipStatus, BookingStatus, UserRole, CardTypeCategory, SubscriptionMessageScene, } from '@mp-pilates/shared' import type { PaginatedData, UserProfileResponse, UserStatsResponse, SubscriptionMessageConsentSummary, SubscriptionMessageRequestItem, SubscriptionMessageRequestResult, SubscriptionMessageTemplate, SubscriptionMessageTemplateConfig, AdminMemberSummary, AdminMemberDetail, MembershipWithCardType, UpdateAdminMemberProfileDto, } from '@mp-pilates/shared' import { ConfigService } from '@nestjs/config' import { PrismaService } from '../prisma/prisma.service' 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] @Injectable() export class UserService { constructor( private readonly prisma: PrismaService, private readonly configService: ConfigService, ) {} private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig { const templates = [ { templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''), scene: SubscriptionMessageScene.BOOKING_CREATED, description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送', usageTarget: 'consent' as const, }, { templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''), scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED, description: '管理员主动增加预约提醒次数,用于接收学员新预约通知', usageTarget: 'counter' as const, }, ] satisfies SubscriptionMessageTemplate[] return { templates: templates.filter((item) => item.templateId), } } private mapProfile(user: { id: string phone: string | null nickname: string avatarUrl: string | null role: string adminBookingSubscriptionCount: number createdAt: Date _count: { memberships: number } }): UserProfileResponse { return { id: user.id, phone: user.phone, nickname: user.nickname, avatarUrl: user.avatarUrl, role: user.role as UserRole, activeMembershipCount: user._count.memberships, inviteShareEligible: user._count.memberships > 0, adminBookingSubscriptionCount: user.adminBookingSubscriptionCount, subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(), createdAt: user.createdAt.toISOString(), } } async getProfile(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE }, }, }, }, }, }) if (!user) { throw new NotFoundException('User not found') } return this.mapProfile(user) } async updateProfile( userId: string, dto: { nickname?: string; avatarUrl?: string }, ): Promise { const updated = await this.prisma.user.update({ where: { id: userId }, data: { ...(dto.nickname !== undefined && { nickname: dto.nickname }), ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE }, }, }, }, }, }) return this.mapProfile(updated) } getSubscriptionMessageTemplates(): SubscriptionMessageTemplateConfig { return this.buildSubscriptionTemplateConfig() } async reportSubscriptionMessageRequests( userId: string, requests: readonly SubscriptionMessageRequestItem[], ): Promise { if (requests.length === 0) { return [] } await Promise.all( requests.map((item) => this.prisma.subscriptionMessageConsent.upsert({ where: { userId_templateId_scene: { userId, templateId: item.templateId, scene: item.scene, }, }, create: { userId, templateId: item.templateId, scene: item.scene, totalRequestCount: 1, acceptCount: item.result === 'accept' ? 1 : 0, rejectCount: item.result === 'reject' ? 1 : 0, banCount: item.result === 'ban' ? 1 : 0, filterCount: item.result === 'filter' ? 1 : 0, sentCount: 0, lastResult: item.result, lastRequestedAt: new Date(), }, update: { totalRequestCount: { increment: 1 }, acceptCount: { increment: item.result === 'accept' ? 1 : 0 }, rejectCount: { increment: item.result === 'reject' ? 1 : 0 }, banCount: { increment: item.result === 'ban' ? 1 : 0 }, filterCount: { increment: item.result === 'filter' ? 1 : 0 }, lastResult: item.result, lastRequestedAt: new Date(), }, })), ) const summaries = await this.prisma.subscriptionMessageConsent.findMany({ where: { userId, OR: requests.map((item) => ({ templateId: item.templateId, scene: item.scene, })), }, orderBy: { updatedAt: 'desc' }, }) return summaries.map((item: SubscriptionMessageConsentRecord) => ({ userId: item.userId, templateId: item.templateId, scene: item.scene as SubscriptionMessageScene, totalRequestCount: item.totalRequestCount, acceptCount: item.acceptCount, rejectCount: item.rejectCount, banCount: item.banCount, filterCount: item.filterCount, sentCount: item.sentCount, lastResult: item.lastResult as SubscriptionMessageRequestResult, lastRequestedAt: item.lastRequestedAt.toISOString(), lastSentAt: item.lastSentAt?.toISOString() ?? null, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), })) } async grantAdminBookingSubscriptionCount(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE }, }, }, }, }, }) if (!user) { throw new NotFoundException('User not found') } if (user.role !== UserRole.ADMIN) { return this.mapProfile(user) } const updated = await this.prisma.user.update({ where: { id: userId }, data: { adminBookingSubscriptionCount: { increment: ADMIN_BOOKING_SUBSCRIPTION_INCREMENT, }, }, include: { _count: { select: { memberships: { where: { status: MembershipStatus.ACTIVE }, }, }, }, }, }) return this.mapProfile(updated) } async getStats(userId: string): Promise { const completedBookings = await this.prisma.booking.findMany({ where: { userId, status: BookingStatus.COMPLETED, }, include: { timeSlot: { select: { date: true, startTime: true, endTime: true, }, }, }, }) const supplements = await this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true }, }) const now = new Date() const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) const monthBookings = completedBookings.filter((b) => { const slotDate = new Date(b.timeSlot.date) return slotDate >= monthStart && slotDate <= monthEnd }) const totalDays = new Set( completedBookings.map((b) => b.timeSlot.date.toISOString().split('T')[0]), ).size const monthDays = new Set( monthBookings.map((b) => b.timeSlot.date.toISOString().split('T')[0]), ).size const monthHours = monthBookings.reduce((sum, b) => { const [startH, startM] = b.timeSlot.startTime.split(':').map(Number) const [endH, endM] = b.timeSlot.endTime.split(':').map(Number) const durationMinutes = endH * 60 + endM - (startH * 60 + startM) return sum + durationMinutes / 60 }, 0) return { totalBookings: completedBookings.length + (supplements._sum.quantity ?? 0), totalDays, monthBookings: monthBookings.length, monthDays, monthHours, } } // ─── Admin: paginated member list ───────────────────────────────────────── async getMembers( page: number, limit: number, search?: string, cardType?: string, ): Promise> { const where: { OR?: Array<{ [key: string]: unknown }> memberships?: { some: { status: MembershipStatus cardType?: { type: CardTypeCategory } } } NOT?: { memberships?: { some: { status: MembershipStatus } } } } = search ? { OR: [ { nickname: { contains: search, mode: 'insensitive' as const } }, { openid: { contains: search, mode: 'insensitive' as const } }, { phone: { contains: search } }, ], } : {} // ACTIVE and NONE are complementary membership-status filters. if (cardType === 'NONE') { where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } } } else if (cardType === 'ACTIVE') { where.memberships = { some: { status: MembershipStatus.ACTIVE } } } else if (cardType && VALID_CARD_TYPES.has(cardType)) { where.memberships = { some: { status: MembershipStatus.ACTIVE, cardType: { type: cardType as CardTypeCategory }, }, } } const [users, total] = await Promise.all([ this.prisma.user.findMany({ where, select: { id: true, openid: true, 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, }, }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), this.prisma.user.count({ where }), ]) // Batch-fetch booking stats for the page of users const userIds = users.map((u) => u.id) const bookingStats = userIds.length ? await this.prisma.booking.groupBy({ by: ['userId', 'status'], where: { userId: { in: userIds } }, _count: { id: true }, }) : [] const statsMap = new Map() for (const stat of bookingStats) { const entry = statsMap.get(stat.userId) ?? { total: 0, completed: 0, cancelled: 0 } entry.total += stat._count.id if (stat.status === BookingStatus.COMPLETED) entry.completed += stat._count.id if (stat.status === BookingStatus.CANCELLED) entry.cancelled += stat._count.id statsMap.set(stat.userId, entry) } const supplements = userIds.length ? await this.prisma.lessonSupplement.groupBy({ by: ['userId'], where: { userId: { in: userIds }, revokedAt: null }, _sum: { quantity: true }, }) : [] const supplementMap = new Map(supplements.map(row => [row.userId, row._sum.quantity ?? 0])) 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 + (supplementMap.get(u.id) ?? 0), cancelledBookings: s.cancelled, } }) 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 } const supplements = await this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true }, }) 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 + (supplements._sum.quantity ?? 0), 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) { const membership = await this.prisma.membership.findFirst({ where: { userId }, include: { cardType: true }, }) return { membership } } async updateUserMembership(userId: string, dto: UpdateUserMembershipDto) { const now = new Date() const expireDate = new Date(dto.expireDate) const remainingTimes = dto.remainingTimes ?? null const isCountLimited = remainingTimes !== null let status: MembershipStatus = MembershipStatus.ACTIVE if (expireDate < now) { status = MembershipStatus.EXPIRED } else if (isCountLimited && remainingTimes <= 0) { status = MembershipStatus.USED_UP } const data = { cardTypeId: dto.cardTypeId, remainingTimes, startDate: new Date(dto.startDate), expireDate: new Date(dto.expireDate), status, } 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) { const totalTimes = remainingTimes === null ? null : existing.cardTypeId === dto.cardTypeId ? Math.max(existing.totalTimes ?? 0, remainingTimes) : remainingTimes return this.prisma.membership.update({ where: { id: existing.id }, data: { ...data, totalTimes }, include: { cardType: true }, }) } return this.prisma.membership.create({ data: { userId, totalTimes: remainingTimes, ...data }, include: { cardType: true }, }) } 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 }, }) } }