feat: 支持新版会员档案

This commit is contained in:
richarjiang
2026-10-18 14:59:48 +08:00
parent 21e3ece21c
commit b452de379d
23 changed files with 731 additions and 54 deletions

View File

@@ -0,0 +1,44 @@
import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { ExecutionContext } from '@nestjs/common'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { ShopAdminController, ShopController } from '../shop.controller'
import { ShopService } from '../shop.service'
import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
describe('Shop rollout HTTP compatibility', () => {
let app: INestApplication
const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID' }
const service = { orders: jest.fn().mockResolvedValue({ items: [order], total: 1 }), complete: jest.fn().mockResolvedValue({ ...order, status: 'COMPLETED' }) }
async function request(method: string, path: string, role?: string, body?: unknown) {
const address = app.getHttpServer().address() as AddressInfo
return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(role ? { 'x-test-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
}
beforeAll(async () => {
const module = await Test.createTestingModule({ controllers: [ShopController, ShopAdminController], providers: [{ provide: ShopService, useValue: service }, RolesGuard, ShopOrderCompatibilityInterceptor] })
.overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); if (!req.headers['x-test-role']) throw new UnauthorizedException(); req.user = { sub: 'test-operator', role: req.headers['x-test-role'] }; return true } }).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
app.useGlobalInterceptors(new ApiResponseInterceptor())
await app.listen(0, '127.0.0.1')
})
afterAll(async () => { await app.close() })
it.each(['/shop/orders', '/admin/shop/orders'])('supplies the old display field inside the API envelope: %s', async path => {
const response = await request('GET', path, 'ADMIN')
expect(response.status).toBe(200)
expect((await response.json()).data.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
})
it.each([{}, { pickupCode: 'ABCDEF123456' }])('accepts new and already-published administrator request bodies: %j', async body => {
const response = await request('POST', '/admin/shop/orders/order-1/complete', 'ADMIN', body)
expect(response.status).toBe(201)
expect((await response.json()).data.status).toBe('COMPLETED')
expect(service.complete).toHaveBeenCalledWith('order-1', 'test-operator')
})
it.each([undefined, 'MEMBER'])('retains administrator authorization for fulfillment: %s', async role => {
const response = await request('POST', '/admin/shop/orders/order-1/complete', role, {})
expect(response.status).toBe(role ? 403 : 401)
})
})

View File

@@ -0,0 +1,25 @@
import { CallHandler, ExecutionContext } from '@nestjs/common'
import { firstValueFrom, of } from 'rxjs'
import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
describe('Published shop client compatibility', () => {
const interceptor = new ShopOrderCompatibilityInterceptor()
const serialize = (value: unknown) => firstValueFrom(interceptor.intercept({} as ExecutionContext, { handle: () => of(value) } as CallHandler))
it('returns a stable 12-character code for existing and new orders without changing the stored order', async () => {
const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID', amount: 100 }
const result = await serialize(order) as Record<string, unknown>
expect(result.pickupCode).toMatch(/^[A-F0-9]{12}$/)
expect(await serialize(order)).toEqual(result)
expect(order).not.toHaveProperty('pickupCode')
expect(result).toMatchObject(order)
expect((await serialize({ ...order, id: 'order-2' }) as Record<string, unknown>).pickupCode).not.toBe(result.pickupCode)
})
it('preserves pagination while supplying codes to customer and administrator lists', async () => {
const result = await serialize({ items: [{ id: 'order-1', orderNo: 'SP001' }], total: 1, page: 2, limit: 20 }) as { items: { pickupCode: string }[] }
expect(result).toMatchObject({ total: 1, page: 2, limit: 20 })
expect(result.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
})
it.each([null, undefined, { id: 'product', title: '商品' }, { prepayId: 'wx123', paySign: 'signature' }, { orders: 2, amount: 300 }])('leaves non-order responses unchanged: %j', async value => {
expect(await serialize(value)).toBe(value)
})
})

View File

@@ -0,0 +1,24 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
import { createHash } from 'crypto'
import { map, Observable } from 'rxjs'
/** Keep already-published clients usable until their pickup-code UI is retired. */
function withLegacyPickupCode(value: unknown): unknown {
if (!value || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map(withLegacyPickupCode)
const record = value as Record<string, unknown>
if (typeof record.id === 'string' && typeof record.orderNo === 'string') {
// Display compatibility only: fulfillment is authorized by admin role and PAID status.
// Derive a stable value without retaining the removed database column.
return { ...record, pickupCode: createHash('sha256').update('legacy-pickup:' + record.id).digest('hex').slice(0, 12).toUpperCase() }
}
if (Array.isArray(record.items)) return { ...record, items: record.items.map(withLegacyPickupCode) }
return value
}
@Injectable()
export class ShopOrderCompatibilityInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(map(withLegacyPickupCode))
}
}

View File

