feat(server): add auth, user, and studio modules
Auth: WeChat login, JWT, roles guard (24 tests passing) User: profile CRUD, training stats with month/total calculations Studio: config management with auto-default creation
This commit is contained in:
120
packages/server/src/user/user.service.ts
Normal file
120
packages/server/src/user/user.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import type { UserProfileResponse, UserStatsResponse } from '@mp-pilates/shared'
|
||||
|
||||
@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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user