From 47babd91db7030fbeeb4e7f5cb0ad35e4efa4eff Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 4 Jul 2026 21:03:57 +0200 Subject: [PATCH 1/2] Centralize settings revisions and reanalyze lifecycle --- src/app/api/ai-config/route.ts | 18 +++--- src/app/api/insights/route.ts | 15 +---- .../[id]/link/[rowingSessionId]/route.ts | 25 +------- .../mocap/sessions/[id]/reanalyze/route.ts | 38 ++---------- .../api/mocap/sessions/[id]/unlink/route.ts | 25 +------- .../sessions/backfill-consistency/route.ts | 21 +------ src/app/api/sessions/route.ts | 41 +----------- src/app/api/settings/route.ts | 37 +++++------ src/app/api/test/settings-sync/route.ts | 13 ++-- src/lib/mocap/lifecycle.ts | 32 +++++----- src/lib/mocap/lifecyclePrisma.ts | 62 ++++++++++++++++++- src/lib/server/userSettings.ts | 53 ++++++++++++++++ tests/mocapLifecycleFactory.test.ts | 38 ++++++++++++ 13 files changed, 221 insertions(+), 197 deletions(-) create mode 100644 src/lib/server/userSettings.ts create mode 100644 tests/mocapLifecycleFactory.test.ts diff --git a/src/app/api/ai-config/route.ts b/src/app/api/ai-config/route.ts index 36d5bf1..032ccfa 100644 --- a/src/app/api/ai-config/route.ts +++ b/src/app/api/ai-config/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; +import { + buildUserSettingsCreateData, + USER_SETTINGS_DEFAULT_VALUES, +} from "@/lib/server/userSettings"; /** * GET /api/ai-config @@ -32,8 +36,8 @@ export async function GET() { return NextResponse.json({ config: settings || { aiConfig: null, - cloudAIEnabled: false, - maxTokens: 4000, + cloudAIEnabled: USER_SETTINGS_DEFAULT_VALUES.cloudAIEnabled, + maxTokens: USER_SETTINGS_DEFAULT_VALUES.maxTokens, } }); } catch (error) { @@ -71,12 +75,12 @@ export async function POST(req: Request) { cloudAIEnabled: configData.cloudAIEnabled, maxTokens: configData.maxTokens, }, - create: { - userId: session.user.id, + create: buildUserSettingsCreateData(session.user.id, { aiConfig: configData.aiConfig, - cloudAIEnabled: configData.cloudAIEnabled || false, - maxTokens: configData.maxTokens || 4000, - }, + cloudAIEnabled: + configData.cloudAIEnabled || USER_SETTINGS_DEFAULT_VALUES.cloudAIEnabled, + maxTokens: configData.maxTokens || USER_SETTINGS_DEFAULT_VALUES.maxTokens, + }), select: { aiConfig: true, cloudAIEnabled: true, diff --git a/src/app/api/insights/route.ts b/src/app/api/insights/route.ts index 0104026..f973e10 100644 --- a/src/app/api/insights/route.ts +++ b/src/app/api/insights/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; +import { buildUserSettingsCreateData } from "@/lib/server/userSettings"; /** * GET /api/insights @@ -158,19 +159,9 @@ export async function POST(req: Request) { update: { insightsRevision: settings?.sessionsRevision ?? 0, }, - create: { - userId: session.user.id, - theme: 'system', - units: 'metric', - dateFormat: 'MM/DD/YYYY', - timeFormat: '24h', - language: 'en', - defaultChartType: 'line', - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, + create: buildUserSettingsCreateData(session.user.id, { insightsRevision: settings?.sessionsRevision ?? 0, - }, + }), }); } diff --git a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts index 069a0fc..2666f53 100644 --- a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts +++ b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts @@ -6,6 +6,7 @@ import { getMocapStorage } from "@/lib/mocap/storage"; import { analyzeAndPersistMocapSessionLinked } from "@/lib/mocap/sessionAnalysis"; import { linkMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; import { setMocapSessionStatus } from "@/lib/mocap/lifecyclePrisma"; +import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -78,7 +79,7 @@ export async function POST( data: { rowingSessionId: restoredRowingSessionId, status }, }) .then(() => undefined), - bumpSessionsRevision: (ownerId) => bumpSessionsRevision(ownerId), + bumpSessionsRevision: bumpUserSessionsRevision, analyzePoseSegmented: async () => { throw new Error("Link lifecycle must use csv-aligned analysis"); }, @@ -104,25 +105,3 @@ export async function POST( faultCount: result.faultCount, }); } - -async function bumpSessionsRevision(userId: string): Promise { - await prisma.userSettings.upsert({ - where: { userId }, - update: { - sessionsRevision: { increment: 1 }, - }, - create: { - userId, - theme: "system", - units: "metric", - dateFormat: "MM/DD/YYYY", - timeFormat: "24h", - language: "en", - defaultChartType: "line", - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, - sessionsRevision: 1, - }, - }); -} diff --git a/src/app/api/mocap/sessions/[id]/reanalyze/route.ts b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts index b7affb9..859d229 100644 --- a/src/app/api/mocap/sessions/[id]/reanalyze/route.ts +++ b/src/app/api/mocap/sessions/[id]/reanalyze/route.ts @@ -1,14 +1,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { prisma } from "@/lib/db/prisma"; -import { getMocapStorage } from "@/lib/mocap/storage"; -import { - analyzeAndPersistMocapSession, - analyzeAndPersistMocapSessionLinked, -} from "@/lib/mocap/sessionAnalysis"; -import { reanalyzeMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; -import { setMocapSessionStatus } from "@/lib/mocap/lifecyclePrisma"; +import { getPrismaMocapSessionLifecycle } from "@/lib/mocap/lifecyclePrisma"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -23,32 +16,9 @@ export async function POST( } const { id } = await params; - const storage = getMocapStorage(); - const result = await reanalyzeMocapSessionLifecycle( - { - storage, - findSession: (userId, mocapSessionId) => - prisma.mocapSession.findFirst({ - where: { id: mocapSessionId, userId }, - select: { - id: true, - userId: true, - status: true, - rowingSessionId: true, - poseStreamPath: true, - capturePerspective: true, - calibrationCatchFrame: true, - calibrationFinishFrame: true, - }, - }), - setStatus: setMocapSessionStatus, - analyzePoseSegmented: analyzeAndPersistMocapSession, - analyzeCsvAligned: analyzeAndPersistMocapSessionLinked, - }, - { - userId: session.user.id, - mocapSessionId: id, - }, + const result = await getPrismaMocapSessionLifecycle().reanalyze( + session.user.id, + id, ); if (!result.ok) { diff --git a/src/app/api/mocap/sessions/[id]/unlink/route.ts b/src/app/api/mocap/sessions/[id]/unlink/route.ts index cde87f3..1659f5f 100644 --- a/src/app/api/mocap/sessions/[id]/unlink/route.ts +++ b/src/app/api/mocap/sessions/[id]/unlink/route.ts @@ -6,6 +6,7 @@ import { getMocapStorage } from "@/lib/mocap/storage"; import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis"; import { unlinkMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; import { setMocapSessionStatus } from "@/lib/mocap/lifecyclePrisma"; +import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -60,7 +61,7 @@ export async function POST( data: { rowingSessionId: restoredRowingSessionId, status }, }) .then(() => undefined), - bumpSessionsRevision: (ownerId) => bumpSessionsRevision(ownerId), + bumpSessionsRevision: bumpUserSessionsRevision, analyzePoseSegmented: analyzeAndPersistMocapSession, analyzeCsvAligned: async () => { throw new Error("Unlink lifecycle must use pose-segmented analysis"); @@ -85,25 +86,3 @@ export async function POST( faultCount: result.faultCount, }); } - -async function bumpSessionsRevision(userId: string): Promise { - await prisma.userSettings.upsert({ - where: { userId }, - update: { - sessionsRevision: { increment: 1 }, - }, - create: { - userId, - theme: "system", - units: "metric", - dateFormat: "MM/DD/YYYY", - timeFormat: "24h", - language: "en", - defaultChartType: "line", - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, - sessionsRevision: 1, - }, - }); -} diff --git a/src/app/api/sessions/backfill-consistency/route.ts b/src/app/api/sessions/backfill-consistency/route.ts index f34e2e1..4029ef4 100644 --- a/src/app/api/sessions/backfill-consistency/route.ts +++ b/src/app/api/sessions/backfill-consistency/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; import { calculateConsistencyScore } from "@/lib/analysisUtils"; +import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; /** * POST /api/sessions/backfill-consistency @@ -57,25 +58,7 @@ export async function POST() { // Bump the sessions revision to invalidate caches if (updated > 0) { - await prisma.userSettings.upsert({ - where: { userId: session.user.id }, - update: { - sessionsRevision: { increment: 1 }, - }, - create: { - userId: session.user.id, - theme: 'system', - units: 'metric', - dateFormat: 'MM/DD/YYYY', - timeFormat: '24h', - language: 'en', - defaultChartType: 'line', - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, - sessionsRevision: 1, - }, - }); + await bumpUserSessionsRevision(session.user.id); } return NextResponse.json({ diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index c55fbaa..8cf3062 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; import { calculateConsistencyScore } from "@/lib/analysisUtils"; +import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; /** * GET /api/sessions @@ -257,25 +258,7 @@ export async function POST(req: Request) { const shouldBumpSessionsRevision = createdAnyNewSession || updatedAnyExistingSession || newSessions.length > 1; if (shouldBumpSessionsRevision) { - await prisma.userSettings.upsert({ - where: { userId: session.user.id }, - update: { - sessionsRevision: { increment: 1 }, - }, - create: { - userId: session.user.id, - theme: 'system', - units: 'metric', - dateFormat: 'MM/DD/YYYY', - timeFormat: '24h', - language: 'en', - defaultChartType: 'line', - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, - sessionsRevision: 1, - }, - }); + await bumpUserSessionsRevision(session.user.id); } return NextResponse.json({ @@ -342,25 +325,7 @@ export async function DELETE(req: Request) { where: { id: sessionId }, }); - await prisma.userSettings.upsert({ - where: { userId: session.user.id }, - update: { - sessionsRevision: { increment: 1 }, - }, - create: { - userId: session.user.id, - theme: 'system', - units: 'metric', - dateFormat: 'MM/DD/YYYY', - timeFormat: '24h', - language: 'en', - defaultChartType: 'line', - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, - sessionsRevision: 1, - }, - }); + await bumpUserSessionsRevision(session.user.id); console.log(`[SESSIONS API] Deleted session ${sessionId}`); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 8318ae0..da3f5d5 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; +import { + buildUserSettingsCreateData, + USER_SETTINGS_DEFAULT_VALUES, +} from "@/lib/server/userSettings"; import { settingsUpdateSchema } from "@/lib/validations/settings"; /** @@ -29,15 +33,7 @@ export async function GET() { if (!settings) { return NextResponse.json({ settings: { - theme: 'system', - units: 'metric', - dateFormat: 'MM/DD/YYYY', - timeFormat: '24h', - language: 'en', - defaultChartType: 'line', - animationsEnabled: true, - cloudAIEnabled: false, - maxTokens: 4000, + ...USER_SETTINGS_DEFAULT_VALUES, sessionsRevision: 0, insightsRevision: 0, userProfileContext: null, @@ -154,21 +150,22 @@ export async function POST(req: Request) { userId: session.user.id, }, update: updateData, - create: { - userId: session.user.id, - theme: settingsData.theme || 'system', - units: settingsData.units || 'metric', - dateFormat: settingsData.dateFormat || 'MM/DD/YYYY', - timeFormat: settingsData.timeFormat || '24h', - language: settingsData.language || 'en', - defaultChartType: settingsData.defaultChartType || 'line', + create: buildUserSettingsCreateData(session.user.id, { + theme: settingsData.theme || USER_SETTINGS_DEFAULT_VALUES.theme, + units: settingsData.units || USER_SETTINGS_DEFAULT_VALUES.units, + dateFormat: settingsData.dateFormat || USER_SETTINGS_DEFAULT_VALUES.dateFormat, + timeFormat: settingsData.timeFormat || USER_SETTINGS_DEFAULT_VALUES.timeFormat, + language: settingsData.language || USER_SETTINGS_DEFAULT_VALUES.language, + defaultChartType: + settingsData.defaultChartType || USER_SETTINGS_DEFAULT_VALUES.defaultChartType, animationsEnabled: settingsData.animationsEnabled !== false, - cloudAIEnabled: settingsData.cloudAIEnabled || false, - maxTokens: settingsData.maxTokens || 4000, + cloudAIEnabled: + settingsData.cloudAIEnabled || USER_SETTINGS_DEFAULT_VALUES.cloudAIEnabled, + maxTokens: settingsData.maxTokens || USER_SETTINGS_DEFAULT_VALUES.maxTokens, userProfileContext: settingsData.userProfileContext, userProfileRawInput: settingsData.userProfileRawInput, sidecarPort: settingsData.sidecarPort, - }, + }), }); return NextResponse.json({ settings }); diff --git a/src/app/api/test/settings-sync/route.ts b/src/app/api/test/settings-sync/route.ts index c53231f..a141dc5 100644 --- a/src/app/api/test/settings-sync/route.ts +++ b/src/app/api/test/settings-sync/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db/prisma"; +import { USER_SETTINGS_DEFAULT_VALUES } from "@/lib/server/userSettings"; /** * GET /api/test/settings-sync @@ -84,15 +85,15 @@ export async function POST(req: Request) { userId: session.user.id, }, update: { - theme: testData.theme || "system", - units: testData.units || "metric", - language: testData.language || "en", + theme: testData.theme || USER_SETTINGS_DEFAULT_VALUES.theme, + units: testData.units || USER_SETTINGS_DEFAULT_VALUES.units, + language: testData.language || USER_SETTINGS_DEFAULT_VALUES.language, }, create: { userId: session.user.id, - theme: testData.theme || "system", - units: testData.units || "metric", - language: testData.language || "en", + theme: testData.theme || USER_SETTINGS_DEFAULT_VALUES.theme, + units: testData.units || USER_SETTINGS_DEFAULT_VALUES.units, + language: testData.language || USER_SETTINGS_DEFAULT_VALUES.language, }, }); diff --git a/src/lib/mocap/lifecycle.ts b/src/lib/mocap/lifecycle.ts index cfa78f5..2e33c3b 100644 --- a/src/lib/mocap/lifecycle.ts +++ b/src/lib/mocap/lifecycle.ts @@ -120,21 +120,34 @@ export interface MocapCaptureFinalizationState { qualityFlags: string[]; } -export interface MocapLifecycleDependencies { +export interface ReanalyzeMocapSessionDependencies { storage: Pick; findSession( userId: string, mocapSessionId: string, ): Promise; - findRowingSession?( - userId: string, - rowingSessionId: string, - ): Promise; setStatus( mocapSessionId: string, status: "capturing" | "analyzing" | "ready", transition?: { from: string | string[] }, ): Promise<{ status: string } | null>; + analyzePoseSegmented( + storage: MocapStorage, + session: MocapLifecycleSession, + ): Promise; + analyzeCsvAligned( + storage: MocapStorage, + session: MocapLifecycleSession, + rowingSessionId: string, + ): Promise; +} + +export interface MocapLifecycleDependencies + extends ReanalyzeMocapSessionDependencies { + findRowingSession?( + userId: string, + rowingSessionId: string, + ): Promise; assignMocapSession?( mocapSessionId: string, userId: string, @@ -151,15 +164,6 @@ export interface MocapLifecycleDependencies { status: "ready", ): Promise; bumpSessionsRevision?(userId: string): Promise; - analyzePoseSegmented( - storage: MocapStorage, - session: MocapLifecycleSession, - ): Promise; - analyzeCsvAligned( - storage: MocapStorage, - session: MocapLifecycleSession, - rowingSessionId: string, - ): Promise; } export interface FinalizeMocapSessionDependencies diff --git a/src/lib/mocap/lifecyclePrisma.ts b/src/lib/mocap/lifecyclePrisma.ts index 8430d9e..7abf18c 100644 --- a/src/lib/mocap/lifecyclePrisma.ts +++ b/src/lib/mocap/lifecyclePrisma.ts @@ -1,9 +1,69 @@ +import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db/prisma"; -import type { MocapCaptureFinalizationState } from "@/lib/mocap/lifecycle"; +import { + reanalyzeMocapSessionLifecycle, + type MocapCaptureFinalizationState, + type ReanalyzeMocapSessionDependencies, + type ReanalyzeMocapSessionResult, +} from "@/lib/mocap/lifecycle"; +import { getMocapStorage } from "@/lib/mocap/storage"; +import { + analyzeAndPersistMocapSession, + analyzeAndPersistMocapSessionLinked, +} from "@/lib/mocap/sessionAnalysis"; type MocapStatus = "capturing" | "analyzing" | "ready"; type StatusTransition = { from: string | string[] }; +export const mocapLifecycleSessionSelect = { + id: true, + userId: true, + status: true, + rowingSessionId: true, + poseStreamPath: true, + capturePerspective: true, + calibrationCatchFrame: true, + calibrationFinishFrame: true, +} satisfies Prisma.MocapSessionSelect; + +export interface MocapSessionLifecycle { + reanalyze( + userId: string, + mocapSessionId: string, + ): Promise; +} + +export function createPrismaMocapSessionLifecycle(): MocapSessionLifecycle { + const storage = getMocapStorage(); + const reanalyzeDeps: ReanalyzeMocapSessionDependencies = { + storage, + findSession: (userId, mocapSessionId) => + prisma.mocapSession.findFirst({ + where: { id: mocapSessionId, userId }, + select: mocapLifecycleSessionSelect, + }), + setStatus: setMocapSessionStatus, + analyzePoseSegmented: analyzeAndPersistMocapSession, + analyzeCsvAligned: analyzeAndPersistMocapSessionLinked, + }; + + return { + reanalyze(userId, mocapSessionId) { + return reanalyzeMocapSessionLifecycle(reanalyzeDeps, { + userId, + mocapSessionId, + }); + }, + }; +} + +let prismaMocapSessionLifecycle: MocapSessionLifecycle | null = null; + +export function getPrismaMocapSessionLifecycle(): MocapSessionLifecycle { + prismaMocapSessionLifecycle ??= createPrismaMocapSessionLifecycle(); + return prismaMocapSessionLifecycle; +} + function transitionStatusWhere( mocapSessionId: string, transition?: StatusTransition, diff --git a/src/lib/server/userSettings.ts b/src/lib/server/userSettings.ts new file mode 100644 index 0000000..8719864 --- /dev/null +++ b/src/lib/server/userSettings.ts @@ -0,0 +1,53 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db/prisma"; + +export const USER_SETTINGS_DEFAULT_VALUES = { + theme: "system", + units: "metric", + dateFormat: "MM/DD/YYYY", + timeFormat: "24h", + language: "en", + defaultChartType: "line", + animationsEnabled: true, + cloudAIEnabled: false, + maxTokens: 4000, +} as const satisfies Pick< + Prisma.UserSettingsUncheckedCreateInput, + | "theme" + | "units" + | "dateFormat" + | "timeFormat" + | "language" + | "defaultChartType" + | "animationsEnabled" + | "cloudAIEnabled" + | "maxTokens" +>; + +type UserSettingsCreateOverrides = Omit< + Partial, + "userId" +>; + +export function buildUserSettingsCreateData( + userId: string, + overrides: UserSettingsCreateOverrides = {}, +) { + return { + userId, + ...USER_SETTINGS_DEFAULT_VALUES, + ...overrides, + }; +} + +export async function bumpUserSessionsRevision(userId: string): Promise { + await prisma.userSettings.upsert({ + where: { userId }, + update: { + sessionsRevision: { increment: 1 }, + }, + create: buildUserSettingsCreateData(userId, { + sessionsRevision: 1, + }), + }); +} diff --git a/tests/mocapLifecycleFactory.test.ts b/tests/mocapLifecycleFactory.test.ts new file mode 100644 index 0000000..3cec1ba --- /dev/null +++ b/tests/mocapLifecycleFactory.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const repoRoot = process.cwd(); + +test("reanalyze route delegates lifecycle dependencies to the Prisma factory", () => { + const source = readFileSync( + join(repoRoot, "src/app/api/mocap/sessions/[id]/reanalyze/route.ts"), + "utf8", + ); + + assert.match(source, /getPrismaMocapSessionLifecycle/); + assert.doesNotMatch(source, /@\/lib\/db\/prisma/); + assert.doesNotMatch(source, /@\/lib\/mocap\/storage/); + assert.doesNotMatch(source, /@\/lib\/mocap\/sessionAnalysis/); + assert.doesNotMatch(source, /reanalyzeMocapSessionLifecycle/); + assert.doesNotMatch(source, /\bprisma\./); + assert.doesNotMatch(source, /\bgetMocapStorage\b/); +}); + +test("Prisma lifecycle factory owns the reanalyze wiring", () => { + const source = readFileSync( + join(repoRoot, "src/lib/mocap/lifecyclePrisma.ts"), + "utf8", + ); + + assert.match(source, /export function createPrismaMocapSessionLifecycle/); + assert.match(source, /reanalyze\(userId, mocapSessionId\)/); + assert.match(source, /mocapLifecycleSessionSelect/); + assert.match(source, /select: mocapLifecycleSessionSelect/); + assert.match(source, /setStatus: setMocapSessionStatus/); + assert.match(source, /getMocapStorage\(\)/); + assert.match(source, /analyzePoseSegmented: analyzeAndPersistMocapSession/); + assert.match(source, /analyzeCsvAligned: analyzeAndPersistMocapSessionLinked/); + assert.doesNotMatch(source, /throw new Error\(".*must use/); +}); From 9966c096c90d54b47ad1debf4ad86186ec24da2c Mon Sep 17 00:00:00 2001 From: Rupert Germann Date: Sat, 4 Jul 2026 21:20:25 +0200 Subject: [PATCH 2/2] Complete mocap lifecycle factory migration --- .../api/mocap/sessions/[id]/finalize/route.ts | 41 +-- .../[id]/link/[rowingSessionId]/route.ts | 75 +--- .../api/mocap/sessions/[id]/unlink/route.ts | 55 +-- src/lib/mocap/lifecycle.ts | 86 +++-- src/lib/mocap/lifecyclePrisma.ts | 314 ++++++++++++++-- tests/mocapLifecycle.test.ts | 24 +- tests/mocapLifecycleFactory.test.ts | 347 +++++++++++++++++- 7 files changed, 692 insertions(+), 250 deletions(-) diff --git a/src/app/api/mocap/sessions/[id]/finalize/route.ts b/src/app/api/mocap/sessions/[id]/finalize/route.ts index 34392da..ec80473 100644 --- a/src/app/api/mocap/sessions/[id]/finalize/route.ts +++ b/src/app/api/mocap/sessions/[id]/finalize/route.ts @@ -2,15 +2,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { z } from "zod"; import { authOptions } from "@/lib/auth"; -import { prisma } from "@/lib/db/prisma"; -import { getMocapStorage } from "@/lib/mocap/storage"; -import { finalizePoseStreamBlob } from "@/lib/mocap/capturePersistence"; -import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis"; -import { finalizeMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; -import { - setMocapCaptureFinalizationState, - setMocapSessionStatus, -} from "@/lib/mocap/lifecyclePrisma"; +import { getPrismaMocapSessionLifecycle } from "@/lib/mocap/lifecyclePrisma"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -45,35 +37,10 @@ export async function POST( ); } - const storage = getMocapStorage(); - const result = await finalizeMocapSessionLifecycle( + const result = await getPrismaMocapSessionLifecycle().finalize( + session.user.id, + id, { - storage, - findSession: (userId, mocapSessionId) => - prisma.mocapSession.findFirst({ - where: { id: mocapSessionId, userId }, - select: { - id: true, - userId: true, - status: true, - rowingSessionId: true, - poseStreamPath: true, - capturePerspective: true, - calibrationCatchFrame: true, - calibrationFinishFrame: true, - }, - }), - setStatus: setMocapSessionStatus, - setCaptureFinalizationState: setMocapCaptureFinalizationState, - finalizePoseStream: finalizePoseStreamBlob, - analyzePoseSegmented: analyzeAndPersistMocapSession, - analyzeCsvAligned: async () => { - throw new Error("Finalize lifecycle must use pose-segmented analysis"); - }, - }, - { - userId: session.user.id, - mocapSessionId: id, durationSec: body.durationSec, qualityScore: body.qualityScore, qualityFlags: body.qualityFlags, diff --git a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts index 2666f53..0658981 100644 --- a/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts +++ b/src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts @@ -1,12 +1,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { prisma } from "@/lib/db/prisma"; -import { getMocapStorage } from "@/lib/mocap/storage"; -import { analyzeAndPersistMocapSessionLinked } from "@/lib/mocap/sessionAnalysis"; -import { linkMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; -import { setMocapSessionStatus } from "@/lib/mocap/lifecyclePrisma"; -import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; +import { getPrismaMocapSessionLifecycle } from "@/lib/mocap/lifecyclePrisma"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -22,70 +17,10 @@ export async function POST( const { id, rowingSessionId } = await params; const userId = session.user.id; - const storage = getMocapStorage(); - - const result = await linkMocapSessionLifecycle( - { - storage, - findSession: (ownerId, mocapSessionId) => - prisma.mocapSession.findFirst({ - where: { id: mocapSessionId, userId: ownerId }, - select: { - id: true, - userId: true, - status: true, - rowingSessionId: true, - poseStreamPath: true, - capturePerspective: true, - calibrationCatchFrame: true, - calibrationFinishFrame: true, - }, - }), - findRowingSession: (ownerId, targetRowingSessionId) => - prisma.rowingSession.findFirst({ - where: { id: targetRowingSessionId, userId: ownerId }, - select: { - id: true, - mocapSession: { select: { id: true } }, - }, - }), - setStatus: setMocapSessionStatus, - assignMocapSession: async (mocapSessionId, ownerId, targetRowingSessionId) => { - try { - const update = await prisma.mocapSession.updateMany({ - where: { - id: mocapSessionId, - userId: ownerId, - rowingSessionId: null, - status: "ready", - }, - data: { - rowingSessionId: targetRowingSessionId, - status: "analyzing", - }, - }); - return update.count === 1 ? "assigned" : "mocap-conflict"; - } catch (err) { - if ((err as { code?: string })?.code === "P2002") { - return "rowing-conflict"; - } - throw err; - } - }, - restoreMocapSessionAssignment: (mocapSessionId, restoredRowingSessionId, status) => - prisma.mocapSession - .update({ - where: { id: mocapSessionId }, - data: { rowingSessionId: restoredRowingSessionId, status }, - }) - .then(() => undefined), - bumpSessionsRevision: bumpUserSessionsRevision, - analyzePoseSegmented: async () => { - throw new Error("Link lifecycle must use csv-aligned analysis"); - }, - analyzeCsvAligned: analyzeAndPersistMocapSessionLinked, - }, - { userId, mocapSessionId: id, rowingSessionId }, + const result = await getPrismaMocapSessionLifecycle().link( + userId, + id, + rowingSessionId, ); if (!result.ok) { diff --git a/src/app/api/mocap/sessions/[id]/unlink/route.ts b/src/app/api/mocap/sessions/[id]/unlink/route.ts index 1659f5f..55df482 100644 --- a/src/app/api/mocap/sessions/[id]/unlink/route.ts +++ b/src/app/api/mocap/sessions/[id]/unlink/route.ts @@ -1,12 +1,7 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { prisma } from "@/lib/db/prisma"; -import { getMocapStorage } from "@/lib/mocap/storage"; -import { analyzeAndPersistMocapSession } from "@/lib/mocap/sessionAnalysis"; -import { unlinkMocapSessionLifecycle } from "@/lib/mocap/lifecycle"; -import { setMocapSessionStatus } from "@/lib/mocap/lifecyclePrisma"; -import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; +import { getPrismaMocapSessionLifecycle } from "@/lib/mocap/lifecyclePrisma"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -22,53 +17,7 @@ export async function POST( const { id } = await params; const userId = session.user.id; - const storage = getMocapStorage(); - - const result = await unlinkMocapSessionLifecycle( - { - storage, - findSession: (ownerId, mocapSessionId) => - prisma.mocapSession.findFirst({ - where: { id: mocapSessionId, userId: ownerId }, - select: { - id: true, - userId: true, - status: true, - rowingSessionId: true, - poseStreamPath: true, - capturePerspective: true, - calibrationCatchFrame: true, - calibrationFinishFrame: true, - }, - }), - setStatus: setMocapSessionStatus, - unassignMocapSession: async (mocapSessionId, ownerId, rowingSessionId) => { - const update = await prisma.mocapSession.updateMany({ - where: { - id: mocapSessionId, - userId: ownerId, - rowingSessionId, - status: "ready", - }, - data: { rowingSessionId: null, status: "analyzing" }, - }); - return update.count === 1; - }, - restoreMocapSessionAssignment: (mocapSessionId, restoredRowingSessionId, status) => - prisma.mocapSession - .update({ - where: { id: mocapSessionId }, - data: { rowingSessionId: restoredRowingSessionId, status }, - }) - .then(() => undefined), - bumpSessionsRevision: bumpUserSessionsRevision, - analyzePoseSegmented: analyzeAndPersistMocapSession, - analyzeCsvAligned: async () => { - throw new Error("Unlink lifecycle must use pose-segmented analysis"); - }, - }, - { userId, mocapSessionId: id }, - ); + const result = await getPrismaMocapSessionLifecycle().unlink(userId, id); if (!result.ok) { return NextResponse.json( diff --git a/src/lib/mocap/lifecycle.ts b/src/lib/mocap/lifecycle.ts index 2e33c3b..be4e932 100644 --- a/src/lib/mocap/lifecycle.ts +++ b/src/lib/mocap/lifecycle.ts @@ -142,32 +142,78 @@ export interface ReanalyzeMocapSessionDependencies { ): Promise; } -export interface MocapLifecycleDependencies - extends ReanalyzeMocapSessionDependencies { - findRowingSession?( +interface MocapLifecycleStorageDependencies { + storage: Pick; +} + +interface MocapLifecycleSessionLookupDependencies + extends MocapLifecycleStorageDependencies { + findSession( + userId: string, + mocapSessionId: string, + ): Promise; +} + +export interface LinkMocapSessionDependencies + extends MocapLifecycleSessionLookupDependencies { + findRowingSession( userId: string, rowingSessionId: string, ): Promise; - assignMocapSession?( + setStatus( + mocapSessionId: string, + status: "capturing" | "analyzing" | "ready", + transition?: { from: string | string[] }, + ): Promise<{ status: string } | null>; + assignMocapSession( mocapSessionId: string, userId: string, rowingSessionId: string, ): Promise; - unassignMocapSession?( + restoreMocapSessionAssignment( + mocapSessionId: string, + rowingSessionId: string | null, + status: "ready", + ): Promise; + bumpSessionsRevision?(userId: string): Promise; + analyzeCsvAligned( + storage: MocapStorage, + session: MocapLifecycleSession, + rowingSessionId: string, + ): Promise; +} + +export interface UnlinkMocapSessionDependencies + extends MocapLifecycleSessionLookupDependencies { + setStatus( + mocapSessionId: string, + status: "capturing" | "analyzing" | "ready", + transition?: { from: string | string[] }, + ): Promise<{ status: string } | null>; + unassignMocapSession( mocapSessionId: string, userId: string, rowingSessionId: string, ): Promise; - restoreMocapSessionAssignment?( + restoreMocapSessionAssignment( mocapSessionId: string, rowingSessionId: string | null, status: "ready", ): Promise; bumpSessionsRevision?(userId: string): Promise; + analyzePoseSegmented( + storage: MocapStorage, + session: MocapLifecycleSession, + ): Promise; } export interface FinalizeMocapSessionDependencies - extends MocapLifecycleDependencies { + extends MocapLifecycleSessionLookupDependencies { + setStatus( + mocapSessionId: string, + status: "capturing" | "analyzing" | "ready", + transition?: { from: string | string[] }, + ): Promise<{ status: string } | null>; finalizePoseStream( storage: MocapStorage, poseStreamPath: string, @@ -177,6 +223,10 @@ export interface FinalizeMocapSessionDependencies state: MocapCaptureFinalizationState, transition?: { from: string | string[] }, ): Promise<{ status: string; durationSec: number } | null>; + analyzePoseSegmented( + storage: MocapStorage, + session: MocapLifecycleSession, + ): Promise; } export async function finalizeMocapSessionLifecycle( @@ -293,7 +343,7 @@ export async function finalizeMocapSessionLifecycle( } export async function reanalyzeMocapSessionLifecycle( - deps: MocapLifecycleDependencies, + deps: ReanalyzeMocapSessionDependencies, input: ReanalyzeMocapSessionInput, ): Promise { const session = await deps.findSession(input.userId, input.mocapSessionId); @@ -362,7 +412,7 @@ export async function reanalyzeMocapSessionLifecycle( } export async function linkMocapSessionLifecycle( - deps: MocapLifecycleDependencies, + deps: LinkMocapSessionDependencies, input: LinkMocapSessionInput, ): Promise { const session = await deps.findSession(input.userId, input.mocapSessionId); @@ -380,9 +430,6 @@ export async function linkMocapSessionLifecycle( error: "Mocap session is already linked to a rowing session. Unlink first.", }; } - if (!deps.findRowingSession) { - throw new Error("findRowingSession dependency is required for linking"); - } const rowingSession = await deps.findRowingSession( input.userId, input.rowingSessionId, @@ -397,9 +444,6 @@ export async function linkMocapSessionLifecycle( error: "Rowing session is already linked to another mocap session.", }; } - if (!deps.assignMocapSession) { - throw new Error("assignMocapSession dependency is required for linking"); - } const assigned = await deps.assignMocapSession( session.id, input.userId, @@ -456,7 +500,7 @@ export async function linkMocapSessionLifecycle( } export async function unlinkMocapSessionLifecycle( - deps: MocapLifecycleDependencies, + deps: UnlinkMocapSessionDependencies, input: ReanalyzeMocapSessionInput, ): Promise { const session = await deps.findSession(input.userId, input.mocapSessionId); @@ -475,9 +519,6 @@ export async function unlinkMocapSessionLifecycle( error: "Mocap session is not linked to a rowing session.", }; } - if (!deps.unassignMocapSession) { - throw new Error("unassignMocapSession dependency is required for unlinking"); - } const unassigned = await deps.unassignMocapSession( session.id, input.userId, @@ -525,7 +566,7 @@ export async function unlinkMocapSessionLifecycle( } async function validateReadyAnalyzableSession( - deps: MocapLifecycleDependencies, + deps: MocapLifecycleStorageDependencies, session: MocapLifecycleSession, ): Promise<{ ok: true } | { ok: false; status: number; error: string }> { if (session.status !== "ready") { @@ -546,13 +587,10 @@ async function validateReadyAnalyzableSession( } async function restoreAssignment( - deps: MocapLifecycleDependencies, + deps: Pick, mocapSessionId: string, rowingSessionId: string | null, ): Promise { - if (!deps.restoreMocapSessionAssignment) { - throw new Error("restoreMocapSessionAssignment dependency is required"); - } await deps.restoreMocapSessionAssignment(mocapSessionId, rowingSessionId, "ready"); } diff --git a/src/lib/mocap/lifecyclePrisma.ts b/src/lib/mocap/lifecyclePrisma.ts index 7abf18c..2caab29 100644 --- a/src/lib/mocap/lifecyclePrisma.ts +++ b/src/lib/mocap/lifecyclePrisma.ts @@ -1,19 +1,85 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db/prisma"; import { + finalizeMocapSessionLifecycle, + linkMocapSessionLifecycle, reanalyzeMocapSessionLifecycle, + unlinkMocapSessionLifecycle, + type FinalizeMocapSessionInput, + type FinalizeMocapSessionResult, + type LinkMocapSessionResult, type MocapCaptureFinalizationState, + type MocapLifecycleRowingSession, + type MocapLifecycleSession, + type ReanalyzeMocapSessionInput, type ReanalyzeMocapSessionDependencies, type ReanalyzeMocapSessionResult, + type UnlinkMocapSessionResult, } from "@/lib/mocap/lifecycle"; +import { finalizePoseStreamBlob } from "@/lib/mocap/capturePersistence"; import { getMocapStorage } from "@/lib/mocap/storage"; +import type { MocapStorage } from "@/lib/mocap/storage"; import { analyzeAndPersistMocapSession, analyzeAndPersistMocapSessionLinked, } from "@/lib/mocap/sessionAnalysis"; +import { bumpUserSessionsRevision } from "@/lib/server/userSettings"; type MocapStatus = "capturing" | "analyzing" | "ready"; type StatusTransition = { from: string | string[] }; +type SessionStatusUpdate = Partial< + Omit & { + rowingSessionId: string | null; + status: MocapStatus; + } +>; + +interface MocapLifecyclePrismaClient { + mocapSession: { + findFirst(args: { + where: { id: string; userId: string }; + select: typeof mocapLifecycleSessionSelect; + }): Promise; + updateMany(args: { + where: { + id: string; + userId?: string; + rowingSessionId?: string | null; + status?: string | { in: string[] }; + }; + data: SessionStatusUpdate; + }): Promise<{ count: number }>; + findUniqueOrThrow(args: { + where: { id: string }; + select: Record; + }): Promise<{ status: string; durationSec?: number }>; + update(args: { + where: { id: string }; + data: SessionStatusUpdate; + select?: Record; + }): Promise; + }; + rowingSession: { + findFirst(args: { + where: { id: string; userId: string }; + select: { id: true; mocapSession: { select: { id: true } } }; + }): Promise; + }; +} + +type FinalizeVerbInput = Omit< + FinalizeMocapSessionInput, + keyof ReanalyzeMocapSessionInput +>; + +export interface CreatePrismaMocapSessionLifecycleOptions { + prisma?: MocapLifecyclePrismaClient; + storage?: MocapStorage; + finalizePoseStream?: typeof finalizePoseStreamBlob; + analyzePoseSegmented?: typeof analyzeAndPersistMocapSession; + analyzeCsvAligned?: typeof analyzeAndPersistMocapSessionLinked; + bumpSessionsRevision?: (userId: string) => Promise; +} export const mocapLifecycleSessionSelect = { id: true, @@ -31,20 +97,49 @@ export interface MocapSessionLifecycle { userId: string, mocapSessionId: string, ): Promise; + finalize( + userId: string, + mocapSessionId: string, + input: FinalizeVerbInput, + ): Promise; + link( + userId: string, + mocapSessionId: string, + rowingSessionId: string, + ): Promise; + unlink( + userId: string, + mocapSessionId: string, + ): Promise; } -export function createPrismaMocapSessionLifecycle(): MocapSessionLifecycle { - const storage = getMocapStorage(); +export function createPrismaMocapSessionLifecycle( + options: CreatePrismaMocapSessionLifecycleOptions = {}, +): MocapSessionLifecycle { + const db = (options.prisma ?? prisma) as MocapLifecyclePrismaClient; + const storage = options.storage ?? getMocapStorage(); + const setStatus = createSetMocapSessionStatus(db); + const setCaptureFinalizationState = + createSetMocapCaptureFinalizationState(db); + const finalizePoseStream = + options.finalizePoseStream ?? finalizePoseStreamBlob; + const analyzePoseSegmented = + options.analyzePoseSegmented ?? analyzeAndPersistMocapSession; + const analyzeCsvAligned = + options.analyzeCsvAligned ?? analyzeAndPersistMocapSessionLinked; + const bumpSessionsRevision = + options.bumpSessionsRevision ?? bumpUserSessionsRevision; + const findSession = (userId: string, mocapSessionId: string) => + db.mocapSession.findFirst({ + where: { id: mocapSessionId, userId }, + select: mocapLifecycleSessionSelect, + }); const reanalyzeDeps: ReanalyzeMocapSessionDependencies = { storage, - findSession: (userId, mocapSessionId) => - prisma.mocapSession.findFirst({ - where: { id: mocapSessionId, userId }, - select: mocapLifecycleSessionSelect, - }), - setStatus: setMocapSessionStatus, - analyzePoseSegmented: analyzeAndPersistMocapSession, - analyzeCsvAligned: analyzeAndPersistMocapSessionLinked, + findSession, + setStatus, + analyzePoseSegmented, + analyzeCsvAligned, }; return { @@ -54,6 +149,119 @@ export function createPrismaMocapSessionLifecycle(): MocapSessionLifecycle { mocapSessionId, }); }, + finalize(userId, mocapSessionId, input) { + return finalizeMocapSessionLifecycle( + { + storage, + findSession, + setStatus, + setCaptureFinalizationState, + finalizePoseStream, + analyzePoseSegmented, + }, + { + userId, + mocapSessionId, + ...input, + }, + ); + }, + link(userId, mocapSessionId, rowingSessionId) { + return linkMocapSessionLifecycle( + { + storage, + findSession, + findRowingSession: (ownerId, targetRowingSessionId) => + db.rowingSession.findFirst({ + where: { id: targetRowingSessionId, userId: ownerId }, + select: { + id: true, + mocapSession: { select: { id: true } }, + }, + }), + setStatus, + assignMocapSession: async ( + targetMocapSessionId, + ownerId, + targetRowingSessionId, + ) => { + try { + const update = await db.mocapSession.updateMany({ + where: { + id: targetMocapSessionId, + userId: ownerId, + rowingSessionId: null, + status: "ready", + }, + data: { + rowingSessionId: targetRowingSessionId, + status: "analyzing", + }, + }); + return update.count === 1 ? "assigned" : "mocap-conflict"; + } catch (err) { + if ((err as { code?: string })?.code === "P2002") { + return "rowing-conflict"; + } + throw err; + } + }, + restoreMocapSessionAssignment: ( + targetMocapSessionId, + restoredRowingSessionId, + status, + ) => + db.mocapSession + .update({ + where: { id: targetMocapSessionId }, + data: { rowingSessionId: restoredRowingSessionId, status }, + }) + .then(() => undefined), + bumpSessionsRevision, + analyzeCsvAligned, + }, + { userId, mocapSessionId, rowingSessionId }, + ); + }, + unlink(userId, mocapSessionId) { + return unlinkMocapSessionLifecycle( + { + storage, + findSession, + setStatus, + unassignMocapSession: async ( + targetMocapSessionId, + ownerId, + rowingSessionId, + ) => { + const update = await db.mocapSession.updateMany({ + where: { + id: targetMocapSessionId, + userId: ownerId, + rowingSessionId, + status: "ready", + }, + data: { rowingSessionId: null, status: "analyzing" }, + }); + return update.count === 1; + }, + restoreMocapSessionAssignment: ( + targetMocapSessionId, + restoredRowingSessionId, + status, + ) => + db.mocapSession + .update({ + where: { id: targetMocapSessionId }, + data: { rowingSessionId: restoredRowingSessionId, status }, + }) + .then(() => undefined), + bumpSessionsRevision, + analyzePoseSegmented, + }, + { userId, mocapSessionId }, + ); + }, }; } @@ -82,23 +290,36 @@ export async function setMocapSessionStatus( status: MocapStatus, transition?: StatusTransition, ): Promise<{ status: string } | null> { - if (transition) { - const updated = await prisma.mocapSession.updateMany({ - where: transitionStatusWhere(mocapSessionId, transition), - data: { status }, - }); - if (updated.count !== 1) return null; - return prisma.mocapSession.findUniqueOrThrow({ + return createSetMocapSessionStatus( + prisma as unknown as MocapLifecyclePrismaClient, + )(mocapSessionId, status, transition); +} + +function createSetMocapSessionStatus(db: MocapLifecyclePrismaClient) { + return async ( + mocapSessionId: string, + status: MocapStatus, + transition?: StatusTransition, + ): Promise<{ status: string } | null> => { + if (transition) { + const updated = await db.mocapSession.updateMany({ + where: transitionStatusWhere(mocapSessionId, transition), + data: { status }, + }); + if (updated.count !== 1) return null; + return db.mocapSession.findUniqueOrThrow({ + where: { id: mocapSessionId }, + select: { status: true }, + }); + } + + const row = await db.mocapSession.update({ where: { id: mocapSessionId }, + data: { status }, select: { status: true }, }); - } - - return prisma.mocapSession.update({ - where: { id: mocapSessionId }, - data: { status }, - select: { status: true }, - }); + return row as { status: string }; + }; } export async function setMocapCaptureFinalizationState( @@ -106,21 +327,38 @@ export async function setMocapCaptureFinalizationState( state: MocapCaptureFinalizationState, transition?: StatusTransition, ): Promise<{ status: string; durationSec: number } | null> { - if (transition) { - const updated = await prisma.mocapSession.updateMany({ - where: transitionStatusWhere(mocapSessionId, transition), - data: state, - }); - if (updated.count !== 1) return null; - return prisma.mocapSession.findUniqueOrThrow({ + return createSetMocapCaptureFinalizationState( + prisma as unknown as MocapLifecyclePrismaClient, + )(mocapSessionId, state, transition); +} + +function createSetMocapCaptureFinalizationState( + db: MocapLifecyclePrismaClient, +) { + return async ( + mocapSessionId: string, + state: MocapCaptureFinalizationState, + transition?: StatusTransition, + ): Promise<{ status: string; durationSec: number } | null> => { + if (transition) { + const updated = await db.mocapSession.updateMany({ + where: transitionStatusWhere(mocapSessionId, transition), + data: state, + }); + if (updated.count !== 1) return null; + const row = await db.mocapSession.findUniqueOrThrow({ + where: { id: mocapSessionId }, + select: { status: true, durationSec: true }, + }); + return { status: row.status, durationSec: row.durationSec ?? 0 }; + } + + const row = await db.mocapSession.update({ where: { id: mocapSessionId }, + data: state, select: { status: true, durationSec: true }, }); - } - - return prisma.mocapSession.update({ - where: { id: mocapSessionId }, - data: state, - select: { status: true, durationSec: true }, - }); + const record = row as { status: string; durationSec?: number }; + return { status: record.status, durationSec: record.durationSec ?? 0 }; + }; } diff --git a/tests/mocapLifecycle.test.ts b/tests/mocapLifecycle.test.ts index 1b11370..d7d447d 100644 --- a/tests/mocapLifecycle.test.ts +++ b/tests/mocapLifecycle.test.ts @@ -6,9 +6,17 @@ import { linkMocapSessionLifecycle, reanalyzeMocapSessionLifecycle, unlinkMocapSessionLifecycle, + type FinalizeMocapSessionDependencies, + type LinkMocapSessionDependencies, type MocapLifecycleSession, + type ReanalyzeMocapSessionDependencies, + type UnlinkMocapSessionDependencies, } from "../src/lib/mocap/lifecycle"; +type FullLifecycleTestDeps = ReanalyzeMocapSessionDependencies & + LinkMocapSessionDependencies & + UnlinkMocapSessionDependencies; + test("reanalyzeMocapSessionLifecycle keeps linked sessions csv-aligned", async () => { const calls: string[] = []; const session = makeSession({ rowingSessionId: "rowing-1" }); @@ -196,10 +204,6 @@ test("finalizeMocapSessionLifecycle finalizes and analyzes before returning read calls.push(`pose-segmented:${analysisSession.status}`); return { strokeMetricCount: 5, faultCount: 2 }; }, - analyzeCsvAligned: async () => { - calls.push("csv-aligned"); - return { strokeMetricCount: 0, faultCount: 0 }; - }, setStatus: async (_id, status) => { calls.push(`status:${status}`); return { status }; @@ -323,10 +327,6 @@ test("finalizeMocapSessionLifecycle completes record-only captures without analy calls.push("pose-segmented"); throw new Error("record-only should not run pose analysis"); }, - analyzeCsvAligned: async () => { - calls.push("csv-aligned"); - throw new Error("record-only should not run csv-aligned analysis"); - }, }), { userId: "user-1", @@ -787,8 +787,8 @@ function makeSession( } function makeDeps( - overrides: Partial[0]> = {}, -): Parameters[0] { + overrides: Partial = {}, +): FullLifecycleTestDeps { return { storage: { exists: async () => true }, findSession: async () => makeSession(), @@ -805,8 +805,8 @@ function makeDeps( } function makeFinalizeDeps( - overrides: Partial[0]> = {}, -): Parameters[0] { + overrides: Partial = {}, +): FinalizeMocapSessionDependencies { return { ...makeDeps(), findSession: async () => makeSession({ status: "capturing" }), diff --git a/tests/mocapLifecycleFactory.test.ts b/tests/mocapLifecycleFactory.test.ts index 3cec1ba..9925712 100644 --- a/tests/mocapLifecycleFactory.test.ts +++ b/tests/mocapLifecycleFactory.test.ts @@ -3,36 +3,351 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { test } from "node:test"; +import { createPrismaMocapSessionLifecycle } from "../src/lib/mocap/lifecyclePrisma"; +import type { MocapLifecycleSession } from "../src/lib/mocap/lifecycle"; +import type { MocapStorage } from "../src/lib/mocap/storage"; + const repoRoot = process.cwd(); -test("reanalyze route delegates lifecycle dependencies to the Prisma factory", () => { - const source = readFileSync( - join(repoRoot, "src/app/api/mocap/sessions/[id]/reanalyze/route.ts"), - "utf8", - ); +test("lifecycle routes delegate dependencies to the Prisma factory", () => { + for (const route of [ + "src/app/api/mocap/sessions/[id]/finalize/route.ts", + "src/app/api/mocap/sessions/[id]/reanalyze/route.ts", + "src/app/api/mocap/sessions/[id]/link/[rowingSessionId]/route.ts", + "src/app/api/mocap/sessions/[id]/unlink/route.ts", + ]) { + const source = readFileSync(join(repoRoot, route), "utf8"); - assert.match(source, /getPrismaMocapSessionLifecycle/); - assert.doesNotMatch(source, /@\/lib\/db\/prisma/); - assert.doesNotMatch(source, /@\/lib\/mocap\/storage/); - assert.doesNotMatch(source, /@\/lib\/mocap\/sessionAnalysis/); - assert.doesNotMatch(source, /reanalyzeMocapSessionLifecycle/); - assert.doesNotMatch(source, /\bprisma\./); - assert.doesNotMatch(source, /\bgetMocapStorage\b/); + assert.match(source, /getPrismaMocapSessionLifecycle/); + assert.doesNotMatch(source, /@\/lib\/db\/prisma/); + assert.doesNotMatch(source, /@\/lib\/mocap\/storage/); + assert.doesNotMatch(source, /@\/lib\/mocap\/sessionAnalysis/); + assert.doesNotMatch(source, /finalizeMocapSessionLifecycle/); + assert.doesNotMatch(source, /reanalyzeMocapSessionLifecycle/); + assert.doesNotMatch(source, /linkMocapSessionLifecycle/); + assert.doesNotMatch(source, /unlinkMocapSessionLifecycle/); + assert.doesNotMatch(source, /\bprisma\./); + assert.doesNotMatch(source, /\bgetMocapStorage\b/); + } }); -test("Prisma lifecycle factory owns the reanalyze wiring", () => { +test("Prisma lifecycle factory owns all verb wiring without throwing stubs", () => { const source = readFileSync( join(repoRoot, "src/lib/mocap/lifecyclePrisma.ts"), "utf8", ); assert.match(source, /export function createPrismaMocapSessionLifecycle/); + assert.match(source, /finalize\(userId, mocapSessionId, input\)/); assert.match(source, /reanalyze\(userId, mocapSessionId\)/); + assert.match(source, /link\(userId, mocapSessionId, rowingSessionId\)/); + assert.match(source, /unlink\(userId, mocapSessionId\)/); assert.match(source, /mocapLifecycleSessionSelect/); assert.match(source, /select: mocapLifecycleSessionSelect/); - assert.match(source, /setStatus: setMocapSessionStatus/); assert.match(source, /getMocapStorage\(\)/); - assert.match(source, /analyzePoseSegmented: analyzeAndPersistMocapSession/); - assert.match(source, /analyzeCsvAligned: analyzeAndPersistMocapSessionLinked/); + assert.match( + source, + /options\.analyzePoseSegmented \?\? analyzeAndPersistMocapSession/, + ); + assert.match( + source, + /options\.analyzeCsvAligned \?\? analyzeAndPersistMocapSessionLinked/, + ); assert.doesNotMatch(source, /throw new Error\(".*must use/); }); + +test("factory drives finalize and reanalyze through injectable Prisma seams", async () => { + const finalizeState = makeFactoryState({ + mocapSession: makeFactorySession({ status: "capturing" }), + }); + const finalizeLifecycle = makeFactory(finalizeState); + + const finalized = await finalizeLifecycle.finalize("user-1", "mocap-1", { + durationSec: 42, + qualityScore: 0.91, + qualityFlags: ["steady"], + }); + + assert.deepEqual(finalized, { + ok: true, + id: "mocap-1", + status: "ready", + analysisMode: "pose-segmented", + durationSec: 42, + frameCount: 12, + poseStreamBytes: 4096, + strokeMetricCount: 3, + faultCount: 1, + }); + assert.equal(finalizeState.mocapSession.status, "ready"); + assert.equal(finalizeState.mocapSession.durationSec, 42); + assert.equal(finalizeState.revisionBumps, 0); + + const reanalyzeState = makeFactoryState(); + const reanalyzeLifecycle = makeFactory(reanalyzeState); + + const reanalyzed = await reanalyzeLifecycle.reanalyze("user-1", "mocap-1"); + + assert.deepEqual(reanalyzed, { + ok: true, + id: "mocap-1", + status: "ready", + analysisMode: "pose-segmented", + strokeMetricCount: 3, + faultCount: 1, + }); + assert.equal(reanalyzeState.mocapSession.status, "ready"); + assert.equal(reanalyzeState.revisionBumps, 0); +}); + +test("factory maps Prisma unique assignment conflicts to rowing-session conflicts", async () => { + const state = makeFactoryState({ uniqueConflictOnAssign: true }); + const lifecycle = makeFactory(state); + + const result = await lifecycle.link("user-1", "mocap-1", "rowing-1"); + + assert.deepEqual(result, { + ok: false, + status: 409, + error: "Rowing session is already linked to another mocap session.", + }); + assert.equal(state.mocapSession.rowingSessionId, null); + assert.equal(state.mocapSession.status, "ready"); + assert.equal(state.revisionBumps, 0); +}); + +test("factory preserves state when the link optimistic guard loses a race", async () => { + const state = makeFactoryState({ lostAssignmentRace: true }); + const lifecycle = makeFactory(state); + + const result = await lifecycle.link("user-1", "mocap-1", "rowing-1"); + + assert.deepEqual(result, { + ok: false, + status: 409, + error: "Mocap session is already linked to a rowing session. Unlink first.", + }); + assert.equal(state.mocapSession.rowingSessionId, null); + assert.equal(state.mocapSession.status, "ready"); + assert.equal(state.revisionBumps, 0); +}); + +test("factory rolls back link assignment when csv-aligned analysis fails", async () => { + const state = makeFactoryState({ failCsvAnalysis: true }); + const lifecycle = makeFactory(state); + + const result = await lifecycle.link("user-1", "mocap-1", "rowing-1"); + + assert.deepEqual(result, { + ok: false, + status: 500, + error: "csv analysis failed", + }); + assert.equal(state.mocapSession.rowingSessionId, null); + assert.equal(state.mocapSession.status, "ready"); + assert.equal(state.revisionBumps, 0); +}); + +test("factory restores unlink assignment when pose-segmented analysis fails", async () => { + const state = makeFactoryState({ + mocapSession: makeFactorySession({ rowingSessionId: "rowing-1" }), + failPoseAnalysis: true, + }); + const lifecycle = makeFactory(state); + + const result = await lifecycle.unlink("user-1", "mocap-1"); + + assert.deepEqual(result, { + ok: false, + status: 500, + error: "pose analysis failed", + }); + assert.equal(state.mocapSession.rowingSessionId, "rowing-1"); + assert.equal(state.mocapSession.status, "ready"); + assert.equal(state.revisionBumps, 0); +}); + +test("factory bumps sessions revision once per successful link and unlink", async () => { + const linkState = makeFactoryState(); + const linkLifecycle = makeFactory(linkState); + + const link = await linkLifecycle.link("user-1", "mocap-1", "rowing-1"); + + assert.equal(link.ok, true); + assert.equal(linkState.mocapSession.rowingSessionId, "rowing-1"); + assert.equal(linkState.mocapSession.status, "ready"); + assert.equal(linkState.revisionBumps, 1); + + const unlinkState = makeFactoryState({ + mocapSession: makeFactorySession({ rowingSessionId: "rowing-1" }), + }); + const unlinkLifecycle = makeFactory(unlinkState); + + const unlink = await unlinkLifecycle.unlink("user-1", "mocap-1"); + + assert.equal(unlink.ok, true); + assert.equal(unlinkState.mocapSession.rowingSessionId, null); + assert.equal(unlinkState.mocapSession.status, "ready"); + assert.equal(unlinkState.revisionBumps, 1); +}); + +type FakeMocapSession = MocapLifecycleSession & { + durationSec: number; + qualityScore: number | null; + qualityFlags: string[]; +}; + +interface FactoryState { + mocapSession: FakeMocapSession; + uniqueConflictOnAssign: boolean; + lostAssignmentRace: boolean; + failPoseAnalysis: boolean; + failCsvAnalysis: boolean; + revisionBumps: number; +} + +function makeFactoryState( + overrides: Partial = {}, +): FactoryState { + return { + mocapSession: makeFactorySession(), + uniqueConflictOnAssign: false, + lostAssignmentRace: false, + failPoseAnalysis: false, + failCsvAnalysis: false, + revisionBumps: 0, + ...overrides, + }; +} + +function makeFactorySession( + overrides: Partial = {}, +): FakeMocapSession { + return { + id: "mocap-1", + userId: "user-1", + status: "ready", + rowingSessionId: null, + poseStreamPath: "mocap/user-1/mocap-1/pose-stream.bin", + capturePerspective: "side-right", + calibrationCatchFrame: null, + calibrationFinishFrame: null, + durationSec: 0, + qualityScore: null, + qualityFlags: [], + ...overrides, + }; +} + +function makeFactory(state: FactoryState) { + return createPrismaMocapSessionLifecycle({ + prisma: makeFakePrisma(state), + storage: makeFakeStorage(), + analyzePoseSegmented: async () => { + if (state.failPoseAnalysis) throw new Error("pose analysis failed"); + return { strokeMetricCount: 3, faultCount: 1 }; + }, + analyzeCsvAligned: async () => { + if (state.failCsvAnalysis) throw new Error("csv analysis failed"); + return { strokeMetricCount: 4, faultCount: 2 }; + }, + finalizePoseStream: async () => ({ + frameCount: 12, + poseStreamBytes: 4096, + }), + bumpSessionsRevision: async () => { + state.revisionBumps++; + }, + }); +} + +function makeFakePrisma(state: FactoryState) { + return { + mocapSession: { + findFirst: async () => state.mocapSession, + updateMany: async (args: { + where: { + id: string; + userId?: string; + rowingSessionId?: string | null; + status?: string | { in: string[] }; + }; + data: Partial; + }) => { + if (state.uniqueConflictOnAssign && args.data.rowingSessionId) { + const err = new Error("unique constraint") as Error & { + code?: string; + }; + err.code = "P2002"; + throw err; + } + if ( + state.lostAssignmentRace && + args.where.rowingSessionId === null && + args.data.rowingSessionId + ) { + return { count: 0 }; + } + if (!matchesMocapWhere(state.mocapSession, args.where)) { + return { count: 0 }; + } + Object.assign(state.mocapSession, args.data); + return { count: 1 }; + }, + findUniqueOrThrow: async () => ({ + status: state.mocapSession.status, + durationSec: state.mocapSession.durationSec, + }), + update: async (args: { data: Partial }) => { + Object.assign(state.mocapSession, args.data); + return state.mocapSession; + }, + }, + rowingSession: { + findFirst: async () => ({ + id: "rowing-1", + mocapSession: + state.mocapSession.rowingSessionId === "rowing-1" + ? { id: state.mocapSession.id } + : null, + }), + }, + }; +} + +function makeFakeStorage(): MocapStorage { + return { + videoPath: () => "video.webm", + poseStreamPath: () => "pose-stream.bin", + appendBytes: async () => {}, + writeAt: async () => {}, + read: async () => new Uint8Array(0), + size: async () => 0, + exists: async () => true, + delete: async () => {}, + }; +} + +function matchesMocapWhere( + session: FakeMocapSession, + where: { + id: string; + userId?: string; + rowingSessionId?: string | null; + status?: string | { in: string[] }; + }, +): boolean { + if (where.id !== session.id) return false; + if (where.userId !== undefined && where.userId !== session.userId) { + return false; + } + if ( + "rowingSessionId" in where && + where.rowingSessionId !== session.rowingSessionId + ) { + return false; + } + if (typeof where.status === "string") return where.status === session.status; + if (where.status) return where.status.in.includes(session.status); + return true; +}