666 lines
20 KiB
TypeScript
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 },
|
|
})
|
|
}
|
|
}
|