206 lines
5.8 KiB
TypeScript
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 }
|
|
}
|
|
}
|