@@ -10,6 +10,7 @@ import {
Put,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
@@ -18,8 +19,10 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ShopService } from './shop.service'
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Controller('shop')
@UseInterceptors(ShopOrderCompatibilityInterceptor)
export class ShopController {
constructor(private readonly shop: ShopService) {}
@Post('wx-notify')
@@ -67,6 +70,7 @@ export class ShopController {
}
@Controller('admin/shop')
@UseInterceptors(ShopOrderCompatibilityInterceptor)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class ShopAdminController {

View File

@@ -3,10 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module'
import { WechatPayService } from '../payment/wechat-pay.service'
import { ShopController, ShopAdminController } from './shop.controller'
import { ShopService } from './shop.service'
import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Module({
imports: [PrismaModule],
controllers: [ShopController, ShopAdminController],
providers: [ShopService, WechatPayService],
providers: [ShopService, WechatPayService, ShopOrderCompatibilityInterceptor],
exports: [ShopService],
})
export class ShopModule {}

View File

@@ -10,8 +10,9 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { emptyPostureAssessment, POSTURE_SECTIONS, togglePostureMark, isPostureAssessment } from '@mp-pilates/shared'
describe('Member progress permissions and measurements', () => {
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
let service: MemberProgressService
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
@@ -30,11 +31,71 @@ describe('Member progress permissions and measurements', () => {
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeNull()
})
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
})
it('continues to display historical measurements with no posture assessment', async () => {
const historical = { id: 'legacy', recordedAt: '2025-01-01', weight: 50, bodyFat: null, flexibility: -3, remark: '首次测量', height: null, assessment: null }
db.bodyMetric.findMany.mockResolvedValue([historical])
db.bodyMetric.findFirst.mockResolvedValue(historical)
expect((await service.archive('member')).metrics).toEqual([historical])
expect(await service.metric('member', 'legacy')).toEqual({ nickname: '学员', record: historical })
expect(db.bodyMetric.findFirst).toHaveBeenCalledWith({ where: { id: 'legacy', userId: 'member' } })
})
it('accepts legacy clients without new fields, posture-only records and notes-only records', async () => {
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', weight: 50 })
expect(db.bodyMetric.create.mock.calls[0][0].data).toMatchObject({ weight: 50, height: null, assessment: emptyPostureAssessment() })
const assessment = emptyPostureAssessment()
assessment.plumb.alignment = 'no'
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', assessment })
expect(db.bodyMetric.create.mock.calls[1][0].data).toMatchObject({ weight: null, assessment })
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', remark: '观察肩部排列' })
expect(db.bodyMetric.create.mock.calls[2][0].data.remark).toBe('观察肩部排列')
})
it('allows empty optional measurements, including explicit null when clearing an existing value', async () => {
expect(await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height: null, weight: null }))).toHaveLength(0)
for (const height of [0, 251, Infinity, '170']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height }))).length).toBeGreaterThan(0)
})
it('updates the existing member record in place and clears removed measurements and findings', async () => {
const assessment = emptyPostureAssessment()
assessment.spine.flat = 'yes'; assessment.spine.flatLocation = '下胸椎'
db.bodyMetric.updateMany.mockResolvedValue({ count: 1 })
db.bodyMetric.findFirst.mockResolvedValue({ id: 'existing', assessment })
await service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-02', weight: null, assessment })
expect(db.bodyMetric.updateMany).toHaveBeenCalledWith({ where: { id: 'existing', userId: 'member' }, data: expect.objectContaining({ weight: null, height: null, assessment, operatorId: 'admin', recordedAt: new Date('2025-01-02T00:00:00.000Z') }) })
expect(db.bodyMetric.create).not.toHaveBeenCalled()
})
it('cannot read or edit another member record and rejects invalid edits before writing', async () => {
db.bodyMetric.findFirst.mockResolvedValue(null)
db.bodyMetric.updateMany.mockResolvedValue({ count: 0 })
await expect(service.metric('other', 'existing')).rejects.toThrow('不存在')
await expect(service.updateMetric('other', 'existing', 'admin', { recordedAt: '2025-01-01', weight: 50 })).rejects.toThrow('不存在')
expect(db.bodyMetric.updateMany.mock.calls[0][0].where).toEqual({ id: 'existing', userId: 'other' })
db.bodyMetric.updateMany.mockClear()
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
expect(db.bodyMetric.updateMany).not.toHaveBeenCalled()
})
it('rejects malformed, unknown, wrong-side and contradictory posture payloads', async () => {
const badPayloads = [
[], { ...emptyPostureAssessment(), version: 2 },
{ ...emptyPostureAssessment(), answers: { unknown: ['right'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['checked'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right', 'right'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right'], 'side.ankle.plantar': ['right'] } },
{ ...emptyPostureAssessment(), plumb: { alignment: true, stacking: null } },
{ ...emptyPostureAssessment(), spine: { flat: 'no', flatLocation: 'stale location', imbalance: null, imbalanceLocation: '' } },
{ ...emptyPostureAssessment(), spine: { flat: 'yes', flatLocation: 'x'.repeat(201), imbalance: null, imbalanceLocation: '' } },
]
for (const assessment of badPayloads) {
const dto = Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', assessment })
expect((await validate(dto)).length).toBeGreaterThan(0)
await expect(service.addMetric('member', 'admin', dto)).rejects.toThrow('体态检查内容无效')
}
expect(db.bodyMetric.create).not.toHaveBeenCalled()
})
it('only annotates the target member completed booking', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
@@ -86,6 +147,42 @@ describe('Member progress permissions and measurements', () => {
})
})
describe('Posture checklist selection behavior', () => {
const allGroups = POSTURE_SECTIONS.flatMap(section => section.groups)
it('keeps left and right independent while replacing contradictory findings on the same side', () => {
const group = allGroups.find(group => group.id === 'side.ankle')!
const [neutral, plantar, dorsal] = group.options
let answers = togglePostureMark({}, group, neutral, 'left')
answers = togglePostureMark(answers, group, plantar, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'], [plantar.id]: ['right'] })
answers = togglePostureMark(answers, group, dorsal, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'], [dorsal.id]: ['right'] })
answers = togglePostureMark(answers, group, dorsal, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'] })
})
it('allows scapula findings in different planes and excludes neutral only on the affected side', () => {
const group = allGroups.find(group => group.id === 'back.scapula')!
const option = (id: string) => group.options.find(option => option.id === group.id + '.' + id)!
let answers = togglePostureMark({}, group, option('neutral'), 'left')
answers = togglePostureMark(answers, group, option('elevated'), 'right')
answers = togglePostureMark(answers, group, option('upward'), 'right')
answers = togglePostureMark(answers, group, option('winging'), 'right')
expect(Object.keys(answers)).toHaveLength(4)
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
answers = togglePostureMark(answers, group, option('neutral'), 'right')
expect(answers).toEqual({ [option('neutral').id]: ['left', 'right'] })
})
it('clears bilateral abnormalities when whole-body neutral is selected', () => {
const group = allGroups.find(group => group.id === 'front.head')!
const [clockwise, , neutral, tilt] = group.options
let answers = togglePostureMark({}, group, clockwise, 'checked')
answers = togglePostureMark(answers, group, tilt, 'right')
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
answers = togglePostureMark(answers, group, neutral, 'checked')
expect(answers).toEqual({ [neutral.id]: ['checked'] })
})
})
describe('Member progress authorization', () => {
it('requires authentication on member and admin progress controllers', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
@@ -99,4 +196,12 @@ describe('Member progress authorization', () => {
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
it('restricts record editing to admins and exposes no member write route', () => {
const handler = AdminMemberProgressController.prototype.updateMetric
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
expect('updateMetric' in MemberProgressController.prototype).toBe(false)
})
})

View File

@@ -1,11 +1,14 @@
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy } from 'class-validator'
import { isPostureAssessment, PostureAssessment } from '@mp-pilates/shared'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
@IsOptional() @IsNumber() @Min(30) @Max(250) height?: number | null
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number | null
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number | null
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number | null
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number | null
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number | null
@IsOptional() @ValidateBy({ name: 'postureAssessment', validator: { validate: isPostureAssessment, defaultMessage: () => '体态检查内容无效,请检查选项及左右侧标记' } }) assessment?: PostureAssessment | null
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
@@ -10,6 +10,7 @@ import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
@@ -19,6 +20,8 @@ export class AdminMemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
@Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }

View File

@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
@Injectable()
export class MemberProgressService {
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
@@ -29,8 +31,26 @@ export class MemberProgressService {
}
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
await this.member(userId)
if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
return this.prisma.bodyMetric.create({ data: { ...this.metricData(dto), userId, operatorId } })
}
async metric(userId: string, id: string) {
const member = await this.member(userId)
const record = await this.prisma.bodyMetric.findFirst({ where: { id, userId } })
if (!record) throw new NotFoundException('体测记录不存在')
return { nickname: member.nickname, record }
}
async updateMetric(userId: string, id: string, operatorId: string, dto: BodyMetricDto) {
const data = this.metricData(dto)
const result = await this.prisma.bodyMetric.updateMany({ where: { id, userId }, data: { ...data, operatorId } })
if (!result.count) throw new NotFoundException('体测记录不存在')
return this.prisma.bodyMetric.findFirst({ where: { id, userId } })
}
private metricData(dto: BodyMetricDto) {
const assessment = dto.assessment ?? emptyPostureAssessment()
if (!isPostureAssessment(assessment)) throw new BadRequestException('体态检查内容无效')
const measurements = Object.fromEntries(BODY_MEASUREMENT_FIELDS.map(field => [field.key, dto[field.key] ?? null]))
if (BODY_MEASUREMENT_FIELDS.every(field => dto[field.key] == null) && !postureAnswerCount(assessment) && !dto.remark?.trim()) throw new BadRequestException('请至少填写一项体测数据、体态检查或评估备注')
return { ...measurements, recordedAt: this.date(dto.recordedAt), remark: dto.remark?.trim() || '', assessment: assessment as unknown as Prisma.InputJsonValue }
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)