Files
mp-pilates/packages/server/src/user/user.service.ts

666 lines
20 KiB
TypeScript

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<string>(Object.values(CardTypeCategory))
const ADMIN_BOOKING_SUBSCRIPTION_INCREMENT = 1
function serializeMembership(membership: {
id: string
userId: string
cardTypeId: string
remainingTimes: number | null
totalTimes: number | null
startDate: Date
expireDate: Date
status: string
createdAt: Date
updatedAt: Date
cardType: {
id: string
name: string
type: string
totalTimes: number | null
durationDays: number
price: number | { toString(): string }
originalPrice: number | { toString(): string } | null
description: string | null
coverUrl: string | null
isActive: boolean
sortOrder: number
createdAt: Date
updatedAt: Date
}
}): MembershipWithCardType {
const { cardType, ...rest } = membership
return {
id: rest.id,
userId: rest.userId,
cardTypeId: rest.cardTypeId,
remainingTimes: rest.remainingTimes,
totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(),
expireDate: rest.expireDate.toISOString(),
status: rest.status as MembershipStatus,
createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(),
cardType: {
id: cardType.id,
name: cardType.name,
type: cardType.type as CardTypeCategory,
totalTimes: cardType.totalTimes,
durationDays: cardType.durationDays,
price: Number(cardType.price),
originalPrice: cardType.originalPrice == null ? null : Number(cardType.originalPrice),
description: cardType.description,
coverUrl: cardType.coverUrl,
isActive: cardType.isActive,
sortOrder: cardType.sortOrder,
createdAt: cardType.createdAt.toISOString(),
updatedAt: cardType.updatedAt.toISOString(),
},
}
}
type SubscriptionMessageConsentDelegate = PrismaService['subscriptionMessageConsent']
type SubscriptionMessageConsentRecord = Awaited<ReturnType<SubscriptionMessageConsentDelegate['findMany']>>[number]
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>('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<UserProfileResponse> {
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<UserProfileResponse> {
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<SubscriptionMessageConsentSummary[]> {
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<UserProfileResponse> {
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<UserStatsResponse> {
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<PaginatedData<AdminMemberSummary>> {
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<string, { total: number; completed: number; cancelled: number }>()
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<AdminMemberDetail> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
memberships: {
include: { cardType: true },
orderBy: [{ createdAt: 'desc' }],
},
},
})
if (!user) {
throw new NotFoundException('User not found')
}
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const [bookingStats, upcoming] = await Promise.all([
this.prisma.booking.groupBy({
by: ['status'],
where: { userId },
_count: { id: true },
}),
this.prisma.booking.findMany({
where: {
userId,
status: {
in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED],
},
timeSlot: { date: { gte: today } },
},
include: {
timeSlot: true,
membership: { include: { cardType: { select: { name: true } } } },
},
orderBy: [
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
],
}),
])
const stats = { total: 0, completed: 0, cancelled: 0, noShow: 0 }
for (const row of bookingStats) {
stats.total += row._count.id
if (row.status === BookingStatus.COMPLETED) stats.completed += row._count.id
if (row.status === BookingStatus.CANCELLED) stats.cancelled += row._count.id
if (row.status === BookingStatus.NO_SHOW) stats.noShow += row._count.id
}
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<AdminMemberDetail> {
const existing = await this.prisma.user.findUnique({ where: { id: userId } })
if (!existing) {
throw new NotFoundException('User not found')
}
const phone = dto.phone === undefined
? undefined
: (dto.phone?.trim() ? dto.phone.trim() : null)
await this.prisma.user.update({
where: { id: userId },
data: {
...(dto.nickname !== undefined && { nickname: dto.nickname.trim() }),
...(phone !== undefined && { phone }),
},
})
return this.getMemberDetail(userId)
}
// ─── Membership management ────────────────────────────────────────────────
async getUserMembership(userId: string) {
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<void> {
if (!membershipId) {
throw new BadRequestException('membershipId is required')
}
const existing = await this.prisma.membership.findFirst({
where: { id: membershipId, userId },
})
if (!existing) {
throw new NotFoundException('Membership not found')
}
await this.prisma.membership.update({
where: { id: existing.id },
data: { status: MembershipStatus.EXPIRED },
})
}
}