diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 42ce717..119cbd5 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -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], }) diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts new file mode 100644 index 0000000..1e7f06c --- /dev/null +++ b/packages/server/src/auth/__tests__/auth.service.spec.ts @@ -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) + + 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) + }) + }) +}) diff --git a/packages/server/src/auth/auth.controller.ts b/packages/server/src/auth/auth.controller.ts new file mode 100644 index 0000000..b34f90a --- /dev/null +++ b/packages/server/src/auth/auth.controller.ts @@ -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 { + return this.authService.bindPhone( + req.user.userId, + bindPhoneDto.encryptedData, + bindPhoneDto.iv, + ) + } +} diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts new file mode 100644 index 0000000..a27a374 --- /dev/null +++ b/packages/server/src/auth/auth.module.ts @@ -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('JWT_SECRET'), + signOptions: { expiresIn: '7d' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, WechatService, JwtStrategy, JwtAuthGuard, RolesGuard], + exports: [JwtStrategy, JwtAuthGuard, RolesGuard, AuthService], +}) +export class AuthModule {} diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts new file mode 100644 index 0000000..a04463b --- /dev/null +++ b/packages/server/src/auth/auth.service.ts @@ -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() + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly wechatService: WechatService, + ) {} + + async login(code: string): Promise { + 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 { + 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 }, + }) + } +} diff --git a/packages/server/src/auth/dto/bind-phone.dto.ts b/packages/server/src/auth/dto/bind-phone.dto.ts new file mode 100644 index 0000000..a57772e --- /dev/null +++ b/packages/server/src/auth/dto/bind-phone.dto.ts @@ -0,0 +1,11 @@ +import { IsString, IsNotEmpty } from 'class-validator' + +export class BindPhoneDto { + @IsString() + @IsNotEmpty() + encryptedData!: string + + @IsString() + @IsNotEmpty() + iv!: string +} diff --git a/packages/server/src/auth/dto/login.dto.ts b/packages/server/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..78f83b9 --- /dev/null +++ b/packages/server/src/auth/dto/login.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsNotEmpty } from 'class-validator' + +export class LoginDto { + @IsString() + @IsNotEmpty() + code!: string +} diff --git a/packages/server/src/auth/jwt-auth.guard.ts b/packages/server/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..aa859f7 --- /dev/null +++ b/packages/server/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common' +import { AuthGuard } from '@nestjs/passport' + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/packages/server/src/auth/jwt.strategy.ts b/packages/server/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..5cdbaba --- /dev/null +++ b/packages/server/src/auth/jwt.strategy.ts @@ -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('JWT_SECRET'), + }) + } + + validate(payload: JwtPayload): AuthenticatedUser { + return { + userId: payload.sub, + role: payload.role, + } + } +} diff --git a/packages/server/src/auth/roles.decorator.ts b/packages/server/src/auth/roles.decorator.ts new file mode 100644 index 0000000..9454222 --- /dev/null +++ b/packages/server/src/auth/roles.decorator.ts @@ -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) diff --git a/packages/server/src/auth/roles.guard.ts b/packages/server/src/auth/roles.guard.ts new file mode 100644 index 0000000..8ae2249 --- /dev/null +++ b/packages/server/src/auth/roles.guard.ts @@ -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( + 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) + } +} diff --git a/packages/server/src/auth/wechat.service.ts b/packages/server/src/auth/wechat.service.ts new file mode 100644 index 0000000..551ab8a --- /dev/null +++ b/packages/server/src/auth/wechat.service.ts @@ -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('WX_APPID') + this.secret = this.configService.getOrThrow('WX_SECRET') + } + + async code2Session(code: string): Promise { + 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, + } + } +} diff --git a/packages/server/src/common/decorators/current-user.decorator.ts b/packages/server/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..5b286b3 --- /dev/null +++ b/packages/server/src/common/decorators/current-user.decorator.ts @@ -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 | undefined + return data ? user?.[data] : user + }, +) diff --git a/packages/server/src/studio/__tests__/studio.service.spec.ts b/packages/server/src/studio/__tests__/studio.service.spec.ts new file mode 100644 index 0000000..3b11f7b --- /dev/null +++ b/packages/server/src/studio/__tests__/studio.service.spec.ts @@ -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) + 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) + }) + }) +}) diff --git a/packages/server/src/studio/dto/update-studio.dto.ts b/packages/server/src/studio/dto/update-studio.dto.ts new file mode 100644 index 0000000..1a85db3 --- /dev/null +++ b/packages/server/src/studio/dto/update-studio.dto.ts @@ -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[] +} diff --git a/packages/server/src/studio/studio.controller.ts b/packages/server/src/studio/studio.controller.ts new file mode 100644 index 0000000..faeaca9 --- /dev/null +++ b/packages/server/src/studio/studio.controller.ts @@ -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) + } +} diff --git a/packages/server/src/studio/studio.module.ts b/packages/server/src/studio/studio.module.ts new file mode 100644 index 0000000..e297b57 --- /dev/null +++ b/packages/server/src/studio/studio.module.ts @@ -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 {} diff --git a/packages/server/src/studio/studio.service.ts b/packages/server/src/studio/studio.service.ts new file mode 100644 index 0000000..68c6604 --- /dev/null +++ b/packages/server/src/studio/studio.service.ts @@ -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 { + const existing = await this.prisma.studioConfig.findFirst() + + if (existing) { + return existing + } + + return this.prisma.studioConfig.create({ + data: { + name: '普拉提工作室', + }, + }) + } + + async updateInfo(dto: UpdateStudioDto): Promise { + const existing = await this.getInfo() + + const updated = await this.prisma.studioConfig.update({ + where: { id: existing.id }, + data: { ...dto }, + }) + + return { ...updated } + } +} diff --git a/packages/server/src/user/__tests__/user.service.spec.ts b/packages/server/src/user/__tests__/user.service.spec.ts new file mode 100644 index 0000000..81fec5e --- /dev/null +++ b/packages/server/src/user/__tests__/user.service.spec.ts @@ -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 = {}) => ({ + 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) + 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 }, + }), + ) + }) + }) +}) diff --git a/packages/server/src/user/dto/update-profile.dto.ts b/packages/server/src/user/dto/update-profile.dto.ts new file mode 100644 index 0000000..22fed44 --- /dev/null +++ b/packages/server/src/user/dto/update-profile.dto.ts @@ -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 +} diff --git a/packages/server/src/user/user.controller.ts b/packages/server/src/user/user.controller.ts new file mode 100644 index 0000000..9f21e95 --- /dev/null +++ b/packages/server/src/user/user.controller.ts @@ -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) + } +} diff --git a/packages/server/src/user/user.module.ts b/packages/server/src/user/user.module.ts new file mode 100644 index 0000000..b2060ee --- /dev/null +++ b/packages/server/src/user/user.module.ts @@ -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 {} diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts new file mode 100644 index 0000000..e9b039c --- /dev/null +++ b/packages/server/src/user/user.service.ts @@ -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 { + 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 { + 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 { + 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, + } + } +}