108 lines
7.0 KiB
TypeScript
108 lines
7.0 KiB
TypeScript
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
|
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) {}
|
|
private async member(userId: string) {
|
|
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { nickname: true } })
|
|
if (!user) throw new NotFoundException('学员不存在')
|
|
return user
|
|
}
|
|
private date(value: string) {
|
|
const date = new Date(value + 'T00:00:00.000Z')
|
|
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value || value > new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)) throw new BadRequestException('请选择有效的记录日期,不能晚于今天')
|
|
return date
|
|
}
|
|
async archive(userId: string, admin = false) {
|
|
const user = await this.member(userId)
|
|
const [count, supplements, metrics, notes, photos] = await Promise.all([
|
|
this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }),
|
|
this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true } }),
|
|
this.prisma.bodyMetric.findMany({ where: { userId }, orderBy: [{ recordedAt: 'desc' }, { createdAt: 'desc' }] }),
|
|
this.prisma.memberNote.findMany({ where: { userId, ...(admin ? {} : { shared: true }) }, orderBy: { createdAt: 'desc' }, include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } } }),
|
|
this.prisma.progressPhoto.findMany({ where: { userId, uploadedAt: { not: null } }, orderBy: { recordedAt: 'desc' }, select: { id: true, caption: true, recordedAt: true, consentedAt: true, revokedAt: true } }),
|
|
])
|
|
const completedCount = count + (supplements._sum.quantity || 0)
|
|
return { nickname: user.nickname, completedCount, milestones: [10, 30, 50].filter(n => completedCount >= n), metrics, notes, photos }
|
|
}
|
|
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
|
|
await this.member(userId)
|
|
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)
|
|
if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
|
|
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
|
|
return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
|
|
}
|
|
async remove(userId: string, kind: string, id: string) {
|
|
if (kind === 'metrics') {
|
|
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })
|
|
if (!result.count) throw new NotFoundException('记录不存在')
|
|
return { deleted: true }
|
|
}
|
|
if (kind === 'notes') {
|
|
const result = await this.prisma.memberNote.deleteMany({ where: { id, userId } })
|
|
if (!result.count) throw new NotFoundException('记录不存在')
|
|
return { deleted: true }
|
|
}
|
|
if (kind === 'photos') {
|
|
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
|
|
if (!photo) throw new NotFoundException('照片不存在')
|
|
await this.storage.removeObject(photo.objectKey)
|
|
await this.prisma.progressPhoto.delete({ where: { id } })
|
|
return { deleted: true }
|
|
}
|
|
throw new BadRequestException('记录类型无效')
|
|
}
|
|
async upload(userId: string, operatorId: string, dto: ProgressPhotoDto) {
|
|
await this.member(userId)
|
|
const recordedAt = this.date(dto.recordedAt)
|
|
const credential = this.storage.credential(userId, dto.fileName)
|
|
const photo = await this.prisma.progressPhoto.create({ data: { userId, operatorId, objectKey: credential.key, recordedAt, caption: dto.caption } })
|
|
return { ...credential, id: photo.id }
|
|
}
|
|
async finishUpload(userId: string, id: string) {
|
|
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
|
|
if (!photo) throw new NotFoundException('照片不存在')
|
|
await this.storage.verify(photo.objectKey)
|
|
await this.prisma.progressPhoto.update({ where: { id }, data: { uploadedAt: new Date() } })
|
|
return { uploaded: true }
|
|
}
|
|
async consent(userId: string, id: string, consent: boolean) {
|
|
const result = await this.prisma.progressPhoto.updateMany({ where: { id, userId, uploadedAt: { not: null } }, data: { consentedAt: consent ? new Date() : null, revokedAt: consent ? null : new Date() } })
|
|
if (!result.count) throw new NotFoundException('照片不存在')
|
|
return { consent }
|
|
}
|
|
async photoUrl(userId: string, id: string, admin = false) {
|
|
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId, uploadedAt: { not: null } } })
|
|
if (!photo) throw new NotFoundException('照片不存在')
|
|
// Members can privately preview their own photo to make an informed consent decision.
|
|
if (admin && !photo.consentedAt) throw new ForbiddenException('学员尚未授权展示这张照片')
|
|
return { url: this.storage.signedUrl(photo.objectKey), expiresIn: 60 }
|
|
}
|
|
}
|