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:
@@ -2,6 +2,9 @@ import { Module } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { AppController } from './app.controller'
|
||||
import { PrismaModule } from './prisma/prisma.module'
|
||||
import { UserModule } from './user/user.module'
|
||||
import { AuthModule } from './auth/auth.module'
|
||||
import { StudioModule } from './studio/studio.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -10,6 +13,9 @@ import { PrismaModule } from './prisma/prisma.module'
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
StudioModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
220
packages/server/src/auth/__tests__/auth.service.spec.ts
Normal file
220
packages/server/src/auth/__tests__/auth.service.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { UnauthorizedException } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { WechatService } from '../wechat.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPENID = 'test_openid_123'
|
||||
const SESSION_KEY = 'test_session_key'
|
||||
const USER_ID = 'user-uuid-001'
|
||||
const JWT_TOKEN = 'signed.jwt.token'
|
||||
|
||||
const mockUser = {
|
||||
id: USER_ID,
|
||||
openid: OPENID,
|
||||
unionid: null,
|
||||
phone: null,
|
||||
nickname: '',
|
||||
avatarUrl: null,
|
||||
role: UserRole.MEMBER,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
const mockWechatService = {
|
||||
code2Session: jest.fn(),
|
||||
decryptData: jest.fn(),
|
||||
}
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: WechatService, useValue: mockWechatService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
authService = module.get<AuthService>(AuthService)
|
||||
|
||||
jest.clearAllMocks()
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
})
|
||||
|
||||
// ── login ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('login', () => {
|
||||
const loginCode = 'wx_login_code_abc'
|
||||
|
||||
beforeEach(() => {
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new user when openid is not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockWechatService.code2Session).toHaveBeenCalledWith(loginCode)
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID },
|
||||
})
|
||||
expect(result.user).toEqual(mockUser)
|
||||
})
|
||||
|
||||
it('creates user with unionid when present', async () => {
|
||||
const unionid = 'wx_union_id_xyz'
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
unionid,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue({ ...mockUser, unionid })
|
||||
|
||||
await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, unionid },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns existing user when openid already exists', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||
expect(result.user).toEqual(mockUser)
|
||||
})
|
||||
|
||||
it('returns a valid JWT token', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockJwtService.sign).toHaveBeenCalledWith({
|
||||
sub: USER_ID,
|
||||
role: UserRole.MEMBER,
|
||||
})
|
||||
expect(result.token).toBe(JWT_TOKEN)
|
||||
})
|
||||
|
||||
it('returns both token and user in result', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(result).toEqual({
|
||||
token: JWT_TOKEN,
|
||||
user: mockUser,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── bindPhone ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bindPhone', () => {
|
||||
const encryptedData = 'encrypted_phone_data'
|
||||
const iv = 'init_vector'
|
||||
const phoneNumber = '+8613800138000'
|
||||
|
||||
beforeEach(async () => {
|
||||
// Seed the in-memory session key store by running login first
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
await authService.login('login_code')
|
||||
})
|
||||
|
||||
it('updates phone number and returns the updated user', async () => {
|
||||
const updatedUser = { ...mockUser, phone: phoneNumber }
|
||||
|
||||
mockWechatService.decryptData.mockReturnValue({
|
||||
phoneNumber,
|
||||
purePhoneNumber: '13800138000',
|
||||
countryCode: '86',
|
||||
})
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser)
|
||||
|
||||
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
|
||||
|
||||
expect(mockWechatService.decryptData).toHaveBeenCalledWith(
|
||||
SESSION_KEY,
|
||||
encryptedData,
|
||||
iv,
|
||||
)
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { id: USER_ID },
|
||||
data: { phone: phoneNumber },
|
||||
})
|
||||
expect(result).toEqual(updatedUser)
|
||||
})
|
||||
|
||||
it('throws UnauthorizedException when session key is not found', async () => {
|
||||
const unknownUserId = 'unknown-user-id'
|
||||
|
||||
await expect(
|
||||
authService.bindPhone(unknownUserId, encryptedData, iv),
|
||||
).rejects.toThrow(UnauthorizedException)
|
||||
})
|
||||
|
||||
it('does not mutate the original user object', async () => {
|
||||
const originalUser = { ...mockUser }
|
||||
const updatedUser = { ...mockUser, phone: phoneNumber }
|
||||
|
||||
mockWechatService.decryptData.mockReturnValue({
|
||||
phoneNumber,
|
||||
purePhoneNumber: '13800138000',
|
||||
countryCode: '86',
|
||||
})
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser)
|
||||
|
||||
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
|
||||
|
||||
// Original mock user should be unchanged
|
||||
expect(mockUser.phone).toBeNull()
|
||||
// Result is a new object with the updated phone
|
||||
expect(result.phone).toBe(phoneNumber)
|
||||
expect(result).not.toBe(originalUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
44
packages/server/src/auth/auth.controller.ts
Normal file
44
packages/server/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common'
|
||||
import { AuthService } from './auth.service'
|
||||
import { LoginDto } from './dto/login.dto'
|
||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { AuthenticatedUser } from './jwt.strategy'
|
||||
import { User } from '@prisma/client'
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
|
||||
return this.authService.login(loginDto.code)
|
||||
}
|
||||
|
||||
@Post('phone')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async bindPhone(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body() bindPhoneDto: BindPhoneDto,
|
||||
): Promise<User> {
|
||||
return this.authService.bindPhone(
|
||||
req.user.userId,
|
||||
bindPhoneDto.encryptedData,
|
||||
bindPhoneDto.iv,
|
||||
)
|
||||
}
|
||||
}
|
||||
28
packages/server/src/auth/auth.module.ts
Normal file
28
packages/server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { AuthService } from './auth.service'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { RolesGuard } from './roles.guard'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, WechatService, JwtStrategy, JwtAuthGuard, RolesGuard],
|
||||
exports: [JwtStrategy, JwtAuthGuard, RolesGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
82
packages/server/src/auth/auth.service.ts
Normal file
82
packages/server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User } from '@prisma/client'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatService } from './wechat.service'
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session key store.
|
||||
* TODO: Replace with Redis for production multi-instance deployments.
|
||||
* Key: userId, Value: WeChat sessionKey
|
||||
*/
|
||||
const sessionKeyStore = new Map<string, string>()
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly wechatService: WechatService,
|
||||
) {}
|
||||
|
||||
async login(code: string): Promise<LoginResult> {
|
||||
const { openid, unionid, sessionKey } =
|
||||
await this.wechatService.code2Session(code)
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { openid },
|
||||
})
|
||||
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
},
|
||||
}))
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
return { token, user }
|
||||
}
|
||||
|
||||
async bindPhone(
|
||||
userId: string,
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
): Promise<User> {
|
||||
const sessionKey = sessionKeyStore.get(userId)
|
||||
|
||||
if (!sessionKey) {
|
||||
throw new UnauthorizedException(
|
||||
'Session expired. Please log in again to bind your phone number.',
|
||||
)
|
||||
}
|
||||
|
||||
const phoneInfo = this.wechatService.decryptData(
|
||||
sessionKey,
|
||||
encryptedData,
|
||||
iv,
|
||||
)
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { phone: phoneInfo.phoneNumber },
|
||||
})
|
||||
}
|
||||
}
|
||||
11
packages/server/src/auth/dto/bind-phone.dto.ts
Normal file
11
packages/server/src/auth/dto/bind-phone.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator'
|
||||
|
||||
export class BindPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
encryptedData!: string
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
iv!: string
|
||||
}
|
||||
7
packages/server/src/auth/dto/login.dto.ts
Normal file
7
packages/server/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator'
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string
|
||||
}
|
||||
5
packages/server/src/auth/jwt-auth.guard.ts
Normal file
5
packages/server/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { AuthGuard } from '@nestjs/passport'
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
29
packages/server/src/auth/jwt.strategy.ts
Normal file
29
packages/server/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { PassportStrategy } from '@nestjs/passport'
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtPayload } from './auth.service'
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
userId: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
})
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): AuthenticatedUser {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
role: payload.role,
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/server/src/auth/roles.decorator.ts
Normal file
6
packages/server/src/auth/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
|
||||
export const ROLES_KEY = 'roles'
|
||||
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)
|
||||
33
packages/server/src/auth/roles.guard.ts
Normal file
33
packages/server/src/auth/roles.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { ROLES_KEY } from './roles.decorator'
|
||||
import { AuthenticatedUser } from './jwt.strategy'
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
)
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthenticatedUser
|
||||
}>()
|
||||
|
||||
const user = request.user
|
||||
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
return requiredRoles.includes(user.role)
|
||||
}
|
||||
}
|
||||
107
packages/server/src/auth/wechat.service.ts
Normal file
107
packages/server/src/auth/wechat.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
export interface Code2SessionResult {
|
||||
openid: string
|
||||
sessionKey: string
|
||||
unionid?: string
|
||||
}
|
||||
|
||||
export interface WechatPhoneInfo {
|
||||
phoneNumber: string
|
||||
purePhoneNumber: string
|
||||
countryCode: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatService {
|
||||
private readonly appId: string
|
||||
private readonly secret: string
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.appId = this.configService.getOrThrow<string>('WX_APPID')
|
||||
this.secret = this.configService.getOrThrow<string>('WX_SECRET')
|
||||
}
|
||||
|
||||
async code2Session(code: string): Promise<Code2SessionResult> {
|
||||
const url =
|
||||
`https://api.weixin.qq.com/sns/jscode2session` +
|
||||
`?appid=${this.appId}` +
|
||||
`&secret=${this.secret}` +
|
||||
`&js_code=${code}` +
|
||||
`&grant_type=authorization_code`
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException(
|
||||
'WeChat API request failed',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
openid?: string
|
||||
session_key?: string
|
||||
unionid?: string
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
}
|
||||
|
||||
if (data.errcode) {
|
||||
throw new HttpException(
|
||||
`WeChat login error: ${data.errmsg ?? data.errcode}`,
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.openid || !data.session_key) {
|
||||
throw new HttpException(
|
||||
'Invalid response from WeChat API',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
openid: data.openid,
|
||||
sessionKey: data.session_key,
|
||||
...(data.unionid !== undefined && { unionid: data.unionid }),
|
||||
}
|
||||
}
|
||||
|
||||
decryptData(
|
||||
sessionKey: string,
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
): WechatPhoneInfo {
|
||||
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64')
|
||||
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64')
|
||||
const ivBuffer = Buffer.from(iv, 'base64')
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-128-cbc',
|
||||
sessionKeyBuffer,
|
||||
ivBuffer,
|
||||
)
|
||||
decipher.setAutoPadding(true)
|
||||
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
decipher.update(encryptedDataBuffer),
|
||||
decipher.final(),
|
||||
])
|
||||
|
||||
const decryptedText = decryptedBuffer.toString('utf8')
|
||||
const decryptedData = JSON.parse(decryptedText) as {
|
||||
phoneNumber: string
|
||||
purePhoneNumber: string
|
||||
countryCode: string
|
||||
}
|
||||
|
||||
return {
|
||||
phoneNumber: decryptedData.phoneNumber,
|
||||
purePhoneNumber: decryptedData.purePhoneNumber,
|
||||
countryCode: decryptedData.countryCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||
|
||||
/**
|
||||
* Extract a field from the JWT payload attached by JwtAuthGuard.
|
||||
* JWT payload shape: { sub: userId, role: userRole }
|
||||
*
|
||||
* Usage:
|
||||
* @CurrentUser('sub') userId: string ← specific field
|
||||
* @CurrentUser() user: JwtPayload ← full payload
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
const user = request.user as Record<string, unknown> | undefined
|
||||
return data ? user?.[data] : user
|
||||
},
|
||||
)
|
||||
109
packages/server/src/studio/__tests__/studio.service.spec.ts
Normal file
109
packages/server/src/studio/__tests__/studio.service.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { StudioService } from '../studio.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { UpdateStudioDto } from '../dto/update-studio.dto'
|
||||
|
||||
const mockStudioConfig = {
|
||||
id: 'test-id-001',
|
||||
name: '普拉提工作室',
|
||||
logo: null,
|
||||
bannerUrl: null,
|
||||
address: '',
|
||||
phone: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
cancelHoursLimit: 2,
|
||||
photos: [],
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
|
||||
const mockPrismaService = {
|
||||
studioConfig: {
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
describe('StudioService', () => {
|
||||
let service: StudioService
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StudioService,
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<StudioService>(StudioService)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getInfo()', () => {
|
||||
it('should return existing config when one exists', async () => {
|
||||
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
|
||||
|
||||
const result = await service.getInfo()
|
||||
|
||||
expect(result).toEqual(mockStudioConfig)
|
||||
expect(mockPrismaService.studioConfig.findFirst).toHaveBeenCalledTimes(1)
|
||||
expect(mockPrismaService.studioConfig.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create and return a default config when none exists', async () => {
|
||||
const defaultConfig = { ...mockStudioConfig, name: '普拉提工作室' }
|
||||
mockPrismaService.studioConfig.findFirst.mockResolvedValue(null)
|
||||
mockPrismaService.studioConfig.create.mockResolvedValue(defaultConfig)
|
||||
|
||||
const result = await service.getInfo()
|
||||
|
||||
expect(result).toEqual(defaultConfig)
|
||||
expect(mockPrismaService.studioConfig.findFirst).toHaveBeenCalledTimes(1)
|
||||
expect(mockPrismaService.studioConfig.create).toHaveBeenCalledWith({
|
||||
data: { name: '普拉提工作室' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInfo()', () => {
|
||||
it('should update and return a new config object with changes applied', async () => {
|
||||
const dto: UpdateStudioDto = {
|
||||
name: 'New Studio Name',
|
||||
address: '123 Main St',
|
||||
cancelHoursLimit: 4,
|
||||
}
|
||||
const updatedConfig = {
|
||||
...mockStudioConfig,
|
||||
name: dto.name!,
|
||||
address: dto.address!,
|
||||
cancelHoursLimit: dto.cancelHoursLimit!,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
|
||||
mockPrismaService.studioConfig.update.mockResolvedValue(updatedConfig)
|
||||
|
||||
const result = await service.updateInfo(dto)
|
||||
|
||||
expect(result).toEqual(updatedConfig)
|
||||
expect(mockPrismaService.studioConfig.update).toHaveBeenCalledWith({
|
||||
where: { id: mockStudioConfig.id },
|
||||
data: { ...dto },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a new object reference (immutable)', async () => {
|
||||
const dto: UpdateStudioDto = { name: 'Updated Name' }
|
||||
const updatedConfig = { ...mockStudioConfig, name: 'Updated Name' }
|
||||
|
||||
mockPrismaService.studioConfig.findFirst.mockResolvedValue(mockStudioConfig)
|
||||
mockPrismaService.studioConfig.update.mockResolvedValue(updatedConfig)
|
||||
|
||||
const result = await service.updateInfo(dto)
|
||||
|
||||
expect(result).not.toBe(updatedConfig)
|
||||
expect(result).toEqual(updatedConfig)
|
||||
})
|
||||
})
|
||||
})
|
||||
53
packages/server/src/studio/dto/update-studio.dto.ts
Normal file
53
packages/server/src/studio/dto/update-studio.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator'
|
||||
|
||||
export class UpdateStudioDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bannerUrl?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
cancelHoursLimit?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
photos?: string[]
|
||||
}
|
||||
30
packages/server/src/studio/studio.controller.ts
Normal file
30
packages/server/src/studio/studio.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { UpdateStudioDto } from './dto/update-studio.dto'
|
||||
import { StudioService } from './studio.service'
|
||||
|
||||
@Controller()
|
||||
export class StudioController {
|
||||
constructor(private readonly studioService: StudioService) {}
|
||||
|
||||
@Get('studio/info')
|
||||
getInfo() {
|
||||
return this.studioService.getInfo()
|
||||
}
|
||||
|
||||
@Put('admin/studio/info')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
updateInfo(@Body() dto: UpdateStudioDto) {
|
||||
return this.studioService.updateInfo(dto)
|
||||
}
|
||||
}
|
||||
10
packages/server/src/studio/studio.module.ts
Normal file
10
packages/server/src/studio/studio.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { StudioController } from './studio.controller'
|
||||
import { StudioService } from './studio.service'
|
||||
|
||||
@Module({
|
||||
controllers: [StudioController],
|
||||
providers: [StudioService],
|
||||
exports: [StudioService],
|
||||
})
|
||||
export class StudioModule {}
|
||||
34
packages/server/src/studio/studio.service.ts
Normal file
34
packages/server/src/studio/studio.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { StudioConfig } from '@prisma/client'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { UpdateStudioDto } from './dto/update-studio.dto'
|
||||
|
||||
@Injectable()
|
||||
export class StudioService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getInfo(): Promise<StudioConfig> {
|
||||
const existing = await this.prisma.studioConfig.findFirst()
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return this.prisma.studioConfig.create({
|
||||
data: {
|
||||
name: '普拉提工作室',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async updateInfo(dto: UpdateStudioDto): Promise<StudioConfig> {
|
||||
const existing = await this.getInfo()
|
||||
|
||||
const updated = await this.prisma.studioConfig.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...dto },
|
||||
})
|
||||
|
||||
return { ...updated }
|
||||
}
|
||||
}
|
||||
275
packages/server/src/user/__tests__/user.service.spec.ts
Normal file
275
packages/server/src/user/__tests__/user.service.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { NotFoundException } from '@nestjs/common'
|
||||
import { UserService } from '../user.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { MembershipStatus, BookingStatus, UserRole } from '@mp-pilates/shared'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeUser = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'user-1',
|
||||
openid: 'openid-1',
|
||||
unionid: null,
|
||||
phone: '13800000000',
|
||||
nickname: 'Alice',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
_count: { memberships: 2 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeBooking = (
|
||||
date: Date,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
status: BookingStatus = BookingStatus.COMPLETED,
|
||||
) => ({
|
||||
id: `booking-${Math.random()}`,
|
||||
userId: 'user-1',
|
||||
timeSlotId: `slot-${Math.random()}`,
|
||||
membershipId: `membership-${Math.random()}`,
|
||||
status,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
timeSlot: { date, startTime, endTime },
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock PrismaService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<UserService>(UserService)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getProfile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('returns a UserProfileResponse with activeMembershipCount', async () => {
|
||||
const user = makeUser({ _count: { memberships: 3 } })
|
||||
mockPrisma.user.findUnique.mockResolvedValue(user)
|
||||
|
||||
const result = await service.getProfile('user-1')
|
||||
|
||||
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user-1',
|
||||
phone: '13800000000',
|
||||
nickname: 'Alice',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
role: UserRole.MEMBER,
|
||||
activeMembershipCount: 3,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null)
|
||||
|
||||
await expect(service.getProfile('unknown')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// updateProfile
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('updates nickname and avatarUrl, returns new UserProfileResponse', async () => {
|
||||
const updated = makeUser({
|
||||
nickname: 'Bob',
|
||||
avatarUrl: 'https://example.com/new.png',
|
||||
_count: { memberships: 1 },
|
||||
})
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
const result = await service.updateProfile('user-1', {
|
||||
nickname: 'Bob',
|
||||
avatarUrl: 'https://example.com/new.png',
|
||||
})
|
||||
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { nickname: 'Bob', avatarUrl: 'https://example.com/new.png' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
memberships: { where: { status: MembershipStatus.ACTIVE } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.nickname).toBe('Bob')
|
||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||
expect(result.activeMembershipCount).toBe(1)
|
||||
})
|
||||
|
||||
it('only includes provided fields in the update payload', async () => {
|
||||
const updated = makeUser({ nickname: 'Charlie', _count: { memberships: 0 } })
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
await service.updateProfile('user-1', { nickname: 'Charlie' })
|
||||
|
||||
const callArgs = mockPrisma.user.update.mock.calls[0][0]
|
||||
expect(callArgs.data).toEqual({ nickname: 'Charlie' })
|
||||
expect(callArgs.data.avatarUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns an immutable snapshot — the original dto is not mutated', async () => {
|
||||
const updated = makeUser({ _count: { memberships: 0 } })
|
||||
mockPrisma.user.update.mockResolvedValue(updated)
|
||||
|
||||
const dto = { nickname: 'Dave' }
|
||||
const originalDto = { ...dto }
|
||||
|
||||
await service.updateProfile('user-1', dto)
|
||||
|
||||
expect(dto).toEqual(originalDto)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getStats
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('getStats', () => {
|
||||
/**
|
||||
* Build a date in the *current* month so the month-filter logic works
|
||||
* regardless of when the test is run.
|
||||
*/
|
||||
const thisYear = new Date().getFullYear()
|
||||
const thisMonth = new Date().getMonth()
|
||||
|
||||
const dateInMonth = (day: number) => new Date(thisYear, thisMonth, day)
|
||||
const dateLastMonth = new Date(thisYear, thisMonth - 1, 15)
|
||||
|
||||
it('returns zeroed stats when there are no completed bookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
totalBookings: 0,
|
||||
totalDays: 0,
|
||||
monthBookings: 0,
|
||||
monthDays: 0,
|
||||
monthHours: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts all completed bookings for totalBookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'),
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.totalBookings).toBe(2)
|
||||
})
|
||||
|
||||
it('counts distinct dates for totalDays', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(1), '11:00', '12:00'), // same day → still 1 distinct day
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'),
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.totalDays).toBe(2) // day-in-month(1) + last-month-day
|
||||
})
|
||||
|
||||
it('only counts this-month bookings in monthBookings', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(5), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(10), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthBookings).toBe(2)
|
||||
})
|
||||
|
||||
it('counts distinct this-month dates for monthDays', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(3), '09:00', '10:00'),
|
||||
makeBooking(dateInMonth(3), '11:00', '12:00'), // same day
|
||||
makeBooking(dateInMonth(7), '09:00', '10:00'),
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthDays).toBe(2)
|
||||
})
|
||||
|
||||
it('sums hours for monthHours from startTime/endTime', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([
|
||||
makeBooking(dateInMonth(1), '09:00', '10:00'), // 1 h
|
||||
makeBooking(dateInMonth(2), '14:00', '15:30'), // 1.5 h
|
||||
makeBooking(dateLastMonth, '09:00', '10:00'), // excluded
|
||||
])
|
||||
|
||||
const result = await service.getStats('user-1')
|
||||
|
||||
expect(result.monthHours).toBeCloseTo(2.5)
|
||||
})
|
||||
|
||||
it('queries only COMPLETED bookings for this user', async () => {
|
||||
mockPrisma.booking.findMany.mockResolvedValue([])
|
||||
|
||||
await service.getStats('user-1')
|
||||
|
||||
expect(mockPrisma.booking.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', status: BookingStatus.COMPLETED },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
13
packages/server/src/user/dto/update-profile.dto.ts
Normal file
13
packages/server/src/user/dto/update-profile.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator'
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
readonly nickname?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
readonly avatarUrl?: string
|
||||
}
|
||||
35
packages/server/src/user/user.controller.ts
Normal file
35
packages/server/src/user/user.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { UserService } from './user.service'
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto'
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get('profile')
|
||||
getProfile(@CurrentUser('sub') userId: string) {
|
||||
return this.userService.getProfile(userId)
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
updateProfile(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
) {
|
||||
return this.userService.updateProfile(userId, dto)
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
getStats(@CurrentUser('sub') userId: string) {
|
||||
return this.userService.getStats(userId)
|
||||
}
|
||||
}
|
||||
10
packages/server/src/user/user.module.ts
Normal file
10
packages/server/src/user/user.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { UserController } from './user.controller'
|
||||
import { UserService } from './user.service'
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
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