fix: 修复身体画像约课阻断、页面响应式丢失及多项边界问题
- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定 - fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参 - fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案 - fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理 - test: 补齐 body-portrait-offline 与 booking 来源画像单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { BadRequestException } from '@nestjs/common'
|
||||
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
|
||||
import { BodyPortraitOfflineService } from '../body-portrait-offline.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
|
||||
describe('BodyPortraitOfflineService', () => {
|
||||
const prisma: any = {
|
||||
user: { findUnique: jest.fn() },
|
||||
booking: { findFirst: jest.fn(), count: jest.fn() },
|
||||
bodyPortraitAssessment: { findFirst: jest.fn() },
|
||||
professionalAssessmentSession: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
|
||||
reassessmentTodo: { findFirst: jest.fn(), update: jest.fn(), createMany: jest.fn(), findMany: jest.fn() },
|
||||
trainingPlan: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
|
||||
growthShareCard: { create: jest.fn(), findUnique: jest.fn() },
|
||||
}
|
||||
let service: BodyPortraitOfflineService
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'u1' })
|
||||
service = new BodyPortraitOfflineService(prisma as unknown as PrismaService)
|
||||
})
|
||||
|
||||
it('rejects assessment recorded in the future based on China timezone', async () => {
|
||||
const futureDate = new Date(Date.now() + 86400000 * 2).toISOString().slice(0, 10)
|
||||
await expect(
|
||||
service.createSession('u1', 'op1', {
|
||||
kind: ProfessionalAssessmentKind.INITIAL,
|
||||
recordedAt: futureDate,
|
||||
observations: {
|
||||
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||
},
|
||||
coachSummary: 'test',
|
||||
trainingFocus: 'test',
|
||||
phaseGoal: 'test',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
})
|
||||
|
||||
it('completes only the earliest pending reassessment todo on follow-up session', async () => {
|
||||
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
|
||||
prisma.professionalAssessmentSession.create.mockResolvedValue({
|
||||
id: 'sess-2',
|
||||
userId: 'u1',
|
||||
kind: ProfessionalAssessmentKind.FOLLOW_UP,
|
||||
protocolVersion: 'offline-v1',
|
||||
recordedAt: new Date(`${today}T00:00:00.000Z`),
|
||||
bookingId: null,
|
||||
originAssessmentId: null,
|
||||
observations: {
|
||||
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||
},
|
||||
subjectiveTension: 4,
|
||||
coachSummary: 'progress good',
|
||||
trainingFocus: 'stability',
|
||||
phaseGoal: 'next stage',
|
||||
photoAngle: null,
|
||||
})
|
||||
prisma.reassessmentTodo.findFirst.mockResolvedValue({ id: 'todo-4', lessonCheckpoint: 4 })
|
||||
prisma.reassessmentTodo.update.mockResolvedValue({ id: 'todo-4', completedAt: new Date() })
|
||||
|
||||
const result = await service.createSession('u1', 'op1', {
|
||||
kind: ProfessionalAssessmentKind.FOLLOW_UP,
|
||||
recordedAt: today,
|
||||
observations: {
|
||||
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||
},
|
||||
coachSummary: 'progress good',
|
||||
trainingFocus: 'stability',
|
||||
phaseGoal: 'next stage',
|
||||
})
|
||||
|
||||
expect(result.id).toBe('sess-2')
|
||||
expect(prisma.reassessmentTodo.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: 'u1', completedAt: null },
|
||||
orderBy: { lessonCheckpoint: 'asc' },
|
||||
})
|
||||
expect(prisma.reassessmentTodo.update).toHaveBeenCalledWith({
|
||||
where: { id: 'todo-4' },
|
||||
data: { completedAt: expect.any(Date) },
|
||||
})
|
||||
})
|
||||
|
||||
it('generates baseline share card for single session without false comparison', async () => {
|
||||
const sessDate = new Date('2026-09-01T00:00:00.000Z')
|
||||
prisma.professionalAssessmentSession.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'sess-1',
|
||||
userId: 'u1',
|
||||
kind: ProfessionalAssessmentKind.INITIAL,
|
||||
protocolVersion: 'offline-v1',
|
||||
recordedAt: sessDate,
|
||||
bookingId: null,
|
||||
originAssessmentId: null,
|
||||
observations: {
|
||||
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
|
||||
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
|
||||
},
|
||||
subjectiveTension: 6,
|
||||
coachSummary: 'initial summary',
|
||||
trainingFocus: 'focus',
|
||||
phaseGoal: 'goal',
|
||||
photoAngle: null,
|
||||
},
|
||||
])
|
||||
prisma.booking.count.mockResolvedValue(1)
|
||||
prisma.trainingPlan.findFirst.mockResolvedValue({ id: 'p1', weeks: 12 })
|
||||
prisma.growthShareCard.create.mockImplementation(async ({ data }: { data: any }) => ({
|
||||
id: 'card-1',
|
||||
...data,
|
||||
}))
|
||||
|
||||
const card = await service.createShareCard('u1', false)
|
||||
expect(card.title).toBe('我的第 1 节普拉提')
|
||||
expect(card.caption).toContain('已建立初始身体基准状态')
|
||||
expect(prisma.growthShareCard.create.mock.calls[0][0].data.caption).not.toContain('周前肩颈紧张')
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,7 @@ describe('BodyPortraitService', () => {
|
||||
userId: null,
|
||||
visitId: 'v1',
|
||||
status: BodyPortraitAssessmentStatus.DRAFT,
|
||||
expiresAt: new Date(Date.now() + 10000),
|
||||
completedAt: null,
|
||||
answers: {
|
||||
concerns: ['neck'],
|
||||
@@ -85,6 +86,18 @@ describe('BodyPortraitService', () => {
|
||||
expect(session.report).toBeNull()
|
||||
expect(prisma.bodyPortraitAssessment.update.mock.calls[0][0].data.report.scores.cervicalShoulder).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejects saveAnswers on expired assessment', async () => {
|
||||
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
|
||||
id: 'a1',
|
||||
userId: null,
|
||||
visitId: 'v1',
|
||||
status: BodyPortraitAssessmentStatus.EXPIRED,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
answers: {},
|
||||
})
|
||||
await expect(service.saveAnswers('expired-token', { goal: PortraitGoal.CORE })).rejects.toThrow('测评已过期')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BodyPortraitLifecycleService', () => {
|
||||
@@ -114,4 +127,20 @@ describe('BodyPortraitLifecycleService', () => {
|
||||
expect(prisma.growthEvent.create.mock.calls[0][0].data.idempotencyKey).toBe('trial_purchased:order-1')
|
||||
expect(prisma.growthEvent.create.mock.calls[0][0].data.name).toBe(GrowthEventName.TRIAL_PURCHASED)
|
||||
})
|
||||
|
||||
it('generates customized follow-up drafts with nickname', () => {
|
||||
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
|
||||
const draft = service.followUpDraft({
|
||||
nickname: '张同学',
|
||||
report: null,
|
||||
})
|
||||
expect(draft).toContain('张同学你好,')
|
||||
expect(draft).toContain('做一次实际的活动度和动作评估')
|
||||
|
||||
const trialDraft = service.followUpAfterTrialDraft({
|
||||
nickname: '李同学',
|
||||
report: null,
|
||||
})
|
||||
expect(trialDraft).toContain('李同学你好,前两天的普拉提体验课感觉怎么样?')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +83,10 @@ export class BodyPortraitAdminService {
|
||||
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
|
||||
detail: '建议回访,确认是否需要 12 周改善计划',
|
||||
happenedAt: lead.lastActiveAt.toISOString(),
|
||||
followUpDraft: draft,
|
||||
followUpDraft: this.lifecycle.followUpAfterTrialDraft({
|
||||
nickname: lead.user.nickname,
|
||||
report,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ export class BodyPortraitLifecycleService {
|
||||
sittingHours?: SittingHours | null
|
||||
workPosture?: WorkPosture | null
|
||||
}): string {
|
||||
const name = input.nickname || '你'
|
||||
const greeting = input.nickname ? `${input.nickname}你好,` : ''
|
||||
if (!input.report) {
|
||||
return `看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
||||
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
||||
}
|
||||
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
|
||||
.sort((a, b) => b[1] - a[1])[0]
|
||||
@@ -175,7 +175,16 @@ export class BodyPortraitLifecycleService {
|
||||
].filter(Boolean).join('、')
|
||||
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
|
||||
const dimension = BODY_DIMENSION_LABELS[top[0]]
|
||||
return `看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`.replace(`${name}`, `${name}`)
|
||||
return `${greeting}看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
||||
}
|
||||
|
||||
followUpAfterTrialDraft(input: {
|
||||
nickname: string
|
||||
report: BodyPortraitReport | null
|
||||
}): string {
|
||||
const greeting = input.nickname ? `${input.nickname}你好,` : ''
|
||||
const type = input.report ? `结合你之前的「${PORTRAIT_TYPE_LABELS[input.report.primaryType]}」画像和` : ''
|
||||
return `${greeting}前两天的普拉提体验课感觉怎么样?${type}现场教练观察的情况,我们为你准备了针对性的阶段改善建议。如果有时间可以聊聊你的感受,看看是否需要为你规划后续的训练。`
|
||||
}
|
||||
|
||||
parseSource(raw?: string | null): BodyPortraitSource {
|
||||
|
||||
@@ -54,10 +54,16 @@ export class BodyPortraitOfflineService {
|
||||
},
|
||||
})
|
||||
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
|
||||
await this.prisma.reassessmentTodo.updateMany({
|
||||
const nextTodo = await this.prisma.reassessmentTodo.findFirst({
|
||||
where: { userId, completedAt: null },
|
||||
data: { completedAt: new Date() },
|
||||
orderBy: { lessonCheckpoint: 'asc' },
|
||||
})
|
||||
if (nextTodo) {
|
||||
await this.prisma.reassessmentTodo.update({
|
||||
where: { id: nextTodo.id },
|
||||
data: { completedAt: new Date() },
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.mapSession(created)
|
||||
}
|
||||
@@ -132,18 +138,23 @@ export class BodyPortraitOfflineService {
|
||||
const sessions = await this.listSessions(userId)
|
||||
const first = sessions[0]
|
||||
const latest = sessions[sessions.length - 1]
|
||||
const hasComparison = sessions.length >= 2
|
||||
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 rows = hasComparison ? this.compare(first, latest) : this.singleSnapshot(first)
|
||||
let caption = '坚持有时候真的看得见。'
|
||||
if (hasComparison && first?.subjectiveTension != null && latest?.subjectiveTension != null) {
|
||||
caption = `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10,现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
|
||||
} else if (!hasComparison && first?.subjectiveTension != null) {
|
||||
caption = `已建立初始身体基准状态,肩颈紧张度 ${first.subjectiveTension}/10。开启专属改善计划。`
|
||||
}
|
||||
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。坚持有时候真的看得见。`
|
||||
: '坚持有时候真的看得见。',
|
||||
caption,
|
||||
completedCount,
|
||||
weeks: plan?.weeks || 12,
|
||||
rows: rows as unknown as Prisma.InputJsonValue,
|
||||
@@ -162,6 +173,29 @@ export class BodyPortraitOfflineService {
|
||||
}
|
||||
}
|
||||
|
||||
private singleSnapshot(first?: ProfessionalAssessmentSessionRecord) {
|
||||
if (!first) return []
|
||||
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: `${first.observations[item.key]}/5`,
|
||||
}))
|
||||
if (first.subjectiveTension != null) {
|
||||
rows.push({
|
||||
label: '肩颈主观紧张',
|
||||
first: `${first.subjectiveTension}/10`,
|
||||
latest: `${first.subjectiveTension}/10`,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
|
||||
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
|
||||
{ key: 'shoulderFlexion', label: '肩屈活动' },
|
||||
@@ -274,7 +308,8 @@ export class BodyPortraitOfflineService {
|
||||
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('日期不能晚于今天')
|
||||
const todayChina = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
|
||||
if (value > todayChina) throw new BadRequestException('日期不能晚于今天')
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@ interface Bucket {
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let lastCleanAt = 0
|
||||
|
||||
function pruneBuckets(now: number) {
|
||||
if (now - lastCleanAt < 60_000 && buckets.size < 1000) return
|
||||
lastCleanAt = now
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRateLimit(key: string, limit: number, windowMs = 60 * 60 * 1000) {
|
||||
const now = Date.now()
|
||||
pruneBuckets(now)
|
||||
const current = buckets.get(key)
|
||||
if (!current || current.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs })
|
||||
|
||||
@@ -117,7 +117,7 @@ export class BodyPortraitService {
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.CLAIMED) {
|
||||
throw new ForbiddenException('报告已认领,不能再修改')
|
||||
}
|
||||
if (assessment.expiresAt < new Date() && assessment.status === BodyPortraitAssessmentStatus.DRAFT) {
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
|
||||
throw new BadRequestException('测评已过期,请重新开始')
|
||||
}
|
||||
const answers = normalizeAnswers(raw as Partial<BodyPortraitAnswers>)
|
||||
@@ -130,6 +130,9 @@ export class BodyPortraitService {
|
||||
|
||||
async complete(accessToken: string): Promise<BodyPortraitSessionResponse> {
|
||||
const assessment = await this.assessmentByToken(accessToken)
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
|
||||
throw new BadRequestException('测评已过期,请重新开始')
|
||||
}
|
||||
const answers = normalizeAnswers(assessment.answers as Partial<BodyPortraitAnswers>)
|
||||
if (!answers.goal || !answers.sittingHours || !answers.exerciseFreq || !answers.workPosture || !answers.safety.length) {
|
||||
throw new BadRequestException('请完成必答题后再生成画像')
|
||||
|
||||
@@ -160,6 +160,11 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
bodyPortraitAssessment: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -476,6 +481,58 @@ describe('BookingService', () => {
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('binds anonymous originAssessmentId to current user on booking create', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'anon-assess', userId: null })
|
||||
tx.bodyPortraitAssessment.update.mockResolvedValue({ id: 'anon-assess', userId: MOCK_USER_ID })
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
originAssessmentId: 'anon-assess',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
originAssessmentId: 'anon-assess',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'anon-assess' })
|
||||
|
||||
expect(tx.bodyPortraitAssessment.update).toHaveBeenCalledWith({
|
||||
where: { id: 'anon-assess' },
|
||||
data: { userId: MOCK_USER_ID, status: 'CLAIMED', claimedAt: expect.any(Date) },
|
||||
})
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
originAssessmentId: 'anon-assess',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects originAssessmentId belonging to another user', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'other-assess', userId: 'someone-else' })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'other-assess' }),
|
||||
).rejects.toThrow('画像报告不属于当前用户')
|
||||
})
|
||||
|
||||
it('records booking status history when user creates a booking', async () => {
|
||||
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
|
||||
|
||||
|
||||
@@ -130,14 +130,24 @@ export class BookingService {
|
||||
|
||||
let originAssessmentId: string | undefined
|
||||
if (dto.originAssessmentId) {
|
||||
const owned = await tx.bodyPortraitAssessment.findFirst({
|
||||
where: { id: dto.originAssessmentId, userId },
|
||||
select: { id: true },
|
||||
const assessment = await tx.bodyPortraitAssessment.findUnique({
|
||||
where: { id: dto.originAssessmentId },
|
||||
select: { id: true, userId: true },
|
||||
})
|
||||
if (!owned) {
|
||||
throw new ForbiddenException('画像报告不属于当前用户')
|
||||
if (assessment) {
|
||||
if (!assessment.userId) {
|
||||
// 匿名画像在约课时绑定给当前用户
|
||||
await tx.bodyPortraitAssessment.update({
|
||||
where: { id: assessment.id },
|
||||
data: { userId, status: 'CLAIMED', claimedAt: new Date() },
|
||||
})
|
||||
originAssessmentId = assessment.id
|
||||
} else if (assessment.userId === userId) {
|
||||
originAssessmentId = assessment.id
|
||||
} else {
|
||||
throw new ForbiddenException('画像报告不属于当前用户')
|
||||
}
|
||||
}
|
||||
originAssessmentId = owned.id
|
||||
}
|
||||
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
|
||||
Reference in New Issue
Block a user