把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。 Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||
import {
|
||
BODY_PORTRAIT_OFFLINE_PROTOCOL,
|
||
CreateProfessionalAssessmentDto,
|
||
CreateTrainingPlanDto,
|
||
GrowthShareCardRecord,
|
||
ProfessionalAssessmentKind,
|
||
ProfessionalAssessmentSessionRecord,
|
||
ProfessionalObservationScores,
|
||
ReassessmentTodoRecord,
|
||
TrainingPlanRecord,
|
||
TrainingPlanStatus,
|
||
} from '@mp-pilates/shared'
|
||
import { Prisma } from '@prisma/client'
|
||
import { PrismaService } from '../prisma/prisma.service'
|
||
import { createShareCode } from './body-portrait.token'
|
||
|
||
const DEFAULT_PHASES = [
|
||
{ name: 'Phase 1 · 重新建立控制', lessonStart: 1, lessonEnd: 4, focus: ['呼吸', '骨盆控制', '核心激活'], summary: '先找回呼吸、骨盆和核心的基本控制。', sortOrder: 0 },
|
||
{ name: 'Phase 2 · 改善活动能力', lessonStart: 5, lessonEnd: 8, focus: ['胸椎', '肩带', '髋'], summary: '在控制基础上改善上背、肩和髋的活动。', sortOrder: 1 },
|
||
{ name: 'Phase 3 · 整合身体动作', lessonStart: 9, lessonEnd: 12, focus: ['全身控制', '稳定', '日常动作迁移'], summary: '把训练能力带回站、走和日常动作。', sortOrder: 2 },
|
||
]
|
||
|
||
@Injectable()
|
||
export class BodyPortraitOfflineService {
|
||
constructor(private readonly prisma: PrismaService) {}
|
||
|
||
async createSession(userId: string, operatorId: string, dto: CreateProfessionalAssessmentDto): Promise<ProfessionalAssessmentSessionRecord> {
|
||
await this.ensureUser(userId)
|
||
if (dto.bookingId) {
|
||
const booking = await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId } })
|
||
if (!booking) throw new BadRequestException('只能关联该学员的预约')
|
||
}
|
||
if (dto.originAssessmentId) {
|
||
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({ where: { id: dto.originAssessmentId, userId } })
|
||
if (!assessment) throw new BadRequestException('线上画像不属于该学员')
|
||
}
|
||
const recordedAt = this.date(dto.recordedAt)
|
||
const created = await this.prisma.professionalAssessmentSession.create({
|
||
data: {
|
||
userId,
|
||
kind: dto.kind,
|
||
protocolVersion: BODY_PORTRAIT_OFFLINE_PROTOCOL,
|
||
recordedAt,
|
||
bookingId: dto.bookingId,
|
||
originAssessmentId: dto.originAssessmentId,
|
||
observations: dto.observations as unknown as Prisma.InputJsonValue,
|
||
subjectiveTension: dto.subjectiveTension ?? null,
|
||
coachSummary: dto.coachSummary.trim().slice(0, 1000),
|
||
trainingFocus: dto.trainingFocus.trim().slice(0, 500),
|
||
phaseGoal: dto.phaseGoal.trim().slice(0, 500),
|
||
photoAngle: dto.photoAngle || null,
|
||
operatorId,
|
||
},
|
||
})
|
||
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
|
||
await this.prisma.reassessmentTodo.updateMany({
|
||
where: { userId, completedAt: null },
|
||
data: { completedAt: new Date() },
|
||
})
|
||
}
|
||
return this.mapSession(created)
|
||
}
|
||
|
||
async listSessions(userId: string): Promise<ProfessionalAssessmentSessionRecord[]> {
|
||
const rows = await this.prisma.professionalAssessmentSession.findMany({
|
||
where: { userId },
|
||
orderBy: { recordedAt: 'asc' },
|
||
})
|
||
return rows.map((row) => this.mapSession(row))
|
||
}
|
||
|
||
async createPlan(userId: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanRecord> {
|
||
await this.ensureUser(userId)
|
||
if (dto.sessionId) {
|
||
const session = await this.prisma.professionalAssessmentSession.findFirst({ where: { id: dto.sessionId, userId } })
|
||
if (!session) throw new BadRequestException('评估记录不属于该学员')
|
||
}
|
||
const plan = await this.prisma.trainingPlan.create({
|
||
data: {
|
||
userId,
|
||
sessionId: dto.sessionId,
|
||
title: dto.title?.trim() || '你的 12 周身体改善计划',
|
||
status: TrainingPlanStatus.ACTIVE,
|
||
weeks: dto.weeks || 12,
|
||
phases: {
|
||
create: DEFAULT_PHASES.map((phase) => ({
|
||
...phase,
|
||
focus: phase.focus as unknown as Prisma.InputJsonValue,
|
||
})),
|
||
},
|
||
},
|
||
include: { phases: { orderBy: { sortOrder: 'asc' } } },
|
||
})
|
||
const now = new Date()
|
||
await this.prisma.reassessmentTodo.createMany({
|
||
data: [4, 8, 12].map((checkpoint) => ({
|
||
userId,
|
||
planId: plan.id,
|
||
lessonCheckpoint: checkpoint,
|
||
dueAt: new Date(now.getTime() + checkpoint * 7 * 24 * 60 * 60 * 1000),
|
||
})),
|
||
})
|
||
return this.mapPlan(plan)
|
||
}
|
||
|
||
async listPlans(userId: string): Promise<TrainingPlanRecord[]> {
|
||
const plans = await this.prisma.trainingPlan.findMany({
|
||
where: { userId },
|
||
include: { phases: { orderBy: { sortOrder: 'asc' } } },
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
return plans.map((plan) => this.mapPlan(plan))
|
||
}
|
||
|
||
async todos(userId: string): Promise<ReassessmentTodoRecord[]> {
|
||
const rows = await this.prisma.reassessmentTodo.findMany({
|
||
where: { userId },
|
||
orderBy: { lessonCheckpoint: 'asc' },
|
||
})
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
userId: row.userId,
|
||
planId: row.planId,
|
||
lessonCheckpoint: row.lessonCheckpoint,
|
||
dueAt: row.dueAt.toISOString(),
|
||
completedAt: row.completedAt?.toISOString() || null,
|
||
}))
|
||
}
|
||
|
||
async createShareCard(userId: string, includePhotos: boolean): Promise<GrowthShareCardRecord> {
|
||
const sessions = await this.listSessions(userId)
|
||
const first = sessions[0]
|
||
const latest = sessions[sessions.length - 1]
|
||
const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
|
||
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } })
|
||
const rows = first && latest ? this.compare(first, latest) : []
|
||
const created = await this.prisma.growthShareCard.create({
|
||
data: {
|
||
userId,
|
||
planId: plan?.id,
|
||
shareCode: createShareCode(),
|
||
title: `我的第 ${completedCount || 1} 节普拉提`,
|
||
caption: first && latest && first.subjectiveTension != null && latest.subjectiveTension != null
|
||
? `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10,现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
|
||
: '坚持有时候真的看得见。',
|
||
completedCount,
|
||
weeks: plan?.weeks || 12,
|
||
rows: rows as unknown as Prisma.InputJsonValue,
|
||
includePhotos: includePhotos === true,
|
||
},
|
||
})
|
||
return this.mapShare(created)
|
||
}
|
||
|
||
async publicShare(shareCode: string) {
|
||
const card = await this.prisma.growthShareCard.findUnique({ where: { shareCode } })
|
||
if (!card) throw new NotFoundException('分享卡片不存在')
|
||
return {
|
||
...this.mapShare(card),
|
||
includePhotos: false,
|
||
}
|
||
}
|
||
|
||
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
|
||
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
|
||
{ key: 'shoulderFlexion', label: '肩屈活动' },
|
||
{ key: 'singleLeg', label: '单腿稳定' },
|
||
{ key: 'coreControl', label: '核心控制' },
|
||
{ key: 'thoracicExtension', label: '胸椎活动' },
|
||
]
|
||
const rows = labels.map((item) => ({
|
||
label: item.label,
|
||
first: `${first.observations[item.key]}/5`,
|
||
latest: `${latest.observations[item.key]}/5`,
|
||
}))
|
||
if (first.subjectiveTension != null || latest.subjectiveTension != null) {
|
||
rows.push({
|
||
label: '肩颈主观紧张',
|
||
first: first.subjectiveTension != null ? `${first.subjectiveTension}/10` : '—',
|
||
latest: latest.subjectiveTension != null ? `${latest.subjectiveTension}/10` : '—',
|
||
})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
private mapSession(row: {
|
||
id: string
|
||
userId: string
|
||
kind: string
|
||
protocolVersion: string
|
||
recordedAt: Date
|
||
bookingId: string | null
|
||
originAssessmentId: string | null
|
||
observations: Prisma.JsonValue
|
||
subjectiveTension: number | null
|
||
coachSummary: string
|
||
trainingFocus: string
|
||
phaseGoal: string
|
||
photoAngle: string | null
|
||
}): ProfessionalAssessmentSessionRecord {
|
||
return {
|
||
id: row.id,
|
||
userId: row.userId,
|
||
kind: row.kind as ProfessionalAssessmentKind,
|
||
protocolVersion: row.protocolVersion,
|
||
recordedAt: row.recordedAt.toISOString().slice(0, 10),
|
||
bookingId: row.bookingId,
|
||
originAssessmentId: row.originAssessmentId,
|
||
observations: row.observations as unknown as ProfessionalObservationScores,
|
||
subjectiveTension: row.subjectiveTension,
|
||
coachSummary: row.coachSummary,
|
||
trainingFocus: row.trainingFocus,
|
||
phaseGoal: row.phaseGoal,
|
||
photoAngle: row.photoAngle as ProfessionalAssessmentSessionRecord['photoAngle'],
|
||
}
|
||
}
|
||
|
||
private mapPlan(plan: {
|
||
id: string
|
||
userId: string
|
||
title: string
|
||
status: string
|
||
weeks: number
|
||
sessionId: string | null
|
||
phases: Array<{ id: string; name: string; lessonStart: number; lessonEnd: number; focus: Prisma.JsonValue; summary: string }>
|
||
}): TrainingPlanRecord {
|
||
return {
|
||
id: plan.id,
|
||
userId: plan.userId,
|
||
title: plan.title,
|
||
status: plan.status as TrainingPlanStatus,
|
||
weeks: plan.weeks,
|
||
sessionId: plan.sessionId,
|
||
phases: plan.phases.map((phase) => ({
|
||
id: phase.id,
|
||
name: phase.name,
|
||
lessonStart: phase.lessonStart,
|
||
lessonEnd: phase.lessonEnd,
|
||
focus: phase.focus as string[],
|
||
summary: phase.summary,
|
||
})),
|
||
}
|
||
}
|
||
|
||
private mapShare(card: {
|
||
id: string
|
||
shareCode: string
|
||
title: string
|
||
caption: string
|
||
completedCount: number
|
||
weeks: number
|
||
rows: Prisma.JsonValue
|
||
includePhotos: boolean
|
||
}): GrowthShareCardRecord {
|
||
return {
|
||
id: card.id,
|
||
shareCode: card.shareCode,
|
||
title: card.title,
|
||
caption: card.caption,
|
||
completedCount: card.completedCount,
|
||
weeks: card.weeks,
|
||
rows: card.rows as unknown as GrowthShareCardRecord['rows'],
|
||
includePhotos: card.includePhotos,
|
||
}
|
||
}
|
||
|
||
private async ensureUser(userId: string) {
|
||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
|
||
if (!user) throw new NotFoundException('学员不存在')
|
||
}
|
||
|
||
private date(value: string) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效')
|
||
const date = new Date(`${value}T00:00:00.000Z`)
|
||
if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效')
|
||
if (date.getTime() > Date.now()) throw new BadRequestException('日期不能晚于今天')
|
||
return date
|
||
}
|
||
}
|