import { Injectable, Logger } from '@nestjs/common' import { BODY_DIMENSION_LABELS, BodyDimension, BodyPortraitReport, BodyPortraitSource, CardTypeCategory, GrowthEventName, GrowthLeadStage, PORTRAIT_TYPE_LABELS, SITTING_HOURS_LABELS, SittingHours, WORK_POSTURE_LABELS, WorkPosture, } from '@mp-pilates/shared' import { Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' const STAGE_RANK: Record = { [GrowthLeadStage.VISIT]: 0, [GrowthLeadStage.STARTED]: 1, [GrowthLeadStage.COMPLETED]: 2, [GrowthLeadStage.CLAIMED]: 3, [GrowthLeadStage.PHONE_BOUND]: 4, [GrowthLeadStage.TRIAL_PURCHASED]: 5, [GrowthLeadStage.TRIAL_BOOKED]: 6, [GrowthLeadStage.TRIAL_ATTENDED]: 7, [GrowthLeadStage.PLAN_PURCHASED]: 8, [GrowthLeadStage.TRAINING]: 9, [GrowthLeadStage.RENEWAL_DUE]: 10, [GrowthLeadStage.RENEWED]: 11, } const SAFE_EVENT_PROPS = new Set(['source', 'campaignId', 'cardType', 'bookingId', 'orderId', 'checkpoint']) @Injectable() export class BodyPortraitLifecycleService { private readonly logger = new Logger(BodyPortraitLifecycleService.name) constructor(private readonly prisma: PrismaService) {} async recordEvent(input: { name: GrowthEventName idempotencyKey: string visitId?: string | null assessmentId?: string | null userId?: string | null properties?: Record stage?: GrowthLeadStage }) { const properties = this.sanitize(input.properties) try { await this.prisma.growthEvent.create({ data: { name: input.name, idempotencyKey: input.idempotencyKey.slice(0, 120), visitId: input.visitId || undefined, assessmentId: input.assessmentId || undefined, userId: input.userId || undefined, properties: properties as Prisma.InputJsonValue, }, }) } catch (error) { if (this.isUnique(error)) return this.logger.warn(`growth event failed: ${input.name}`) return } if (input.userId && input.stage) { await this.advanceLead(input.userId, input.stage, input.assessmentId) } } async advanceLead(userId: string, stage: GrowthLeadStage, assessmentId?: string | null) { const existing = await this.prisma.growthLead.findUnique({ where: { userId } }) if (!existing) { if (STAGE_RANK[stage] < STAGE_RANK[GrowthLeadStage.CLAIMED]) return const assessment = assessmentId ? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId }, include: { visit: true } }) : await this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, include: { visit: true } }) await this.prisma.growthLead.create({ data: { userId, latestAssessmentId: assessment?.id, stage, source: assessment?.visit.source, campaignId: assessment?.visit.campaignId, lastActiveAt: new Date(), }, }) return } const nextStage = STAGE_RANK[stage] > STAGE_RANK[existing.stage as GrowthLeadStage] ? stage : existing.stage await this.prisma.growthLead.update({ where: { userId }, data: { stage: nextStage, lastActiveAt: new Date(), latestAssessmentId: assessmentId || existing.latestAssessmentId, }, }) } async onPhoneBound(userId: string) { await this.recordEvent({ name: GrowthEventName.PHONE_BOUND, idempotencyKey: `phone_bound:${userId}`, userId, stage: GrowthLeadStage.PHONE_BOUND, }) } async onOrderPaid(userId: string, cardType: string, orderId: string) { const assessment = await this.latestAssessment(userId) const trial = cardType === CardTypeCategory.TRIAL await this.recordEvent({ name: trial ? GrowthEventName.TRIAL_PURCHASED : GrowthEventName.PLAN_PURCHASED, idempotencyKey: `${trial ? 'trial_purchased' : 'plan_purchased'}:${orderId}`, userId, assessmentId: assessment?.id, visitId: assessment?.visitId, properties: { cardType, orderId }, stage: trial ? GrowthLeadStage.TRIAL_PURCHASED : GrowthLeadStage.PLAN_PURCHASED, }) if (!trial) { await this.advanceLead(userId, GrowthLeadStage.TRAINING, assessment?.id) } } async onBookingCreated(userId: string, bookingId: string, cardType: string, originAssessmentId?: string | null) { if (cardType !== CardTypeCategory.TRIAL) return const assessmentId = originAssessmentId || (await this.latestAssessment(userId))?.id const assessment = assessmentId ? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId } }) : null await this.recordEvent({ name: GrowthEventName.TRIAL_BOOKED, idempotencyKey: `trial_booked:${bookingId}`, userId, assessmentId: assessment?.id, visitId: assessment?.visitId, properties: { bookingId }, stage: GrowthLeadStage.TRIAL_BOOKED, }) } async onBookingCompleted(userId: string, bookingId: string, cardType: string) { if (cardType !== CardTypeCategory.TRIAL) return const assessment = await this.latestAssessment(userId) await this.recordEvent({ name: GrowthEventName.TRIAL_ATTENDED, idempotencyKey: `trial_attended:${bookingId}`, userId, assessmentId: assessment?.id, visitId: assessment?.visitId, properties: { bookingId }, stage: GrowthLeadStage.TRIAL_ATTENDED, }) } followUpDraft(input: { nickname: string report: BodyPortraitReport | null sittingHours?: SittingHours | null workPosture?: WorkPosture | null }): string { const greeting = input.nickname ? `${input.nickname}你好,` : '' if (!input.report) { return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。` } const top = (Object.entries(input.report.scores) as [BodyDimension, number][]) .sort((a, b) => b[1] - a[1])[0] const lifestyle = [ input.workPosture ? WORK_POSTURE_LABELS[input.workPosture] : '', input.sittingHours ? SITTING_HOURS_LABELS[input.sittingHours] : '', ].filter(Boolean).join('、') const type = PORTRAIT_TYPE_LABELS[input.report.primaryType] const dimension = BODY_DIMENSION_LABELS[top[0]] 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 { const value = String(raw || '').toLowerCase() return (Object.values(BodyPortraitSource) as string[]).includes(value) ? value as BodyPortraitSource : BodyPortraitSource.ORGANIC } private async latestAssessment(userId: string) { return this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, }) } private sanitize(properties?: Record) { const result: Record = {} if (!properties) return result for (const [key, value] of Object.entries(properties)) { if (!SAFE_EVENT_PROPS.has(key)) continue if (value == null) continue result[key] = String(value).slice(0, 80) } return result } private isUnique(error: unknown) { return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' } }