Files
mp-pilates/packages/server/src/user/user.service.ts
2026-04-05 21:03:18 +08:00

206 lines
5.8 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common'
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
import type { PaginatedData, UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
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 {
id: user.id,
phone: user.phone,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount: user._count.memberships,
createdAt: user.createdAt.toISOString(),
}
}
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 {
id: updated.id,
phone: updated.phone,
nickname: updated.nickname,
avatarUrl: updated.avatarUrl,
role: updated.role as UserRole,
activeMembershipCount: updated._count.memberships,
createdAt: updated.createdAt.toISOString(),
}
}
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 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,
totalDays,
monthBookings: monthBookings.length,
monthDays,
monthHours,
}
}
// ─── Admin: paginated member list ─────────────────────────────────────────
async getMembers(
page: number,
limit: number,
search?: string,
): Promise<PaginatedData<{
userId: string
openid: string
nickname: string
phone: string | null
avatarUrl: string | null
totalBookings: number
completedBookings: number
cancelledBookings: number
}>> {
const where = search
? {
OR: [
{ nickname: { contains: search, mode: 'insensitive' as const } },
{ openid: { contains: search, mode: 'insensitive' as const } },
{ phone: { contains: search } },
],
}
: {}
const [users, total] = await Promise.all([
this.prisma.user.findMany({
where,
select: {
id: true,
openid: true,
nickname: true,
phone: true,
avatarUrl: true,
_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 items = users.map((u) => {
const s = statsMap.get(u.id) ?? { total: 0, completed: 0, cancelled: 0 }
return {
userId: u.id,
openid: u.openid,
nickname: u.nickname,
phone: u.phone,
avatarUrl: u.avatarUrl,
totalBookings: s.total,
completedBookings: s.completed,
cancelledBookings: s.cancelled,
}
})
return { items, total, page, limit }
}
}