From 932bbc795534469e2c5205c11bef3572eca88159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 10:30:27 +0200 Subject: [PATCH 01/35] chore: ignore isolated worktrees --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index a0e44962e..37b97efa8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,7 @@ next-env.d.ts # Local screenshot/audit harnesses (repo-root for module resolution; never committed) .audit-*.mjs + + +# Isolated development worktrees +/.worktrees/ \ No newline at end of file From d31a33e14c1d299de61427699f00234d091a9dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:08:50 +0200 Subject: [PATCH 02/35] fix(strava): preserve cursors on workout write failure --- .../__tests__/sync-cursor-durability.test.ts | 208 ++++++++++++++++++ src/lib/strava/sync.ts | 73 +++--- 2 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 src/lib/strava/__tests__/sync-cursor-durability.test.ts diff --git a/src/lib/strava/__tests__/sync-cursor-durability.test.ts b/src/lib/strava/__tests__/sync-cursor-durability.test.ts new file mode 100644 index 000000000..18019f19a --- /dev/null +++ b/src/lib/strava/__tests__/sync-cursor-durability.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StravaWorkoutRow } from "../client"; + +const mocks = vi.hoisted(() => ({ + fetchActivities: vi.fn(), + fetchActivityById: vi.fn(), + mapActivity: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + updateUser: vi.fn(), + createManyAndReturn: vi.fn(), + updateWorkout: vi.fn(), + getStravaConnection: vi.fn(), + recordSyncFailure: vi.fn(), + recordSyncSuccess: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: mocks.findUnique, + updateMany: mocks.updateMany, + update: mocks.updateUser, + }, + workout: { + createManyAndReturn: mocks.createManyAndReturn, + update: mocks.updateWorkout, + }, + }, +})); +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival: vi.fn(async () => {}), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ addWarning: vi.fn() }), +})); +vi.mock("@/lib/integrations/status", () => ({ + recordSyncFailure: mocks.recordSyncFailure, + recordSyncSuccess: mocks.recordSyncSuccess, + toFailureKind: vi.fn(() => "transient"), +})); +vi.mock("../credentials", () => ({ + getStravaConnection: mocks.getStravaConnection, + getStravaClientCredentials: vi.fn(), + storeStravaTokens: vi.fn(), +})); +vi.mock("../client", () => ({ + fetchActivities: mocks.fetchActivities, + fetchActivityById: mocks.fetchActivityById, + mapActivity: mocks.mapActivity, + refreshAccessToken: vi.fn(), + summaryHasHeartRate: vi.fn(() => true), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; +import { syncUserStrava } from "../sync"; + +const previousCursor = new Date("2026-07-01T08:00:00.000Z"); +const firstStart = new Date("2026-07-18T08:00:00.000Z"); +const secondStart = new Date("2026-07-19T08:00:00.000Z"); +const thirdStart = new Date("2026-07-20T08:00:00.000Z"); + +function workout(externalId: string, startedAt: Date): StravaWorkoutRow { + return { + externalId, + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 3_600_000), + durationSec: 3_600, + totalEnergyKcal: 500, + totalDistanceM: 10_000, + avgHeartRate: 145, + maxHeartRate: 170, + elevationM: 80, + metadata: {}, + }; +} + +function activity(id: number, row: StravaWorkoutRow) { + return { + id, + start_date: row.startedAt.toISOString(), + row, + }; +} + +describe("syncUserStrava cursor durability", () => { + let cursor: Date | null; + let failExternalId: string | null; + let workouts: Map; + let backfillCompletedAt: Date | null; + + beforeEach(() => { + vi.clearAllMocks(); + cursor = previousCursor; + failExternalId = null; + workouts = new Map(); + backfillCompletedAt = null; + + const rows = [ + workout("strava-1", firstStart), + workout("strava-2", secondStart), + workout("strava-3", thirdStart), + ]; + mocks.getStravaConnection.mockResolvedValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + refreshTokenCiphertext: Buffer.from("ciphertext"), + }); + mocks.findUnique.mockImplementation(async () => ({ + stravaLastActivityAt: cursor, + })); + mocks.fetchActivities.mockResolvedValue( + rows.map((row, index) => activity(index + 1, row)), + ); + mocks.fetchActivityById.mockResolvedValue({}); + mocks.mapActivity.mockImplementation((source) => source.row); + mocks.createManyAndReturn.mockImplementation(async ({ data }) => { + if (data.externalId === failExternalId) { + throw new Error("database unavailable"); + } + if (workouts.has(data.externalId)) return []; + workouts.set(data.externalId, data); + return [{ id: `workout-${data.externalId}`, startedAt: data.startedAt }]; + }); + mocks.updateWorkout.mockImplementation(async ({ where, data }) => { + const externalId = where.userId_source_externalId.externalId; + const existing = workouts.get(externalId); + if (!existing) throw new Error("missing workout"); + workouts.set(externalId, { ...existing, ...data }); + return { id: `workout-${externalId}` }; + }); + mocks.updateMany.mockImplementation(async ({ data }) => { + cursor = data.stravaLastActivityAt; + return { count: 1 }; + }); + mocks.updateUser.mockImplementation(async ({ data }) => { + backfillCompletedAt = data.stravaBackfillCompletedAt; + return { id: "user-1" }; + }); + mocks.recordSyncFailure.mockResolvedValue(undefined); + mocks.recordSyncSuccess.mockResolvedValue(undefined); + }); + + it("leaves the previous cursor unchanged and rejects when a workout write fails", async () => { + failExternalId = "strava-2"; + + await expect(syncUserStrava("user-1")).rejects.toThrow( + "database unavailable", + ); + + expect(cursor).toEqual(previousCursor); + expect(mocks.updateMany).not.toHaveBeenCalled(); + expect(workouts.has("strava-3")).toBe(true); + expect(mocks.recordSyncFailure).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + integration: "strava", + message: "database unavailable", + }), + ); + expect(mocks.recordSyncSuccess).not.toHaveBeenCalled(); + }); + + it("leaves the backfill watermark unchanged when a workout write fails", async () => { + failExternalId = "strava-2"; + + await expect(runStravaBackfillForUser("user-1")).rejects.toThrow( + "database unavailable", + ); + + expect(backfillCompletedAt).toBeNull(); + expect(mocks.updateUser).not.toHaveBeenCalled(); + }); + + it("advances the backfill watermark after every workout is persisted", async () => { + await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ + imported: 3, + }); + + expect(workouts.size).toBe(3); + expect(backfillCompletedAt).toBeInstanceOf(Date); + expect(mocks.updateUser).toHaveBeenCalledTimes(1); + }); + + it("advances the cursor only after every fetched workout is persisted", async () => { + await expect(syncUserStrava("user-1")).resolves.toBe(3); + + expect(workouts.size).toBe(3); + expect(cursor).toEqual(thirdStart); + expect(mocks.updateMany).toHaveBeenCalledTimes(1); + expect(mocks.recordSyncSuccess).toHaveBeenCalledWith("user-1", "strava"); + }); + + it("replays by external identity without creating duplicate workouts", async () => { + await expect(syncUserStrava("user-1")).resolves.toBe(3); + await expect(syncUserStrava("user-1")).resolves.toBe(3); + + expect(workouts.size).toBe(3); + expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(6); + expect(mocks.updateWorkout).toHaveBeenCalledTimes(3); + expect(cursor).toEqual(thirdStart); + }); +}); diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 4c195512c..800316eaf 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -68,6 +68,19 @@ export function classifyStravaFailure(err: unknown): FailureKind { return toFailureKind(classifyStravaError(err)); } +async function recordStravaFailure(userId: string, err: unknown): Promise { + await recordSyncFailure({ + userId, + integration: "strava", + kind: classifyStravaFailure(err), + message: err instanceof Error ? err.message : String(err), + errorCode: + err instanceof StravaApiError && err.httpStatus != null + ? String(err.httpStatus) + : undefined, + }); +} + export interface SyncUserStravaOptions { fullSync?: boolean; lookbackDays?: number; @@ -198,35 +211,32 @@ export async function syncUserStrava( if (activities.length < PER_PAGE) break; } } catch (err) { - await recordSyncFailure({ - userId, - integration: "strava", - kind: classifyStravaFailure(err), - message: err instanceof Error ? err.message : String(err), - errorCode: - err instanceof StravaApiError && err.httpStatus != null - ? String(err.httpStatus) - : undefined, - }); + await recordStravaFailure(userId, err); throw err; } - const imported = await upsertStravaWorkouts(userId, rows); + let imported: number; + try { + imported = await upsertStravaWorkouts(userId, rows); - // Advance the incremental cursor to the newest activity start we saw, but - // never move it backwards (a bounded backfill page-walk may not reach the - // very newest activity in one pass). - if (newestStart) { - await prisma.user.updateMany({ - where: { - id: userId, - OR: [ - { stravaLastActivityAt: null }, - { stravaLastActivityAt: { lt: newestStart } }, - ], - }, - data: { stravaLastActivityAt: newestStart }, - }); + // Advance the incremental cursor to the newest activity start we saw, but + // never move it backwards (a bounded backfill page-walk may not reach the + // very newest activity in one pass). + if (newestStart) { + await prisma.user.updateMany({ + where: { + id: userId, + OR: [ + { stravaLastActivityAt: null }, + { stravaLastActivityAt: { lt: newestStart } }, + ], + }, + data: { stravaLastActivityAt: newestStart }, + }); + } + } catch (err) { + await recordStravaFailure(userId, err); + throw err; } await recordSyncSuccess(userId, "strava"); @@ -239,8 +249,10 @@ export async function syncUserStrava( /** * Upsert a batch of mapped Strava activities into `Workout`, keyed on * `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place. - * Best-effort per row: a single bad row is logged, never thrown, so it can't - * fail the surrounding sync. + * `(userId, source = STRAVA, externalId)` so replays overwrite in place. + * Failed rows are logged while the rest of the batch is attempted, then the + * first error is rethrown so the caller cannot advance its cursor or backfill + * watermark past a partially persisted batch. */ export async function upsertStravaWorkouts( userId: string, @@ -249,6 +261,8 @@ export async function upsertStravaWorkouts( if (rows.length === 0) return 0; let imported = 0; + let firstError: unknown; + let hadError = false; for (const r of rows) { const data = { sportType: r.sportType, @@ -292,8 +306,13 @@ export async function upsertStravaWorkouts( imported += 1; } catch (err) { getEvent()?.addWarning(`strava: failed to upsert workout: ${err}`); + if (!hadError) { + firstError = err; + hadError = true; + } } } + if (hadError) throw firstError; annotate({ action: { name: "strava.workout.ingest", details: { imported } }, From 1f94249bd502b68bf9fd0ed80bc9b58f49d53836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:11:03 +0200 Subject: [PATCH 03/35] fix(mood): preserve rated factors during edits --- .../mood-entries/[id]/__tests__/route.test.ts | 225 +++++++++++++++++- src/app/api/mood-entries/[id]/route.ts | 74 ++++-- .../mood/__tests__/mood-qol-fixes.test.tsx | 14 ++ src/components/mood/mood-list.tsx | 18 +- src/lib/mood/__tests__/tag-links.test.ts | 57 ++++- src/lib/mood/tag-links.ts | 71 ++++-- 6 files changed, 403 insertions(+), 56 deletions(-) diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index ea8cbb29d..c036c0268 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -3,6 +3,16 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import type * as TagLinksModule from "@/lib/mood/tag-links"; +const txClient = vi.hoisted(() => ({ + moodEntry: { + update: vi.fn(), + }, + moodEntryTagLink: { + findMany: vi.fn(), + }, +})); + vi.mock("@/lib/db", () => ({ prisma: { @@ -12,10 +22,20 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), delete: vi.fn(), }, + moodEntryTagLink: { + findMany: vi.fn(), + }, auditLog: { create: vi.fn() }, + $transaction: vi.fn(), }, })); +vi.mock("@/lib/mood/tag-links", async (importOriginal) => ({ + ...(await importOriginal()), + replaceTagLinks: vi.fn(), + replaceRatedFactorLinks: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -42,12 +62,37 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { + RatedFactorOutOfRangeError, + replaceRatedFactorLinks, + replaceTagLinks, +} from "@/lib/mood/tag-links"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "user-1", username: "tester", role: "USER" as const }, }; +const EXISTING_ENTRY = { + id: "me1", + userId: "user-1", + date: "2026-06-01", + mood: "OKAY", + score: 3, + tags: null, + note: null, + noteEncrypted: null, + source: "MANUAL", + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + syncVersion: 7, +}; + +const UPDATED_ENTRY = { + ...EXISTING_ENTRY, + moodLoggedAt: new Date("2026-06-02T08:00:00.000Z"), + syncVersion: 8, +}; + function putReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/mood-entries/me1", { method: "PUT", @@ -62,16 +107,21 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback(txClient as never), + ); + txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never); + txClient.moodEntryTagLink.findMany.mockResolvedValue([]); + vi.mocked(replaceTagLinks).mockResolvedValue(undefined); + vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a // `deletedAt: null` guard (refuses to resurrect-edit a tombstone). - vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({ - id: "me1", - userId: "user-1", - } as never); - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "me1", - userId: "user-1", - } as never); + vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue( + EXISTING_ENTRY as never, + ); + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue( + EXISTING_ENTRY as never, + ); }); describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { @@ -139,3 +189,162 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("PUT /api/mood-entries/[id] — split tag replacement", () => { + it("preserves RATED links for a note/time/tagKeys-only edit", async () => { + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { rating: null, moodTag: { key: "happy", kind: "BINARY" } }, + { rating: 4, moodTag: { key: "factor_work", kind: "RATED" } }, + ]); + + const res = await PUT( + putReq({ + note: null, + moodLoggedAt: "2026-06-02T08:00:00.000Z", + tagKeys: ["happy"], + }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + expect(replaceTagLinks).toHaveBeenCalledWith( + "me1", + "user-1", + ["happy"], + txClient, + ); + expect(replaceRatedFactorLinks).not.toHaveBeenCalled(); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + }; + error: null; + }; + expect(body.data.tagKeys).toEqual(["happy"]); + expect(body.data.ratedFactors).toEqual([ + { key: "factor_work", rating: 4 }, + ]); + expect(body.error).toBeNull(); + }); + + it("preserves BINARY links and updates scores for a ratedFactors-only edit", async () => { + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { rating: null, moodTag: { key: "happy", kind: "BINARY" } }, + { rating: 5, moodTag: { key: "factor_work", kind: "RATED" } }, + ]); + + const res = await PUT( + putReq({ ratedFactors: [{ key: "factor_work", rating: 5 }] }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + expect(replaceTagLinks).not.toHaveBeenCalled(); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [{ key: "factor_work", rating: 5 }], + txClient, + ); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + }; + }; + expect(body.data.tagKeys).toEqual(["happy"]); + expect(body.data.ratedFactors).toEqual([ + { key: "factor_work", rating: 5 }, + ]); + }); + + it("returns 422 when a factor score is outside its catalog scale", async () => { + vi.mocked(replaceRatedFactorLinks).mockRejectedValueOnce( + new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2), + ); + + const res = await PUT( + putReq({ ratedFactors: [{ key: "factor_conflict", rating: 5 }] }), + ROUTE_CTX, + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + meta: { errorCode: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta.errorCode).toBe("mood.ratedFactor.out_of_range"); + }); + + it("rolls back the mood row, both link sets, and syncVersion when the second replacement fails", async () => { + let persisted = { + mood: "OKAY", + syncVersion: 7, + binary: ["happy"], + rated: [{ key: "factor_work", rating: 2 }], + }; + let draft = structuredClone(persisted); + const transactionClient = { + moodEntry: { + update: vi.fn(async ({ data }: { data: Record }) => { + if (typeof data.mood === "string") draft.mood = data.mood; + if (data.syncVersion) draft.syncVersion += 1; + return UPDATED_ENTRY; + }), + }, + moodEntryTagLink: { + findMany: vi.fn(), + }, + }; + vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { + draft.binary = ["calm"]; + }); + vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => { + draft.rated = [{ key: "factor_work", rating: 5 }]; + throw new Error("injected rated-link replacement failure"); + }); + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => { + draft = structuredClone(persisted); + const result = await callback(transactionClient as never); + persisted = draft; + return result; + }); + + const res = await PUT( + putReq({ + mood: "GUT", + tagKeys: ["calm"], + ratedFactors: [{ key: "factor_work", rating: 5 }], + }), + ROUTE_CTX, + ); + + expect(replaceTagLinks).toHaveBeenCalledTimes(1); + expect(replaceRatedFactorLinks).toHaveBeenCalledTimes(1); + expect(res.status).toBe(500); + expect(persisted).toEqual({ + mood: "OKAY", + syncVersion: 7, + binary: ["happy"], + rated: [{ key: "factor_work", rating: 2 }], + }); + }); + + it.each([ + { ratedFactors: null }, + { ratedFactors: [] }, + ])("clears RATED links for an explicit $ratedFactors", async (payload) => { + const res = await PUT(putReq(payload), ROUTE_CTX); + + expect(res.status).toBe(200); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [], + txClient, + ); + }); +}); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 95813f5db..f5d242fba 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -19,7 +19,11 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { replaceTagLinks } from "@/lib/mood/tag-links"; +import { + RatedFactorOutOfRangeError, + replaceRatedFactorLinks, + replaceTagLinks, +} from "@/lib/mood/tag-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -140,38 +144,75 @@ export const PUT = apiHandler( // monotonic value and paired clients reconcile higher-wins. updateData.syncVersion = { increment: 1 }; - // v1.8.5 — update the entry and replace its structured-tag links in - // one transaction. A tag-link failure must roll the entry update back - // too — otherwise a client retry on the 5xx double-applies the edit - // (and the `syncVersion` increment). The tx client is threaded through - // the helper so both writes commit (or abort) together. - const { entry, persistedTagKeys } = await prisma.$transaction( - async (tx) => { + // Update the row and replace each submitted half of the structured-link + // contract in one transaction. A failure in either replacement rolls + // back the mood edit and its syncVersion increment as one unit. + const persistUpdate = () => + prisma.$transaction(async (tx) => { const updated = await tx.moodEntry.update({ where: { id }, data: updateData, }); - // Full replacement of the structured-tag link set when `tagKeys` - // is present in the body. `null` clears every link; an omitted - // field leaves the links untouched. + // Omission preserves the corresponding link set. Explicit null/empty + // clears it, matching the validated update contract. if (data.tagKeys !== undefined) { await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx); } + if (data.ratedFactors !== undefined) { + await replaceRatedFactorLinks( + id, + user.id, + data.ratedFactors ?? [], + tx, + ); + } - // v1.8.5 — read the persisted link keys back so the update - // response mirrors the list GET shape exactly. + // Return the same split shape as list/create: binary keys are + // independent from rated-factor keys and their per-entry scores. const links = await tx.moodEntryTagLink.findMany({ where: { moodEntryId: id }, - select: { moodTag: { select: { key: true } } }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, }); return { entry: updated, - persistedTagKeys: links.map((link) => link.moodTag.key), + persistedTagKeys: links + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + persistedRatedFactors: links + .filter( + (link) => link.moodTag.kind === "RATED" && link.rating !== null, + ) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), }; - }, + }); + + const transactionOutcome = await persistUpdate().then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), ); + if (!transactionOutcome.ok) { + const { error } = transactionOutcome; + if (error instanceof RatedFactorOutOfRangeError) { + annotate({ + action: { name: "mood-entries.update.rated-factor-out-of-range" }, + meta: { scaleMin: error.scaleMin, scaleMax: error.scaleMax }, + }); + return apiError(error.message, 422, { + errorCode: "mood.ratedFactor.out_of_range", + }); + } + throw error; + } + const { entry, persistedTagKeys, persistedRatedFactors } = + transactionOutcome.result; await auditLog("moodEntry.update", { userId: user.id, @@ -219,6 +260,7 @@ export const PUT = apiHandler( // hydrating from the update response renders the tag set without a // refetch (shape-matches the list GET). tagKeys: persistedTagKeys, + ratedFactors: persistedRatedFactors, }); }, ); diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index d38567d46..1b3e85f2c 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -139,6 +139,20 @@ describe("MoodForm — rated factors (v1.12.0)", () => { }); }); +describe("MoodList — rated factor editing", () => { + it("hydrates, renders, and submits the entry's current ratedFactors", () => { + expect(listSrc).toContain("ratedFactors: RatedFactor[];"); + expect(listSrc).toContain( + "const [editRatedFactors, setEditRatedFactors] = useState<", + ); + expect(listSrc).toContain("setEditRatedFactors(entry.ratedFactors ?? [])"); + expect(listSrc).toMatch( + / { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 76f1f4686..2348772dd 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -81,7 +81,7 @@ import { selectedCountOnPage, } from "@/components/data-list"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { MoodTagPicker } from "./mood-tag-picker"; +import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; // Re-export the score map under the legacy local name to keep the @@ -97,6 +97,7 @@ interface MoodEntry { tags: string[]; // v1.8.5 — structured-tag keys + free-text note. tagKeys: string[]; + ratedFactors: RatedFactor[]; note: string | null; source: string; moodLoggedAt: string; @@ -174,6 +175,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }); const [editTagsInput, setEditTagsInput] = useState(""); const [editTagKeys, setEditTagKeys] = useState([]); + const [editRatedFactors, setEditRatedFactors] = useState([]); const [editNote, setEditNote] = useState(""); const [editMoodLoggedAt, setEditMoodLoggedAt] = useState(""); const [editError, setEditError] = useState(null); @@ -183,6 +185,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], ); } + + function rateEditFactor(key: string, rating: number | null) { + setEditRatedFactors((prev) => { + const rest = prev.filter((factor) => factor.key !== key); + return rating === null ? rest : [...rest, { key, rating }]; + }); + } const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); // v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot. const editFormId = useId(); @@ -357,6 +366,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood, tags, tagKeys, + ratedFactors, note, moodLoggedAt, }: { @@ -364,6 +374,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood: string; tags: string[] | null; tagKeys: string[]; + ratedFactors: RatedFactor[]; note: string | null; moodLoggedAt: string; }) => { @@ -371,6 +382,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood, tags, tagKeys, + ratedFactors, note, moodLoggedAt, }); @@ -393,6 +405,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { setEditMood(entry.mood); setEditTagsInput(entry.tags.join(", ")); setEditTagKeys(entry.tagKeys ?? []); + setEditRatedFactors(entry.ratedFactors ?? []); setEditNote(entry.note ?? ""); setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt)); setEditError(null); @@ -440,6 +453,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood: editMood, tags: tags.length > 0 ? tags : null, tagKeys: editTagKeys, + ratedFactors: editRatedFactors, note: trimmedNote.length > 0 ? trimmedNote : null, moodLoggedAt: measuredDate.toISOString(), }); @@ -909,6 +923,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index 1591a1495..e1abeb0ab 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -45,8 +45,12 @@ beforeEach(() => { }); /** Existing-link row as `replaceTagLinks` selects it. */ -function link(moodTagId: string, isActive: boolean) { - return { moodTagId, moodTag: { isActive } }; +function link( + moodTagId: string, + isActive: boolean, + kind: "BINARY" | "RATED" = "BINARY", +) { + return { moodTagId, moodTag: { isActive, kind } }; } describe("resolveRatedFactors", () => { @@ -196,6 +200,27 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { expect(linkDeleteMany).not.toHaveBeenCalled(); }); + it("preserves active RATED links while replacing binary links", async () => { + linkFindMany.mockResolvedValue([ + link("mt_happy", true), + link("mt_factor_work", true, "RATED"), + ]); + moodTagFindMany.mockResolvedValue([]); + + await replaceTagLinks("entry-1", "user-1", []); + + expect(linkDeleteMany).toHaveBeenCalledWith({ + where: { moodEntryId: "entry-1", moodTagId: { in: ["mt_happy"] } }, + }); + expect(linkDeleteMany).not.toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + moodTagId: { in: expect.arrayContaining(["mt_factor_work"]) }, + }), + }), + ); + }); + it("createTagLinks (the bulk-ingest path) is additive — it never deletes a link", async () => { // The bulk route re-posts an entry via `createTagLinks`; an archived // key cannot resolve (isActive pinned) but no existing link is ever @@ -213,7 +238,7 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { ); }); - it("replaceRatedFactorLinks scopes its delete to active tags", async () => { + it("replaceRatedFactorLinks deletes every active RATED row, including a null-rated legacy link", async () => { moodTagFindMany.mockResolvedValue([]); await replaceRatedFactorLinks("entry-1", "user-1", []); @@ -221,11 +246,33 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { expect(linkDeleteMany).toHaveBeenCalledWith({ where: { moodEntryId: "entry-1", - rating: { not: null }, - moodTag: { isActive: true }, + moodTag: { isActive: true, kind: "RATED" }, }, }); }); + it("replaces RATED scores without deleting binary links", async () => { + moodTagFindMany.mockResolvedValue([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]); + + await replaceRatedFactorLinks("entry-1", "user-1", [ + { key: "factor_work", rating: 4 }, + ]); + + expect(linkDeleteMany).toHaveBeenCalledWith({ + where: { + moodEntryId: "entry-1", + moodTag: { isActive: true, kind: "RATED" }, + }, + }); + expect(linkCreateMany).toHaveBeenCalledWith({ + data: [ + { moodEntryId: "entry-1", moodTagId: "mt_factor_work", rating: 4 }, + ], + skipDuplicates: true, + }); + }); + }); describe("entry-ownership guard", () => { diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 3b6f80622..5ad4c07ed 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -150,11 +150,16 @@ export async function resolveRatedFactors( * the resolution is catalogue-only (`user_id IS NULL`), never matching any * custom row — the safe default, since `{ userId: undefined }` in a Prisma * filter would otherwise match every row. + * + * `kind` narrows resolution when a caller owns only one half of the split + * link contract. Edit-time `tagKeys` are binary-only; rated factors use + * `resolveRatedFactors` and carry their score separately. */ export async function resolveTagKeysToIds( keys: string[], db: TagLinkDb = prisma, ownerUserId?: string, + kind?: "BINARY" | "RATED", ): Promise { const unique = Array.from(new Set(keys)); if (unique.length === 0) return []; @@ -162,7 +167,12 @@ export async function resolveTagKeysToIds( ? { OR: [{ userId: null }, { userId: ownerUserId }] } : { userId: null }; const rows = await db.moodTag.findMany({ - where: { key: { in: unique }, isActive: true, ...ownerClause }, + where: { + key: { in: unique }, + isActive: true, + ...(kind ? { kind } : {}), + ...ownerClause, + }, select: { id: true }, }); return rows.map((row) => row.id); @@ -221,12 +231,12 @@ export async function createTagLinks( } /** - * Replace the structured-tag link set for an entry. `keys` is the desired - * set; the helper deletes links no longer present and inserts the new - * ones, leaving unchanged links in place. Passing an empty array clears - * every ACTIVE link. Asserts the entry belongs to `userId` before - * touching links — a defensive guard against editing links on an entry - * the acting session does not own. + * Replace the active BINARY structured-tag link set for an entry. `keys` is + * the desired binary set; RATED links are owned by + * `replaceRatedFactorLinks` and remain untouched. Passing an empty array + * clears every active binary link. Asserts the entry belongs to `userId` + * before touching links — a defensive guard against editing links on an + * entry the acting session does not own. * * The submitted keys govern ACTIVE tag links only. `resolveTagKeysToIds` * pins `isActive: true`, so an archived tag's key can never resolve back @@ -246,15 +256,25 @@ export async function replaceTagLinks( db: TagLinkDb = prisma, ): Promise { await assertEntryOwnership(moodEntryId, userId, db); - const desiredIds = new Set(await resolveTagKeysToIds(keys, db, userId)); + const desiredIds = new Set( + await resolveTagKeysToIds(keys, db, userId, "BINARY"), + ); const existing = await db.moodEntryTagLink.findMany({ where: { moodEntryId }, - select: { moodTagId: true, moodTag: { select: { isActive: true } } }, + select: { + moodTagId: true, + moodTag: { select: { isActive: true, kind: true } }, + }, }); const existingIds = new Set(existing.map((row) => row.moodTagId)); const toDelete = existing - .filter((row) => row.moodTag.isActive && !desiredIds.has(row.moodTagId)) + .filter( + (row) => + row.moodTag.isActive && + row.moodTag.kind === "BINARY" && + !desiredIds.has(row.moodTagId), + ) .map((row) => row.moodTagId); const toCreate = [...desiredIds].filter((id) => !existingIds.has(id)); @@ -273,16 +293,13 @@ export async function replaceTagLinks( /** * v1.12.0 — replace the full rated-factor link set for an entry. A - * changed rating on the same factor is a real change (not a no-op), so - * the simplest correct shape is "delete every RATED link for the entry, - * re-insert the desired set". Binary links are left untouched. Passing an - * empty array clears every rated link. Throws `RatedFactorOutOfRangeError` - * (→ route 422) before any write when a rating is out of scale. - * - * Not wired into a route yet (the POST + bulk ingestion contract is the - * v1.12.0 scope); exported for the PATCH edit path a later wave adds. - * Asserts the entry belongs to `userId` before any write — a defensive - * guard for that future edit route against touching another user's entry. + * changed rating on the same factor is a real change, so the helper deletes + * every active RATED link and re-inserts the desired set. This also upgrades + * legacy/null-rated RATED rows without colliding with their join key. Binary + * links are left untouched. Passing an empty array clears every rated link. + * Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a + * rating is out of scale. Asserts the entry belongs to `userId` before any + * write. */ export async function replaceRatedFactorLinks( moodEntryId: string, @@ -295,13 +312,15 @@ export async function replaceRatedFactorLinks( // aborts the replace cleanly. const resolved = await resolveRatedFactors(factors, db); - // Drop every existing RATED link for the entry (a `rating IS NOT NULL` - // row is, by construction, a rated-factor link). Same archive contract - // as `replaceTagLinks`: a link whose tag has been archived since the - // entry was logged is history, not editable state — it survives the - // replace untouched. + // Drop every active RATED link, including a legacy row whose rating is + // null. Same archive contract as `replaceTagLinks`: a link whose tag has + // been archived since the entry was logged is history, not editable state + // and survives the replacement. await db.moodEntryTagLink.deleteMany({ - where: { moodEntryId, rating: { not: null }, moodTag: { isActive: true } }, + where: { + moodEntryId, + moodTag: { isActive: true, kind: "RATED" }, + }, }); if (resolved.length > 0) { From f648c1748a705b127d2a0535f617fd934b4b06a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:12:30 +0200 Subject: [PATCH 04/35] fix(import): classify operational failures --- src/app/api/import/__tests__/route.test.ts | 277 ++++++++++++++++++++- src/app/api/import/route.ts | 91 ++++--- 2 files changed, 333 insertions(+), 35 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index c31297c3b..5e0647b8f 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -33,6 +33,12 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +const { encryptNoteMock } = vi.hoisted(() => ({ encryptNoteMock: vi.fn() })); + +vi.mock("@/lib/crypto/note-cipher", () => ({ + encryptNote: encryptNoteMock, +})); + vi.mock("@/lib/arrivals/measurement-emit", () => ({ emitInsertedMeasurementArrivals: vi.fn(), })); @@ -65,12 +71,17 @@ import { prisma } from "@/lib/db"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; +import { Prisma } from "@/generated/prisma/client"; +import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); + encryptNoteMock.mockImplementation((note: string | null) => + note === null ? null : `encrypted:${note}`, + ); }); interface ApiErrorEnvelope { @@ -234,7 +245,10 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => { resetAt: new Date(Date.now() + 1000), } as never); vi.mocked(prisma.measurement.create).mockRejectedValue( - new Error("P2002 duplicate"), + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + }), ); const response = await POST( @@ -390,10 +404,10 @@ describe("POST /api/import — arrival wiring", () => { }); it("does not emit or refresh an existing external-id update", async () => { - vi.mocked(prisma.measurement.findUnique).mockResolvedValue({ - id: "existing", - } as never); - vi.mocked(prisma.measurement.upsert).mockResolvedValue({ + vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.measurement.update).mockResolvedValue({ id: "existing", type: "SLEEP_DURATION", measuredAt: new Date("2026-05-14T06:00:00.000Z"), @@ -573,3 +587,256 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => { expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled(); }); }); + +describe("POST /api/import — write failure classification", () => { + beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + }); + + function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); + } + + function measurement(value = 80): Record { + return { + type: "WEIGHT", + value, + unit: "kg", + measuredAt: "2026-05-01T08:00:00.000Z", + }; + } + + function moodEntry(): Record { + return { + date: "2026-05-01", + mood: "GUT", + score: 4, + loggedAt: "2026-05-01T12:00:00.000Z", + }; + } + + function expectRetryableFailure( + body: unknown, + stats: { measurements: number; moodEntries: number; skipped: number }, + ): void { + expect(body).toEqual({ + data: null, + error: "Import write failed", + meta: { + errorCode: "import.write_failed", + retryable: true, + stats, + }, + }); + } + + it("counts an authentic P2002 measurement error as a duplicate", async () => { + vi.mocked(prisma.measurement.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { + target: ["userId", "type", "measuredAt", "source", "sleepStage"], + }, + }), + ); + + const response = await POST(request({ measurements: [measurement()] })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("counts an authentic P2002 mood error as a duplicate", async () => { + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "test", + meta: { + target: ["userId", "date", "moodLoggedAt"], + }, + }), + ); + + const response = await POST(request({ moodEntries: [moodEntry()] })); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("skips an external-id insert race lost on timestamp", async () => { + vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValueOnce( + [] as never, + ); + vi.mocked(prisma.measurement.update).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Record to update not found", { + code: "P2025", + clientVersion: "test", + }), + ); + + const response = await POST( + request({ + measurements: [{ ...measurement(), externalId: "new-source-id" }], + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + }); + + it("returns retryable counts on database timeout", async () => { + vi.mocked(prisma.measurement.create) + .mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never) + .mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Operations timed out", + "test", + "P1008", + ), + ); + + const response = await POST( + request({ measurements: [measurement(80), measurement(81)] }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 1, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns retryable counts on connection loss", async () => { + vi.mocked(prisma.measurement.create).mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never); + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + + const response = await POST( + request({ + measurements: [measurement()], + moodEntries: [moodEntry()], + }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 1, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns a retryable failure when note encryption fails", async () => { + encryptNoteMock.mockImplementationOnce(() => { + throw new Error("Encryption key unavailable"); + }); + + const response = await POST( + request({ + measurements: [{ ...measurement(), notes: "private note" }], + }), + ); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + }); + + it("returns a retryable failure for a generic mood write error", async () => { + vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( + new Error("write failed"), + ); + + const response = await POST(request({ moodEntries: [moodEntry()] })); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + }); + + it("audits rows committed before a later write failure", async () => { + vi.mocked(prisma.measurement.create) + .mockResolvedValueOnce({ + id: "measurement-1", + type: "WEIGHT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + } as never) + .mockRejectedValueOnce(new Error("write failed")); + + const response = await POST( + request({ measurements: [measurement(80), measurement(81)] }), + ); + + expect(response.status).toBe(503); + expect(auditLog).toHaveBeenCalledWith("import.upload", { + userId: "u-1", + ipAddress: null, + details: { measurements: 1, moodEntries: 0, skipped: 0 }, + }); + }); + + it("keeps the classified failure when its audit also fails", async () => { + vi.mocked(prisma.measurement.create).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + vi.mocked(auditLog).mockRejectedValueOnce( + new Prisma.PrismaClientInitializationError( + "Can't reach database server", + "test", + "P1001", + ), + ); + + const response = await POST(request({ measurements: [measurement()] })); + + expect(response.status).toBe(503); + expectRetryableFailure(await response.json(), { + measurements: 0, + moodEntries: 0, + skipped: 0, + }); + expect(auditLog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index bb8a0e863..6e633e756 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -27,6 +27,7 @@ import { import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; +import { isP2002, isP2025 } from "@/lib/prisma-errors"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -113,6 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const userId = user.id; const data = parsed.data; const stats = { measurements: 0, moodEntries: 0, skipped: 0 }; + let writeFailed = false; // Import measurements // v1.4.39.1 — track each (type, measuredAt) we wrote so we can re- @@ -164,24 +166,32 @@ export const POST = apiHandler(async (request: NextRequest) => { // A pre-existing row or a raced insert owns the key. Preserve // idempotent re-import semantics without classifying this update // as an arrival. - await prisma.measurement.update({ - where: { - userId_type_source_externalId: { - userId, - type: m.type, - source: "IMPORT", - externalId: m.externalId, + try { + await prisma.measurement.update({ + where: { + userId_type_source_externalId: { + userId, + type: m.type, + source: "IMPORT", + externalId: m.externalId, + }, }, - }, - data: { - value: m.value, - unit: m.unit, - measuredAt, - notes: null, - notesEncrypted, - glucoseContext: m.glucoseContext ?? null, - }, - }); + data: { + value: m.value, + unit: m.unit, + measuredAt, + notes: null, + notesEncrypted, + glucoseContext: m.glucoseContext ?? null, + }, + }); + } catch (err) { + if (isP2025(err)) { + stats.skipped++; + continue; + } + throw err; + } } } else { const inserted = await prisma.measurement.create({ @@ -205,9 +215,13 @@ export const POST = apiHandler(async (request: NextRequest) => { measuredAt, }); stats.measurements++; - } catch { - // Unique constraint violation — skip duplicate - stats.skipped++; + } catch (err) { + if (isP2002(err)) { + stats.skipped++; + continue; + } + writeFailed = true; + break; } } } @@ -225,7 +239,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ).catch(() => {}); // Import mood entries - if (data.moodEntries?.length) { + if (!writeFailed && data.moodEntries?.length) { for (const e of data.moodEntries) { try { const loggedAt = e.loggedAt ?? new Date(); @@ -274,9 +288,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } stats.moodEntries++; - } catch { - // Unique constraint violation — skip duplicate - stats.skipped++; + } catch (err) { + if (isP2002(err)) { + stats.skipped++; + continue; + } + writeFailed = true; + break; } } } @@ -322,13 +340,26 @@ export const POST = apiHandler(async (request: NextRequest) => { } } - await auditLog("import.upload", { - userId, - ipAddress: getClientIp(request), - details: stats, - }); - annotate({ meta: { import_stats: stats } }); + try { + await auditLog("import.upload", { + userId, + ipAddress: getClientIp(request), + details: stats, + }); + } catch (err) { + if (!writeFailed) throw err; + annotate({ meta: { import_audit_failed: true } }); + } + + if (writeFailed) { + return apiError("Import write failed", 503, { + errorCode: "import.write_failed", + retryable: true, + stats, + }); + } + return apiSuccess(stats); }); From a6d4bb414c8f132ef9c76dd9ea784289236ded5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:20:35 +0200 Subject: [PATCH 05/35] fix(moodlog): acknowledge durable source writes --- .../moodlog/webhook/__tests__/route.test.ts | 17 ++ .../api/integrations/moodlog/webhook/route.ts | 76 ++------- src/lib/moodlog/__tests__/persistence.test.ts | 64 ++++++++ src/lib/moodlog/__tests__/sync.test.ts | 98 ++++++++++++ src/lib/moodlog/persistence.ts | 132 +++++++++++++++ src/lib/moodlog/sync.ts | 74 +++------ .../partial-unique-live-rows.test.ts | 151 ++++++++++++++++++ 7 files changed, 494 insertions(+), 118 deletions(-) create mode 100644 src/lib/moodlog/__tests__/persistence.test.ts create mode 100644 src/lib/moodlog/__tests__/sync.test.ts create mode 100644 src/lib/moodlog/persistence.ts diff --git a/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts b/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts index a54435f6d..1cf3a6519 100644 --- a/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts +++ b/src/app/api/integrations/moodlog/webhook/__tests__/route.test.ts @@ -192,6 +192,23 @@ describe("POST /api/integrations/moodlog/webhook", () => { expect(wheres[0]).toEqual(wheres[1]); }); + it("returns retryable failure when the source record cannot be persisted", async () => { + vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce( + new Error("database unavailable"), + ); + + const res = await POST( + jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }), + ); + + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ + data: null, + error: expect.stringMatching(/persist/i), + }); + expect(prisma.moodEntry.upsert).toHaveBeenCalledOnce(); + }); + it("mood.deleted removes any matching entry rather than upserting", async () => { const deletedPayload = { ...VALID_PAYLOAD, diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index 64e699731..7d5445811 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -6,6 +6,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { getClientIp } from "@/lib/api-response"; import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog"; import { readMoodLogSecret } from "@/lib/moodlog-secret"; +import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -97,7 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const { event, entry } = parsed.data; annotate({ meta: { webhook_event: event } }); - // 5. Process event (non-blocking — fire and forget with error logging) + // 5. Process event and acknowledge only after the source write commits. const moodLoggedAt = new Date(entry.time); // v1.12.1 — when the upstream supplies a stable entry id, dedup on // `(userId, source, externalId)` so a re-emit with a re-rounded / @@ -116,67 +117,14 @@ export const POST = apiHandler(async (request: NextRequest) => { : { date: entry.date, moodLoggedAt }), }, }); - } else if (externalId) { - // mood.created or mood.updated, keyed on the stable external id. - await prisma.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: user.id, - source: "MOODLOG", - externalId, - }, - }, - update: { - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - date: entry.date, - moodLoggedAt, - // No-op on a live row, a deliberate RESURRECTION on a tombstoned - // one — moodLog is the source of truth for the rows it minted; a - // created/updated event means the entry exists upstream (deletion - // arrives as its own `mood.deleted` event). - deletedAt: null, - }, - create: { - userId: user.id, - date: entry.date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - externalId, - moodLoggedAt, - }, - }); } else { - // mood.created or mood.updated, legacy wall-clock key. - await prisma.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId: user.id, - date: entry.date, - moodLoggedAt, - }, - }, - update: { - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - // Same resurrection rule as the externalId-keyed upsert above. - deletedAt: null, - }, - create: { - userId: user.id, - date: entry.date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - moodLoggedAt, - }, + await persistMoodLogSourceEntry(user.id, { + externalId, + date: entry.date, + moodLoggedAt, + mood: entry.mood, + score: entry.score, + tags: entry.tags, }); } @@ -193,9 +141,11 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } } catch (err) { - getEvent()?.addWarning("DB operation failed: " + err); + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning(`MoodLog source persistence failed: ${message}`); + return apiError("Failed to persist moodLog event", 503); } - // 6. Return 200 immediately + // 6. The source record is durable; the provider can stop retrying. return new Response(null, { status: 200 }); }); diff --git a/src/lib/moodlog/__tests__/persistence.test.ts b/src/lib/moodlog/__tests__/persistence.test.ts new file mode 100644 index 000000000..f470ab1ff --- /dev/null +++ b/src/lib/moodlog/__tests__/persistence.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + moodEntry: { + upsert: vi.fn(), + updateMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { persistMoodLogSourceEntry } from "../persistence"; + +describe("persistMoodLogSourceEntry concurrent legacy adoption", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("applies the losing replay payload to the row adopted by the winner", async () => { + const moodLoggedAt = new Date("2026-07-19T08:30:00.000Z"); + vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce( + Object.assign(new Error("natural-key collision"), { code: "P2002" }), + ); + vi.mocked(prisma.moodEntry.updateMany).mockResolvedValueOnce({ + count: 0, + }); + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValueOnce({ + date: "2026-07-19", + moodLoggedAt, + } as never); + vi.mocked(prisma.moodEntry.update).mockResolvedValueOnce({} as never); + + await persistMoodLogSourceEntry("user-1", { + externalId: "provider-event-1", + date: "2026-07-19", + moodLoggedAt, + mood: "SUPER_GUT", + score: 5, + tags: ["updated"], + }); + + expect(prisma.moodEntry.update).toHaveBeenCalledWith({ + where: { + userId_source_externalId: { + userId: "user-1", + source: "MOODLOG", + externalId: "provider-event-1", + }, + }, + data: { + mood: "SUPER_GUT", + score: 5, + tags: JSON.stringify(["updated"]), + source: "MOODLOG", + deletedAt: null, + date: "2026-07-19", + moodLoggedAt, + }, + }); + }); +}); diff --git a/src/lib/moodlog/__tests__/sync.test.ts b/src/lib/moodlog/__tests__/sync.test.ts new file mode 100644 index 000000000..ad59e1393 --- /dev/null +++ b/src/lib/moodlog/__tests__/sync.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + moodEntry: { + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((value: string) => value), +})); + +vi.mock("@/lib/validations/notifications", () => ({ + isPublicUrl: vi.fn(() => true), +})); + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: vi.fn(), +})); + +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(), + recordSyncFailure: vi.fn(), + recordSyncSuccess: vi.fn(), +})); + +vi.mock("@/lib/rollups/mood-rollups", () => ({ + recomputeUserMoodRollups: vi.fn(), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: vi.fn(() => ({ + addExternalCall: vi.fn(), + addWarning: vi.fn(), + })), +})); + +import { prisma } from "@/lib/db"; +import { + isReauthRequired, + recordSyncFailure, + recordSyncSuccess, +} from "@/lib/integrations/status"; +import { safeFetch } from "@/lib/safe-fetch"; +import { syncMoodLogEntries } from "../sync"; + +const SOURCE_ENTRY = { + id: "provider-event-1", + date: "2026-07-19", + time: "2026-07-19T08:30:00.000Z", + mood: "GUT", + score: 4, + tags: ["walk"], +}; + +describe("syncMoodLogEntries persistence completion", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + moodLogEnabled: true, + moodLogUrlEncrypted: "https://moodlog.example.test", + moodLogApiKeyEncrypted: "api-key", + moodLogLastSyncedAt: new Date("2026-07-18T08:30:00.000Z"), + } as never); + vi.mocked(isReauthRequired).mockResolvedValue(false); + vi.mocked(safeFetch).mockResolvedValue( + new Response(JSON.stringify({ version: "1", entries: [SOURCE_ENTRY] }), { + status: 200, + }), + ); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + }); + + it("holds the watermark and success marker when a source upsert fails", async () => { + const persistenceError = new Error("database unavailable"); + vi.mocked(prisma.moodEntry.upsert).mockRejectedValueOnce(persistenceError); + + await expect(syncMoodLogEntries("user-1")).rejects.toThrow( + "database unavailable", + ); + + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + expect(recordSyncFailure).toHaveBeenCalledWith({ + userId: "user-1", + integration: "moodlog", + kind: "transient", + message: "database unavailable", + errorCode: "persistence_failed", + }); + }); +}); diff --git a/src/lib/moodlog/persistence.ts b/src/lib/moodlog/persistence.ts new file mode 100644 index 000000000..b9ac8f6b2 --- /dev/null +++ b/src/lib/moodlog/persistence.ts @@ -0,0 +1,132 @@ +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; + +export interface MoodLogSourceEntry { + externalId: string | null; + date: string; + moodLoggedAt: Date; + mood: string; + score: number; + tags?: readonly string[]; +} + +/** + * Persist one moodLog-owned source row using the provider id when available + * and the legacy wall-clock key otherwise. + * + * A provider may add an id after an older delivery already created the row by + * wall-clock key. In that case the external-id upsert collides with the legacy + * unique constraint; adopt that durable row and attach the provider id rather + * than creating a duplicate or retrying forever. + */ +export async function persistMoodLogSourceEntry( + userId: string, + entry: MoodLogSourceEntry, +): Promise { + const tags = entry.tags ? JSON.stringify(entry.tags) : null; + const update = { + mood: entry.mood, + score: entry.score, + tags, + source: "MOODLOG", + deletedAt: null, + } as const; + + if (!entry.externalId) { + await prisma.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + }, + }, + update, + create: { + userId, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + ...update, + }, + }); + return; + } + + let collisionError: unknown; + try { + await prisma.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId, + source: "MOODLOG", + externalId: entry.externalId, + }, + }, + update: { + ...update, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + }, + create: { + userId, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + externalId: entry.externalId, + ...update, + }, + }); + return; + } catch (err) { + if (!isP2002(err)) throw err; + collisionError = err; + } + + const adopted = await prisma.moodEntry.updateMany({ + where: { + userId, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + source: "MOODLOG", + externalId: null, + }, + data: { + ...update, + externalId: entry.externalId, + }, + }); + if (adopted.count === 1) return; + + // A concurrent replay may have adopted the same legacy row first. + const winner = await prisma.moodEntry.findUnique({ + where: { + userId_source_externalId: { + userId, + source: "MOODLOG", + externalId: entry.externalId, + }, + }, + select: { date: true, moodLoggedAt: true }, + }); + if ( + winner?.date === entry.date && + winner.moodLoggedAt.getTime() === entry.moodLoggedAt.getTime() + ) { + await prisma.moodEntry.update({ + where: { + userId_source_externalId: { + userId, + source: "MOODLOG", + externalId: entry.externalId, + }, + }, + data: { + ...update, + date: entry.date, + moodLoggedAt: entry.moodLoggedAt, + }, + }); + return; + } + + throw collisionError; +} diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts index 6d2d85a57..6de5ab246 100644 --- a/src/lib/moodlog/sync.ts +++ b/src/lib/moodlog/sync.ts @@ -8,6 +8,7 @@ import { recordSyncFailure, recordSyncSuccess, } from "@/lib/integrations/status"; +import { persistMoodLogSourceEntry } from "@/lib/moodlog/persistence"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; /** @@ -235,63 +236,26 @@ export async function syncMoodLogEntries( ? entry.id.slice(0, 120) : null; - if (externalId) { - await prisma.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId, - source: "MOODLOG", - externalId, - }, - }, - update: { - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - date, - moodLoggedAt, - }, - create: { - userId, - date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - externalId, - moodLoggedAt, - }, - }); - } else { - await prisma.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId, - date, - moodLoggedAt, - }, - }, - update: { - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - }, - create: { - userId, - date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ? JSON.stringify(entry.tags) : null, - source: "MOODLOG", - moodLoggedAt, - }, - }); - } + await persistMoodLogSourceEntry(userId, { + externalId, + date, + moodLoggedAt, + mood: entry.mood, + score: entry.score, + tags: entry.tags, + }); imported++; } catch (err) { - getEvent()?.addWarning(`Failed to upsert entry: ${err}`); + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning(`Failed to upsert moodLog entry: ${message}`); + await recordSyncFailure({ + userId, + integration: "moodlog", + kind: "transient", + message, + errorCode: "persistence_failed", + }); + throw err; } } diff --git a/tests/integration/partial-unique-live-rows.test.ts b/tests/integration/partial-unique-live-rows.test.ts index b8f4524d5..145c95052 100644 --- a/tests/integration/partial-unique-live-rows.test.ts +++ b/tests/integration/partial-unique-live-rows.test.ts @@ -199,6 +199,157 @@ describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () expect(rows[0].score).toBe(5); }); + it("acknowledges an exact provider replay with one stable durable row", async () => { + const prisma = getPrismaClient(); + const secret = "mood-secret-replay"; + const user = await prisma.user.create({ + data: { + username: "mood-provider-replay", + email: "mood-provider-replay@example.test", + role: "USER", + moodLogEnabled: true, + moodLogWebhookSecret: secret, + }, + }); + const { POST } = + await import("@/app/api/integrations/moodlog/webhook/route"); + const payload = { + event: "mood.created", + timestamp: "2026-06-04T11:00:00.000Z", + entry: { + id: "provider-event-replay-1", + date: "2026-06-04", + time: "2026-06-04T11:00:00.000Z", + mood: "GUT", + score: 4, + }, + }; + + const first = await POST(webhookRequest(secret, payload)); + expect(first.status).toBe(200); + const firstRow = await prisma.moodEntry.findUniqueOrThrow({ + where: { + userId_source_externalId: { + userId: user.id, + source: "MOODLOG", + externalId: "provider-event-replay-1", + }, + }, + }); + + const replay = await POST(webhookRequest(secret, payload)); + expect(replay.status).toBe(200); + const rows = await prisma.moodEntry.findMany({ + where: { userId: user.id, source: "MOODLOG" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(firstRow.id); + }); + + it("adopts a legacy natural-key row when a replay gains a provider id", async () => { + const prisma = getPrismaClient(); + const secret = "mood-secret-key-upgrade"; + const user = await prisma.user.create({ + data: { + username: "mood-key-upgrade", + email: "mood-key-upgrade@example.test", + role: "USER", + moodLogEnabled: true, + moodLogWebhookSecret: secret, + }, + }); + const { POST } = + await import("@/app/api/integrations/moodlog/webhook/route"); + const entry = { + date: "2026-06-04", + time: "2026-06-04T12:00:00.000Z", + mood: "GUT", + score: 4, + }; + + const legacy = await POST( + webhookRequest(secret, { + event: "mood.created", + timestamp: "2026-06-04T12:00:00.000Z", + entry, + }), + ); + expect(legacy.status).toBe(200); + const legacyRow = await prisma.moodEntry.findFirstOrThrow({ + where: { userId: user.id, source: "MOODLOG" }, + }); + expect(legacyRow.externalId).toBeNull(); + + const upgraded = await POST( + webhookRequest(secret, { + event: "mood.updated", + timestamp: "2026-06-04T12:05:00.000Z", + entry: { ...entry, id: "provider-key-upgrade-1" }, + }), + ); + expect(upgraded.status).toBe(200); + + const rows = await prisma.moodEntry.findMany({ + where: { userId: user.id, source: "MOODLOG" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(legacyRow.id); + expect(rows[0].externalId).toBe("provider-key-upgrade-1"); + }); + + it("does not adopt an unrelated row that occupies the natural key", async () => { + const prisma = getPrismaClient(); + const secret = "mood-secret-unrelated-collision"; + const user = await prisma.user.create({ + data: { + username: "mood-unrelated-collision", + email: "mood-unrelated-collision@example.test", + role: "USER", + moodLogEnabled: true, + moodLogWebhookSecret: secret, + }, + }); + const moodLoggedAt = new Date("2026-06-04T13:00:00.000Z"); + const manual = await prisma.moodEntry.create({ + data: { + userId: user.id, + date: "2026-06-04", + moodLoggedAt, + mood: "OKAY", + score: 3, + source: "MANUAL", + }, + }); + const { POST } = + await import("@/app/api/integrations/moodlog/webhook/route"); + + const response = await POST( + webhookRequest(secret, { + event: "mood.created", + timestamp: "2026-06-04T13:00:00.000Z", + entry: { + id: "provider-unrelated-collision-1", + date: "2026-06-04", + time: moodLoggedAt.toISOString(), + mood: "GUT", + score: 4, + }, + }), + ); + expect(response.status).toBe(503); + + const row = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: manual.id }, + }); + expect(row).toMatchObject({ + source: "MANUAL", + externalId: null, + mood: "OKAY", + score: 3, + }); + expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(1); + }); + it("entries without an id still dedup on the legacy wall-clock key", async () => { const prisma = getPrismaClient(); const secret = "mood-secret-legacy"; From 452d1a2ae358484180a10ebf9eb453fd5254b0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:23:57 +0200 Subject: [PATCH 06/35] fix(backups): restore canonical payloads completely --- .../api/admin/backups/[id]/restore/route.ts | 481 +++++++++++-- src/lib/export/records-backup.ts | 238 +++++-- .../__tests__/backup-handlers.test.ts | 154 ++++ src/lib/jobs/reminder/backup-handlers.ts | 30 +- src/lib/validations/backup.ts | 80 ++- .../admin-backups-canonical-roundtrip.test.ts | 664 ++++++++++++++++++ 6 files changed, 1500 insertions(+), 147 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/backup-handlers.test.ts create mode 100644 tests/integration/admin-backups-canonical-roundtrip.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 9d553f2cb..68772b223 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -1,35 +1,18 @@ /** - * POST /api/admin/backups/[id]/restore — admin-only restore from backup. + * POST /api/admin/backups/[id]/restore — admin-only disaster recovery. * - * The riskiest endpoint in the backup family. Mirroring the v1.4.14 wipe - * scope (`DELETE /api/admin/data`), the restore replaces the **target - * user's** personal data with the contents of the snapshot: + * After owner and schema validation, one transaction replaces every + * serialized owner-scoped class: measurements, medication history, mood and + * rated factors, cycle data, labs/biomarkers, illness history, allergies, + * family history, workout summaries, and inbound documents. Document content + * and summary ciphertext are decoded from base64 and persisted verbatim. * - * 1. Decrypt + parse the `DataBackup.data` blob. - * 2. In a single Prisma transaction: - * a. Delete the user's measurements, medications (cascades to - * schedules + intake events + telegram messages + - * reminder-phase configs), mood entries, notification channels, - * push subscriptions, telegram scheduled deletions. - * b. Re-create measurements, medications + schedules, intake - * events, mood entries from the payload. - * 3. Outside the transaction, write `admin.backups.restore` to the - * AuditLog so the trail outlives the operation. Audit rows are - * intentionally NOT touched by the restore (same contract as the - * wipe). - * - * Triple-confirm: the request body must carry `confirm: "RESTORE"`. - * The client also gates the form on a typed-string match so an - * accidental click cannot fire this. - * - * Idempotency: wrapped in `withIdempotency()` so retries with the same - * `Idempotency-Key` replay the original outcome instead of double- - * restoring (unlikely in practice — the second run would be a no-op - * because the user data already matches the backup — but this still - * prevents two parallel admin clicks from racing through). - * - * Phase B1 / criterion 3 of the v1.4.15 backup-completeness work. + * Metadata-only portable document exports are rejected before mutation; the + * importer never fabricates content. Audit rows remain outside the wipe, and + * cache invalidation runs only after the complete restore transaction. */ +import { Buffer } from "node:buffer"; + import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; @@ -37,6 +20,9 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { decrypt } from "@/lib/crypto"; import { encryptNote } from "@/lib/crypto/note-cipher"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; +import { encryptNoteToBytes } from "@/lib/labs/store"; import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { @@ -57,12 +43,6 @@ export const dynamic = "force-dynamic"; interface RestoreResponse { restored: true; - // v1.28 — `summary` reports every domain PRESENT in the uploaded file - // (informational — it now includes labs/illness/allergies/family-history/ - // workouts/documents counts too). `cleared` below is the narrower, - // authoritative list of what this restore actually wiped + recreated — - // those newer domains are export-only for now and are NOT touched by a - // restore, so they are deliberately absent from `cleared`. summary: BackupSummary; cleared: { measurements: number; @@ -72,13 +52,26 @@ interface RestoreResponse { notificationChannels: number; pushSubscriptions: number; telegramScheduledDeletions: number; - // v1.15.0 — cycle tables wiped before recreate. cycles: number; cycleDayLogs: number; cycleProfile: number; + labResults: number; + biomarkers: number; + illnessEpisodes: number; + allergies: number; + familyHistory: number; + workouts: number; + documents: number; }; } +function decodeEncryptedBytes(encoded: string): Uint8Array { + const decoded = Buffer.from(encoded, "base64"); + const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength)); + bytes.set(decoded); + return bytes; +} + /** * Maps a measurement-type string from the backup back into the * Prisma enum literal. Backups are written from `enum MeasurementType` @@ -307,14 +300,48 @@ const handler = apiHandler( } } + // Portable exports intentionally omit document ciphertext. They remain + // valid upload/download artifacts, but cannot be used to manufacture an + // InboundDocument row. A DR restore must fail before any delete rather + // than inventing empty/plaintext content. + const incompleteDocument = payload.documents.find( + (document) => + document.contentEncrypted === undefined || + document.contentSha256 === undefined || + document.contentCodec === undefined || + document.providerType === undefined || + document.reportDate === undefined || + document.documentDate === undefined || + document.errorReason === undefined || + document.summaryEncrypted === undefined || + document.summaryGeneratedAt === undefined || + document.summaryState === undefined || + document.createdAt === undefined || + document.updatedAt === undefined, + ); + if (incompleteDocument) { + await auditLog("admin.backups.restore.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId, + reason: "document_ciphertext_missing", + documentId: incompleteDocument.id, + }, + }); + return apiError( + `Document '${incompleteDocument.id}' is metadata-only and cannot be restored`, + 422, + ); + } + let cleared: RestoreResponse["cleared"]; try { cleared = await prisma.$transaction(async (tx) => { - // ── delete-then-recreate, mirroring the wipe scope plus mood ── - // Order matters: child rows first (intake events / schedules - // cascade via FK, but we delete them explicitly to capture the - // count for the audit trail). Telegram-reminder messages cascade - // off `medication`, so the medication delete sweeps them. + // Delete every serialized owner-scoped partition before rebuilding it + // in the same transaction. Child rows either go first for counts or + // cascade from their serialized parent. const intake = await tx.medicationIntakeEvent.deleteMany({ where: { userId: ownerId }, }); @@ -349,6 +376,27 @@ const handler = apiHandler( const tgDel = await tx.telegramScheduledDeletion.deleteMany({ where: { userId: ownerId }, }); + const documents = await tx.inboundDocument.deleteMany({ + where: { userId: ownerId }, + }); + const workouts = await tx.workout.deleteMany({ + where: { userId: ownerId }, + }); + const familyHistory = await tx.familyHistoryEntry.deleteMany({ + where: { userId: ownerId }, + }); + const allergies = await tx.allergy.deleteMany({ + where: { userId: ownerId }, + }); + const illnessEpisodes = await tx.illnessEpisode.deleteMany({ + where: { userId: ownerId }, + }); + const labResults = await tx.labResult.deleteMany({ + where: { userId: ownerId }, + }); + const biomarkers = await tx.biomarker.deleteMany({ + where: { userId: ownerId }, + }); // ── re-create from payload ── if (payload.measurements.length > 0) { @@ -426,18 +474,56 @@ const handler = apiHandler( } if (payload.moodEntries.length > 0) { - await tx.moodEntry.createMany({ - data: payload.moodEntries.map((e) => ({ - userId: ownerId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags ?? null, - source: e.source ?? "MOODLOG", - moodLoggedAt: new Date(e.loggedAt), - })), - skipDuplicates: true, - }); + const factorKeys = [ + ...new Set( + payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ), + ), + ]; + const factorRows = + factorKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: factorKeys }, + kind: "RATED", + userId: null, + }, + select: { id: true, key: true }, + }); + const factorByKey = new Map( + factorRows.map((factor) => [factor.key, factor.id]), + ); + if (factorByKey.size !== factorKeys.length) { + const missing = factorKeys.filter((key) => !factorByKey.has(key)); + throw new Error(`Unknown mood factor keys: ${missing.join(", ")}`); + } + + for (const entry of payload.moodEntries) { + const created = await tx.moodEntry.create({ + data: { + ...(entry.id ? { id: entry.id } : {}), + userId: ownerId, + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ?? null, + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt: new Date(entry.loggedAt), + }, + }); + if (entry.factors.length > 0) { + await tx.moodEntryTagLink.createMany({ + data: entry.factors.map((factor) => ({ + moodEntryId: created.id, + moodTagId: factorByKey.get(factor.key)!, + rating: factor.rating, + })), + }); + } + } } // v1.15.0 — cycle tables (profile + observed spans + day-logs + @@ -445,6 +531,276 @@ const handler = apiHandler( // above. `notesEncrypted` is restored as ciphertext verbatim. const cycleCleared = await restoreCycleData(tx, ownerId, payload); + const biomarkerByName = new Map(); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + ...(biomarker.createdAt + ? { createdAt: new Date(biomarker.createdAt) } + : {}), + ...(biomarker.updatedAt + ? { updatedAt: new Date(biomarker.updatedAt) } + : {}), + }, + }); + biomarkerByName.set(biomarker.name, created.id); + } + + for (const lab of payload.labResults) { + const biomarkerId = lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : undefined; + if (lab.biomarkerName && !biomarkerId) { + throw new Error( + `Unknown biomarker reference: ${lab.biomarkerName}`, + ); + } + await tx.labResult.create({ + data: { + ...(lab.id ? { id: lab.id } : {}), + userId: ownerId, + biomarkerId: biomarkerId ?? null, + panel: lab.panel ?? null, + analyte: lab.analyte, + value: lab.value ?? null, + valueText: lab.valueText ?? null, + unit: lab.unit, + referenceLow: lab.referenceLow ?? null, + referenceHigh: lab.referenceHigh ?? null, + takenAt: new Date(lab.takenAt), + source: lab.source, + noteEncrypted: + lab.note == null ? null : encryptNoteToBytes(lab.note), + ...(lab.createdAt + ? { createdAt: new Date(lab.createdAt) } + : {}), + ...(lab.updatedAt + ? { updatedAt: new Date(lab.updatedAt) } + : {}), + }, + }); + } + + const episodeIds = new Set( + payload.illnessEpisodes.map((episode) => episode.id), + ); + for (const episode of payload.illnessEpisodes) { + if ( + episode.parentConditionId && + !episodeIds.has(episode.parentConditionId) + ) { + throw new Error( + `Unknown illness parent: ${episode.parentConditionId}`, + ); + } + await tx.illnessEpisode.create({ + data: { + id: episode.id, + userId: ownerId, + label: episode.label, + type: episode.type as never, + lifecycle: episode.lifecycle as never, + onsetAt: new Date(episode.onsetAt), + resolvedAt: episode.resolvedAt + ? new Date(episode.resolvedAt) + : null, + parentConditionId: null, + noteEncrypted: + episode.note == null ? null : encryptToBytes(episode.note), + ...(episode.createdAt + ? { createdAt: new Date(episode.createdAt) } + : {}), + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, + }); + } + for (const episode of payload.illnessEpisodes) { + if (episode.parentConditionId) { + await tx.illnessEpisode.update({ + where: { id: episode.id }, + data: { parentConditionId: episode.parentConditionId }, + }); + } + } + + const symptomKeys = [ + ...new Set( + payload.illnessEpisodes.flatMap((episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), + ), + ), + ), + ]; + const symptomRows = + symptomKeys.length === 0 + ? [] + : await tx.illnessSymptom.findMany({ + where: { key: { in: symptomKeys } }, + select: { id: true, key: true }, + }); + const symptomByKey = new Map( + symptomRows.map((symptom) => [symptom.key, symptom.id]), + ); + if (symptomByKey.size !== symptomKeys.length) { + const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); + throw new Error(`Unknown illness symptom keys: ${missing.join(", ")}`); + } + for (const episode of payload.illnessEpisodes) { + for (const dayLog of episode.dayLogs) { + await tx.illnessDayLog.create({ + data: { + ...(dayLog.id ? { id: dayLog.id } : {}), + userId: ownerId, + episodeId: episode.id, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact ?? null, + feverC: dayLog.feverC ?? null, + noteEncrypted: + dayLog.note == null ? null : encryptToBytes(dayLog.note), + ...(dayLog.updatedAt + ? { updatedAt: new Date(dayLog.updatedAt) } + : {}), + symptomLinks: { + create: dayLog.symptoms.map((symptom) => ({ + symptomId: symptomByKey.get(symptom.key)!, + severity: symptom.severity ?? null, + })), + }, + }, + }); + } + } + + for (const allergy of payload.allergies) { + await tx.allergy.create({ + data: { + id: allergy.id, + userId: ownerId, + substance: allergy.substance, + category: allergy.category as never, + type: allergy.type as never, + severity: (allergy.severity ?? null) as never, + status: allergy.status as never, + onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, + reactionEncrypted: + allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), + notesEncrypted: + allergy.note == null ? null : encryptToBytes(allergy.note), + ...(allergy.createdAt + ? { createdAt: new Date(allergy.createdAt) } + : {}), + ...(allergy.updatedAt + ? { updatedAt: new Date(allergy.updatedAt) } + : {}), + }, + }); + } + + for (const familyEntry of payload.familyHistory) { + await tx.familyHistoryEntry.create({ + data: { + id: familyEntry.id, + userId: ownerId, + relationship: familyEntry.relationship as never, + condition: familyEntry.condition, + ageAtOnset: familyEntry.ageAtOnset ?? null, + notesEncrypted: + familyEntry.note == null + ? null + : encryptToBytes(familyEntry.note), + ...(familyEntry.createdAt + ? { createdAt: new Date(familyEntry.createdAt) } + : {}), + ...(familyEntry.updatedAt + ? { updatedAt: new Date(familyEntry.updatedAt) } + : {}), + }, + }); + } + + if (payload.workouts.length > 0) { + await tx.workout.createMany({ + data: payload.workouts.map((workout) => ({ + ...(workout.id ? { id: workout.id } : {}), + userId: ownerId, + sportType: workout.sportType, + startedAt: new Date(workout.startedAt), + endedAt: new Date(workout.endedAt), + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal ?? null, + totalDistanceM: workout.totalDistanceM ?? null, + avgHeartRate: workout.avgHeartRate ?? null, + maxHeartRate: workout.maxHeartRate ?? null, + minHeartRate: workout.minHeartRate ?? null, + stepCount: workout.stepCount ?? null, + elevationM: workout.elevationM ?? null, + pauseDurationSec: workout.pauseDurationSec ?? null, + source: workout.source as never, + externalId: workout.externalId ?? null, + ...(workout.createdAt + ? { createdAt: new Date(workout.createdAt) } + : {}), + ...(workout.updatedAt + ? { updatedAt: new Date(workout.updatedAt) } + : {}), + })), + }); + } + + if (payload.documents.length > 0) { + await tx.inboundDocument.createMany({ + data: payload.documents.map((document) => ({ + id: document.id, + userId: ownerId, + kind: document.kind as never, + title: document.title ?? null, + filename: document.filename ?? null, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentEncrypted: decodeEncryptedBytes( + document.contentEncrypted!, + ), + contentSha256: document.contentSha256 ?? null, + contentCodec: document.contentCodec!, + status: document.status as never, + providerType: document.providerType ?? null, + reportDate: document.reportDate + ? new Date(document.reportDate) + : null, + documentDate: document.documentDate + ? new Date(document.documentDate) + : null, + errorReason: document.errorReason ?? null, + summaryEncrypted: document.summaryEncrypted + ? decodeEncryptedBytes(document.summaryEncrypted) + : null, + summaryGeneratedAt: document.summaryGeneratedAt + ? new Date(document.summaryGeneratedAt) + : null, + summaryState: document.summaryState as never, + createdAt: new Date(document.createdAt!), + updatedAt: new Date(document.updatedAt!), + })), + }); + } + return { measurements: measurements.count, medications: meds.count, @@ -456,7 +812,17 @@ const handler = apiHandler( cycles: cycleCleared.cycles, cycleDayLogs: cycleCleared.cycleDayLogs, cycleProfile: cycleCleared.cycleProfile, + labResults: labResults.count, + biomarkers: biomarkers.count, + illnessEpisodes: illnessEpisodes.count, + allergies: allergies.count, + familyHistory: familyHistory.count, + workouts: workouts.count, + documents: documents.count, }; + }, { + maxWait: 10_000, + timeout: 120_000, }); } catch (err) { // Scrub the raw Prisma / driver error from the wire response — @@ -566,12 +932,19 @@ const handler = apiHandler( moodEntries: summary.moodEntries, cycles: summary.cycles, cycleDayLogs: summary.cycleDayLogs, + labResults: summary.labResults, + biomarkers: summary.biomarkers, + illnessEpisodes: summary.illnessEpisodes, + illnessDayLogs: summary.illnessDayLogs, + allergies: summary.allergies, + familyHistory: summary.familyHistory, + workouts: summary.workouts, + documents: summary.documents, }, }, }); - // v1.16.9 — the restore replaced every row the owner's cached - // payloads were built on; hard-evict all of that user's buckets. + // The complete transaction succeeded; only now evict owner-scoped caches. invalidateUserData(ownerId); const response: RestoreResponse = { diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index b64c1f128..1637560a7 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -1,32 +1,17 @@ /** - * Structured-records / workouts / documents-manifest backup section. + * Structured records, workouts, and document backup section. * - * Shared by the on-demand full-backup builder (`full-backup-payload.ts`, - * consumed by `GET /api/export/full-backup` + `POST /api/export/encrypted`) - * AND the weekly `data-backup` pg-boss worker - * (`src/lib/jobs/reminder/backup-handlers.ts`) so both writers emit the same - * shape — the doc-comment convention `buildCycleBackupSection` already - * established for the cycle tables. + * Portable exports keep the historical v1.28 metadata-only document shape. + * The weekly disaster-recovery writer opts into the canonical mode, which + * carries the encrypted document bytes and their persistence metadata + * verbatim. The DR path never decrypts document content or summaries. * - * Closes the backup-completeness gap: the pre-existing payload covered - * measurements / medications / intake events / mood / cycle only. This adds - * lab results + the biomarker catalog, illness episodes (including - * flares/exacerbations via `parentConditionId`) with their day-logs, - * allergies, family history, and workout summaries. - * - * Every read is scoped to `userId` + `deletedAt: null` (soft-deleted rows - * never resurrect into a backup). Encrypted free-text columns are decrypted - * FAIL-SOFT (null on a bad key / corrupt row, with a wide-event warning) so - * one damaged row never aborts an entire backup — the same posture the - * illness/allergy/family-history DTO layer already uses for list-style - * reads. Never the ciphertext itself leaves this module. - * - * Document BINARIES are deliberately excluded — see `DOCUMENTS_MANIFEST`. - * Workout GPS routes + per-sample time series (`WorkoutRoute` / - * `WorkoutSamples`) are deliberately excluded — see `WORKOUTS_MANIFEST`. - * Both exclusions are disclosed in the payload's `manifest` section (and in - * the export UI copy) rather than silently implied as "everything". + * Every read is scoped to the owner. Soft-deleted rows are excluded so a + * restore cannot resurrect tombstones. Other encrypted free-text fields keep + * the established fail-soft portable representation and are re-encrypted by + * the restore importer. */ +import { Buffer } from "node:buffer"; import type { PrismaClient } from "@/generated/prisma/client"; import { decryptNoteSoft } from "@/lib/labs/store"; import { decryptContextSoft } from "@/lib/labs/biomarker-store"; @@ -60,6 +45,8 @@ export const WORKOUTS_MANIFEST_NOTE = "series are not included in this export."; export interface LabResultBackupEntry { + /** Present in canonical DR payloads so rows keep a stable identity. */ + id?: string; panel: string | null; analyte: string; value: number | null; @@ -72,9 +59,13 @@ export interface LabResultBackupEntry { /** Human-readable cross-reference into `biomarkers` below, not an id. */ biomarkerName: string | null; note: string | null; + createdAt?: string; + updatedAt?: string; } export interface BiomarkerBackupEntry { + /** Present in canonical DR payloads; name remains the natural key. */ + id?: string; name: string; unit: string; lowerBound: number | null; @@ -82,9 +73,15 @@ export interface BiomarkerBackupEntry { panel: string | null; hidden: boolean; context: string | null; + createdAt?: string; + updatedAt?: string; } export interface WorkoutBackupEntry { + /** Present in canonical DR payloads for manual workouts without externalId. */ + id?: string; + createdAt?: string; + updatedAt?: string; sportType: string; startedAt: string; endedAt: string; @@ -111,8 +108,36 @@ export interface DocumentBackupEntry { status: string; reportDate: string | null; documentDate: string | null; - summary: string | null; + /** Portable export only; canonical DR carries summaryEncrypted instead. */ + summary?: string | null; createdAt: string; + updatedAt?: string; + /** Base64 of the already-encrypted BYTEA value; never plaintext. */ + contentEncrypted?: string; + contentSha256?: string | null; + contentCodec?: string; + providerType?: string | null; + errorReason?: string | null; + /** Base64 of the already-encrypted summary BYTEA value. */ + summaryEncrypted?: string | null; + summaryGeneratedAt?: string | null; + summaryState?: string; +} + +export interface RecordsBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +interface DisasterRecoveryDocumentRow { + contentEncrypted: Uint8Array; + contentSha256: string | null; + contentCodec: string; + providerType: string | null; + errorReason: string | null; + summaryEncrypted: Uint8Array | null; + summaryGeneratedAt: Date | null; + summaryState: string; + updatedAt: Date; } export interface RecordsBackupSection { @@ -124,7 +149,10 @@ export interface RecordsBackupSection { workouts: WorkoutBackupEntry[]; documents: DocumentBackupEntry[]; manifest: { - documents: { included: "metadata-only"; note: string }; + documents: { + included: "metadata-only" | "encrypted-content"; + note: string; + }; workouts: { included: "summary-only"; note: string }; }; } @@ -156,10 +184,8 @@ function decryptSummarySoft(buf: Uint8Array | null): string | null { } /** - * Build the structured-records / workouts / documents-manifest slice of a - * user's full backup. Accepts any client exposing the needed delegates (the - * app's global `prisma` OR the pg-boss worker's `getWorkerPrisma()` client) - * so both writers share one read. + * Build the structured-records slice. The default is the established portable + * export. Only the weekly DR writer passes `purpose: "disaster-recovery"`. */ export async function buildRecordsBackupSection( prisma: Pick< @@ -173,7 +199,52 @@ export async function buildRecordsBackupSection( | "inboundDocument" >, userId: string, + options: RecordsBackupOptions = {}, ): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + const documentQuery = disasterRecovery + ? prisma.inboundDocument.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + kind: true, + title: true, + filename: true, + mimeType: true, + byteSize: true, + contentEncrypted: true, + contentSha256: true, + contentCodec: true, + status: true, + providerType: true, + reportDate: true, + documentDate: true, + errorReason: true, + summaryEncrypted: true, + summaryGeneratedAt: true, + summaryState: true, + createdAt: true, + updatedAt: true, + }, + }) + : prisma.inboundDocument.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + kind: true, + title: true, + filename: true, + mimeType: true, + byteSize: true, + status: true, + reportDate: true, + documentDate: true, + summaryEncrypted: true, + createdAt: true, + }, + }); const [ labResultRows, biomarkerRows, @@ -215,26 +286,17 @@ export async function buildRecordsBackupSection( where: { userId }, orderBy: { startedAt: "desc" }, }), - prisma.inboundDocument.findMany({ - where: { userId, deletedAt: null }, - orderBy: { createdAt: "desc" }, - select: { - id: true, - kind: true, - title: true, - filename: true, - mimeType: true, - byteSize: true, - status: true, - reportDate: true, - documentDate: true, - summaryEncrypted: true, - createdAt: true, - }, - }), + documentQuery, ]); const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({ + ...(disasterRecovery + ? { + id: r.id, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + } + : {}), panel: r.panel, analyte: r.analyte, value: r.value, @@ -249,6 +311,13 @@ export async function buildRecordsBackupSection( })); const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({ + ...(disasterRecovery + ? { + id: b.id, + createdAt: b.createdAt.toISOString(), + updatedAt: b.updatedAt.toISOString(), + } + : {}), name: b.name, unit: b.unit, lowerBound: b.lowerBound, @@ -267,6 +336,13 @@ export async function buildRecordsBackupSection( const familyHistory = familyRows.map(toFamilyHistoryEntryDTO); const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({ + ...(disasterRecovery + ? { + id: w.id, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + } + : {}), sportType: w.sportType, startedAt: w.startedAt.toISOString(), endedAt: w.endedAt.toISOString(), @@ -283,21 +359,49 @@ export async function buildRecordsBackupSection( externalId: w.externalId, })); - const documents: DocumentBackupEntry[] = documentRows.map((d) => ({ - id: d.id, - kind: d.kind, - title: d.title, - filename: d.filename, - mimeType: d.mimeType, - byteSize: d.byteSize, - status: d.status, - reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null, - documentDate: d.documentDate - ? d.documentDate.toISOString().slice(0, 10) - : null, - summary: decryptSummarySoft(d.summaryEncrypted), - createdAt: d.createdAt.toISOString(), - })); + const documents: DocumentBackupEntry[] = documentRows.map((d) => { + const metadata = { + id: d.id, + kind: d.kind, + title: d.title, + filename: d.filename, + mimeType: d.mimeType, + byteSize: d.byteSize, + status: d.status, + reportDate: d.reportDate + ? d.reportDate.toISOString().slice(0, 10) + : null, + documentDate: d.documentDate + ? d.documentDate.toISOString().slice(0, 10) + : null, + createdAt: d.createdAt.toISOString(), + }; + + if (disasterRecovery && "contentEncrypted" in d) { + const dr = d as typeof d & DisasterRecoveryDocumentRow; + return { + ...metadata, + reportDate: dr.reportDate?.toISOString() ?? null, + documentDate: dr.documentDate?.toISOString() ?? null, + updatedAt: dr.updatedAt.toISOString(), + contentEncrypted: Buffer.from(dr.contentEncrypted).toString("base64"), + contentSha256: dr.contentSha256, + contentCodec: dr.contentCodec, + providerType: dr.providerType, + errorReason: dr.errorReason, + summaryEncrypted: dr.summaryEncrypted + ? Buffer.from(dr.summaryEncrypted).toString("base64") + : null, + summaryGeneratedAt: dr.summaryGeneratedAt?.toISOString() ?? null, + summaryState: dr.summaryState, + }; + } + + return { + ...metadata, + summary: decryptSummarySoft(d.summaryEncrypted), + }; + }); return { labResults, @@ -308,7 +412,13 @@ export async function buildRecordsBackupSection( workouts, documents, manifest: { - documents: { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, + documents: disasterRecovery + ? { + included: "encrypted-content", + note: + "Document metadata and encrypted stored bytes are included for disaster recovery.", + } + : { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE }, }, }; diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts new file mode 100644 index 000000000..f829ed0be --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -0,0 +1,154 @@ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildRecordsBackupSection: vi.fn(), + encrypt: vi.fn((value: string) => value), + getWorkerPrisma: vi.fn(), + upsert: vi.fn(), +})); + +vi.mock("@/lib/export/records-backup", () => ({ + buildRecordsBackupSection: mocks.buildRecordsBackupSection, +})); + +vi.mock("@/lib/cycle/backup", () => ({ + buildCycleBackupSection: vi.fn().mockResolvedValue({ + cycleProfile: null, + cycles: [], + cycleDayLogs: [], + }), +})); + +vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt })); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: vi.fn( + async (_name: string, run: (event: object) => Promise) => + run({ + addMeta: vi.fn(), + addWarning: vi.fn(), + setBackground: vi.fn(), + setError: vi.fn(), + }), + ), +})); + +vi.mock("../shared", () => ({ + getWorkerPrisma: mocks.getWorkerPrisma, +})); + +import { handleDataBackup } from "../backup-handlers"; + +const documentCiphertext = Buffer.from([1, 2, 3, 4]).toString("base64"); + +function buildPrismaMock() { + return { + user: { + findMany: vi.fn().mockResolvedValue([ + { id: "user-dr", username: "backup-owner" }, + ]), + }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { + findMany: vi.fn().mockResolvedValue([ + { + id: "mood-dr", + date: "2026-07-01", + mood: "GUT", + score: 4, + tags: null, + source: "MOODLOG", + externalId: "mood-external-dr", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + tagLinks: [ + { rating: 5, moodTag: { key: "sleep_quality" } }, + ], + }, + ]), + }, + dataBackup: { upsert: mocks.upsert }, + }; +} + +describe("handleDataBackup canonical DR payload", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock()); + mocks.upsert.mockResolvedValue({}); + mocks.buildRecordsBackupSection.mockResolvedValue({ + labResults: [], + biomarkers: [], + illnessEpisodes: [], + allergies: [], + familyHistory: [], + workouts: [], + documents: [ + { + id: "document-dr", + kind: "OTHER", + title: null, + filename: "record.pdf", + mimeType: "application/pdf", + byteSize: 4, + status: "STORED", + reportDate: null, + documentDate: null, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + contentEncrypted: documentCiphertext, + contentSha256: null, + contentCodec: "binary2", + providerType: null, + errorReason: null, + summaryEncrypted: null, + summaryGeneratedAt: null, + summaryState: "NONE", + }, + ], + manifest: { + documents: { + included: "encrypted-content", + note: "Encrypted content included", + }, + workouts: { included: "summary-only", note: "Summary only" }, + }, + }); + }); + + it("requests canonical records and serializes rated mood factors", async () => { + await handleDataBackup([]); + + expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( + expect.anything(), + "user-dr", + { purpose: "disaster-recovery" }, + ); + expect(mocks.upsert).toHaveBeenCalledOnce(); + const encrypted = mocks.upsert.mock.calls[0]![0].create.data as string; + const payload = JSON.parse(encrypted) as { + moodEntries: Array<{ + id: string; + externalId: string; + factors: Array<{ key: string; rating: number }>; + }>; + documents: Array<{ contentEncrypted: string; contentCodec: string }>; + }; + expect(payload.moodEntries).toEqual([ + expect.objectContaining({ + id: "mood-dr", + externalId: "mood-external-dr", + factors: [{ key: "sleep_quality", rating: 5 }], + }), + ]); + expect(payload.documents).toEqual([ + expect.objectContaining({ + contentEncrypted: documentCiphertext, + contentCodec: "binary2", + }), + ]); + }); +}); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 18a024052..f289a3846 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -120,13 +120,23 @@ export async function handleDataBackup(jobs: Job[]) { prisma.moodEntry.findMany({ where: { userId: user.id }, orderBy: { moodLoggedAt: "desc" }, + include: { + tagLinks: { + where: { rating: { not: null } }, + select: { + rating: true, + moodTag: { select: { key: true } }, + }, + }, + }, }), // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). buildCycleBackupSection(prisma, user.id), - // v1.28 backup-completeness — labs/biomarkers, illness episodes - // + day-logs, allergies, family history, workouts, and the - // documents manifest (shared helper — see records-backup.ts). - buildRecordsBackupSection(prisma, user.id), + // Canonical DR mode includes InboundDocument ciphertext and the + // exact codec/hash metadata needed to re-create the row. + buildRecordsBackupSection(prisma, user.id, { + purpose: "disaster-recovery", + }), ]); const backupJson = JSON.stringify({ @@ -159,20 +169,26 @@ export async function handleDataBackup(jobs: Job[]) { source: e.source, })), moodEntries: moodEntries.map((e) => ({ + id: e.id, date: e.date, mood: e.mood, score: e.score, tags: e.tags, source: e.source, loggedAt: e.moodLoggedAt.toISOString(), + externalId: e.externalId, + factors: e.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), })), // v1.15.0 — cycle slice (profile + observed spans + day-logs). cycleProfile: cycle.cycleProfile, cycles: cycle.cycles, cycleDayLogs: cycle.cycleDayLogs, - // v1.28 backup-completeness (see records-backup.ts) — export-only - // domains; the manifest field discloses the two deliberate - // exclusions (document binaries, workout GPS/sample series). + // Canonical disaster-recovery records. Documents carry encrypted + // bytes plus codec/hash metadata; workout route/sample sidecars + // remain outside the serialized workout-summary contract. labResults: records.labResults, biomarkers: records.biomarkers, illnessEpisodes: records.illnessEpisodes, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index c05ea5010..1b0980090 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -97,14 +97,24 @@ const moodEntryTagsSchema = z { message: "tags must be null, empty, or a JSON array of strings" }, ); +const moodFactorSchema = z + .object({ + key: z.string().min(1), + rating: z.number().int(), + }) + .passthrough(); + const moodEntrySchema = z .object({ + id: z.string().min(1).optional(), date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), tags: moodEntryTagsSchema, source: z.string().min(1).optional(), + externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + factors: z.array(moodFactorSchema).default([]), }) .passthrough(); @@ -177,22 +187,18 @@ const cycleProfileSchema = z }) .passthrough(); -/* ── v1.28 backup-completeness shapes ────────────────────────────── +/* ── Structured-record disaster-recovery shapes ───────────────────── * - * Labs, illness episodes (+ flares/exacerbations), allergies, family - * history, workouts, and the documents manifest. Written by - * `buildRecordsBackupSection` (`src/lib/export/records-backup.ts`) — shared - * by the on-demand export routes AND the weekly `data-backup` worker. - * - * EXPORT-ONLY today: `POST /api/admin/backups/[id]/restore` does not yet - * recreate these domains on restore (only measurements / medications / - * mood / cycle round-trip). The schemas below exist so the payload - * validates + parses with real types (an admin reviewing an uploaded - * backup sees accurate counts) even though restore ignores them for now. + * These shapes serve both the historical portable export and the canonical + * weekly/off-host disaster-recovery payload. Portable document entries remain + * metadata-only. Canonical entries additionally carry encrypted BYTEA values + * as base64 plus the codec/hash fields required to recreate InboundDocument + * without decrypting or fabricating content. */ const labResultBackupSchema = z .object({ + id: z.string().min(1).optional(), panel: z.string().nullable().optional(), analyte: z.string().min(1), value: z.number().nullable().optional(), @@ -204,11 +210,14 @@ const labResultBackupSchema = z source: z.string().min(1), biomarkerName: z.string().nullable().optional(), note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); const biomarkerBackupSchema = z .object({ + id: z.string().min(1).optional(), name: z.string().min(1), unit: z.string().min(1), lowerBound: z.number().nullable().optional(), @@ -216,6 +225,8 @@ const biomarkerBackupSchema = z panel: z.string().nullable().optional(), hidden: z.boolean().optional(), context: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -287,6 +298,7 @@ const familyHistoryBackupSchema = z const workoutBackupSchema = z .object({ + id: z.string().min(1).optional(), sportType: z.string().min(1), startedAt: isoDateTime, endedAt: isoDateTime, @@ -301,9 +313,20 @@ const workoutBackupSchema = z pauseDurationSec: z.number().int().nullable().optional(), source: z.string().min(1), externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); +const base64BytesSchema = z + .string() + .min(1) + .refine( + (value) => + value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + { message: "Expected base64-encoded encrypted bytes" }, + ); + const documentBackupSchema = z .object({ id: z.string().min(1), @@ -315,8 +338,21 @@ const documentBackupSchema = z status: z.string().min(1), reportDate: z.string().nullable().optional(), documentDate: z.string().nullable().optional(), + contentEncrypted: base64BytesSchema.optional(), + contentSha256: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .optional(), + contentCodec: z.string().min(1).optional(), + providerType: z.string().nullable().optional(), + errorReason: z.string().nullable().optional(), + summaryEncrypted: base64BytesSchema.nullable().optional(), + summaryGeneratedAt: isoDateTime.nullable().optional(), + summaryState: z.string().min(1).optional(), summary: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -350,9 +386,9 @@ export const backupPayloadSchema = z cycleProfile: cycleProfileSchema.nullable().default(null), cycles: z.array(cycleSpanSchema).default([]), cycleDayLogs: z.array(cycleDayLogSchema).default([]), - // v1.28 backup-completeness — export-only for now (see the block - // comment above). Default to empty arrays / null so a pre-v1.28 backup - // (no records keys) still round-trips unchanged. + // Structured records default to empty arrays so older backups remain + // parseable. Canonical DR writers add stable ids and encrypted document + // fields; portable exports retain the metadata-only subset. labResults: z.array(labResultBackupSchema).default([]), biomarkers: z.array(biomarkerBackupSchema).default([]), illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]), @@ -383,21 +419,21 @@ export interface BackupSummary { cycles: number; /** v1.15.0 — cycle day-logs in the backup. */ cycleDayLogs: number; - /** v1.28 — lab results in the backup (export-only, see above). */ + /** Lab results in the backup. */ labResults: number; - /** v1.28 — biomarker catalog entries in the backup (export-only). */ + /** User-scoped biomarker catalog entries in the backup. */ biomarkers: number; - /** v1.28 — illness episodes, incl. flares/exacerbations (export-only). */ + /** Illness episodes, including flares/exacerbations. */ illnessEpisodes: number; - /** v1.28 — illness day-logs across every episode (export-only). */ + /** Illness day-logs across every episode. */ illnessDayLogs: number; - /** v1.28 — allergy/intolerance records in the backup (export-only). */ + /** Allergy/intolerance records in the backup. */ allergies: number; - /** v1.28 — family-history entries in the backup (export-only). */ + /** Family-history entries in the backup. */ familyHistory: number; - /** v1.28 — workout summary records in the backup (export-only). */ + /** Workout summary records in the backup. */ workouts: number; - /** v1.28 — document manifest entries (metadata only, export-only). */ + /** Document records (ciphertext included in canonical DR payloads). */ documents: number; } diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts new file mode 100644 index 000000000..3aa274805 --- /dev/null +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -0,0 +1,664 @@ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt, encryptBytes } from "@/lib/crypto"; +import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { buildRecordsBackupSection } from "@/lib/export/records-backup"; +import { backupPayloadSchema } from "@/lib/validations/backup"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +async function seedAdminSession() { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "canonical-restore-admin", + email: "canonical-restore-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { + userId: admin.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return admin; +} + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +describe("canonical disaster-recovery backup round-trip", () => { + it("restores every serialized record class and document ciphertext verbatim", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const ownerId = admin.id; + + const measurement = await prisma.measurement.create({ + data: { + id: "measurement-dr", + userId: ownerId, + type: "WEIGHT", + value: 72.4, + unit: "kg", + measuredAt: new Date("2026-07-01T07:00:00.000Z"), + source: "MANUAL", + }, + }); + + const factorCategory = await prisma.moodTagCategory.create({ + data: { + id: "category-dr", + key: "dr_restore_factors", + labelKey: "mood.tagCategory.drRestore", + }, + }); + const factor = await prisma.moodTag.create({ + data: { + id: "factor-dr", + categoryId: factorCategory.id, + key: "dr_sleep_quality", + labelKey: "mood.tag.drSleepQuality", + kind: "RATED", + scaleMin: 1, + scaleMax: 5, + }, + }); + const mood = await prisma.moodEntry.create({ + data: { + id: "mood-dr", + userId: ownerId, + date: "2026-07-01", + mood: "GUT", + score: 4, + source: "MOODLOG", + moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), + tagLinks: { + create: { moodTagId: factor.id, rating: 5 }, + }, + }, + include: { + tagLinks: { + select: { rating: true, moodTag: { select: { key: true } } }, + }, + }, + }); + + const biomarker = await prisma.biomarker.create({ + data: { + id: "biomarker-dr", + userId: ownerId, + name: "Ferritin", + unit: "ng/mL", + lowerBound: 30, + upperBound: 300, + panel: "Iron", + contextEncrypted: encryptToBytes("Iron storage context"), + }, + }); + const lab = await prisma.labResult.create({ + data: { + id: "lab-dr", + userId: ownerId, + biomarkerId: biomarker.id, + panel: "Iron", + analyte: "Ferritin", + value: 88, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 300, + takenAt: new Date("2026-06-30T09:00:00.000Z"), + source: "MANUAL", + noteEncrypted: encryptToBytes("Fasted"), + }, + }); + + const illness = await prisma.illnessEpisode.create({ + data: { + id: "illness-dr", + userId: ownerId, + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-20T00:00:00.000Z"), + resolvedAt: new Date("2026-06-24T00:00:00.000Z"), + noteEncrypted: encryptToBytes("Recovered fully"), + }, + }); + const symptom = await prisma.illnessSymptom.create({ + data: { + id: "symptom-dr", + key: "dr_cough", + labelKey: "illness.symptom.drCough", + }, + }); + const illnessDay = await prisma.illnessDayLog.create({ + data: { + id: "illness-day-dr", + userId: ownerId, + episodeId: illness.id, + date: "2026-06-21", + functionalImpact: 2, + feverC: 38.1, + noteEncrypted: encryptToBytes("Rest day"), + symptomLinks: { + create: { symptomId: symptom.id, severity: 2 }, + }, + }, + }); + + const allergy = await prisma.allergy.create({ + data: { + id: "allergy-dr", + userId: ownerId, + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: new Date("2020-01-01T00:00:00.000Z"), + reactionEncrypted: encryptToBytes("Hives"), + notesEncrypted: encryptToBytes("Avoid beta-lactams"), + }, + }); + const family = await prisma.familyHistoryEntry.create({ + data: { + id: "family-dr", + userId: ownerId, + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + notesEncrypted: encryptToBytes("Diet controlled"), + }, + }); + const workout = await prisma.workout.create({ + data: { + id: "workout-dr", + userId: ownerId, + sportType: "RUNNING", + startedAt: new Date("2026-06-29T06:00:00.000Z"), + endedAt: new Date("2026-06-29T06:30:00.000Z"), + durationSec: 1800, + totalEnergyKcal: 250, + totalDistanceM: 5000, + avgHeartRate: 145, + maxHeartRate: 170, + source: "IMPORT", + externalId: "run-external-dr", + }, + }); + + const encryptedBuffer = encryptBytes(Buffer.from("private PDF bytes")); + const contentEncrypted = new Uint8Array( + new ArrayBuffer(encryptedBuffer.byteLength), + ); + contentEncrypted.set(encryptedBuffer); + const summaryEncrypted = encryptToBytes("Encrypted summary"); + const document = await prisma.inboundDocument.create({ + data: { + id: "document-dr", + userId: ownerId, + kind: "LAB_RESULT", + title: "June labs", + filename: "june-labs.pdf", + mimeType: "application/pdf", + byteSize: 17, + contentEncrypted, + contentSha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + contentCodec: "binary2", + status: "EXTRACTED", + providerType: "local", + reportDate: new Date("2026-06-30T00:00:00.000Z"), + documentDate: new Date("2026-06-30T00:00:00.000Z"), + summaryEncrypted, + summaryGeneratedAt: new Date("2026-07-01T08:00:00.000Z"), + summaryState: "READY", + createdAt: new Date("2026-07-01T07:30:00.000Z"), + updatedAt: new Date("2026-07-01T08:00:00.000Z"), + }, + }); + + const records = await buildRecordsBackupSection(prisma, ownerId, { + purpose: "disaster-recovery", + }); + + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-02T00:00:00.000Z", + userId: ownerId, + measurements: [ + { + type: measurement.type, + value: measurement.value, + unit: measurement.unit, + measuredAt: measurement.measuredAt.toISOString(), + source: measurement.source, + }, + ], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: mood.id, + date: mood.date, + mood: mood.mood, + score: mood.score, + tags: mood.tags, + source: mood.source, + loggedAt: mood.moodLoggedAt.toISOString(), + factors: mood.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating, + })), + }, + ], + labResults: records.labResults, + biomarkers: records.biomarkers, + illnessEpisodes: records.illnessEpisodes, + allergies: records.allergies, + familyHistory: records.familyHistory, + workouts: records.workouts, + documents: records.documents, + manifest: records.manifest, + }); + expect(payload.documents[0]).not.toHaveProperty("summary"); + expect(JSON.stringify(payload.documents[0])).not.toContain( + "private PDF bytes", + ); + + await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } }); + await prisma.workout.deleteMany({ where: { userId: ownerId } }); + await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } }); + await prisma.allergy.deleteMany({ where: { userId: ownerId } }); + await prisma.illnessEpisode.deleteMany({ where: { userId: ownerId } }); + await prisma.labResult.deleteMany({ where: { userId: ownerId } }); + await prisma.biomarker.deleteMany({ where: { userId: ownerId } }); + + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "CANONICAL_DR_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const restore = async () => + POST(makeRequest(backup.id) as unknown as Parameters[0], { + params: Promise.resolve({ id: backup.id }), + }); + + expect((await restore()).status).toBe(200); + + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.illnessEpisode.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.illnessDayLog.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.familyHistoryEntry.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.measurement.findMany({ + where: { userId: ownerId }, + select: { + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + }, + }), + ).toEqual([ + { + type: measurement.type, + value: measurement.value, + unit: measurement.unit, + measuredAt: measurement.measuredAt, + source: measurement.source, + }, + ]); + + const restoredLab = await prisma.labResult.findUniqueOrThrow({ + where: { id: lab.id }, + }); + expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted"); + expect(restoredLab.biomarkerId).toBe(biomarker.id); + expect(restoredLab).toEqual( + expect.objectContaining({ + panel: lab.panel, + analyte: lab.analyte, + value: lab.value, + unit: lab.unit, + referenceLow: lab.referenceLow, + referenceHigh: lab.referenceHigh, + takenAt: lab.takenAt, + source: lab.source, + }), + ); + const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({ + where: { id: biomarker.id }, + }); + expect(restoredBiomarker).toEqual( + expect.objectContaining({ + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound, + upperBound: biomarker.upperBound, + panel: biomarker.panel, + hidden: biomarker.hidden, + }), + ); + expect(decryptFromBytes(restoredBiomarker.contextEncrypted!)).toBe( + "Iron storage context", + ); + + const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({ + where: { id: illness.id }, + }); + expect(restoredIllness).toEqual( + expect.objectContaining({ + label: illness.label, + type: illness.type, + lifecycle: illness.lifecycle, + onsetAt: illness.onsetAt, + resolvedAt: illness.resolvedAt, + }), + ); + expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe( + "Recovered fully", + ); + const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({ + where: { id: illnessDay.id }, + include: { symptomLinks: true }, + }); + expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe("Rest day"); + expect(restoredIllnessDay.symptomLinks).toEqual([ + expect.objectContaining({ symptomId: symptom.id, severity: 2 }), + ]); + + const restoredAllergy = await prisma.allergy.findUniqueOrThrow({ + where: { id: allergy.id }, + }); + expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives"); + expect(restoredAllergy).toEqual( + expect.objectContaining({ + substance: allergy.substance, + category: allergy.category, + type: allergy.type, + severity: allergy.severity, + status: allergy.status, + onsetAt: allergy.onsetAt, + }), + ); + const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({ + where: { id: family.id }, + }); + expect(decryptFromBytes(restoredFamily.notesEncrypted!)).toBe( + "Diet controlled", + ); + expect(restoredFamily).toEqual( + expect.objectContaining({ + relationship: family.relationship, + condition: family.condition, + ageAtOnset: family.ageAtOnset, + }), + ); + expect( + await prisma.workout.findUniqueOrThrow({ where: { id: workout.id } }), + ).toEqual( + expect.objectContaining({ + sportType: workout.sportType, + startedAt: workout.startedAt, + endedAt: workout.endedAt, + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal, + totalDistanceM: workout.totalDistanceM, + avgHeartRate: workout.avgHeartRate, + maxHeartRate: workout.maxHeartRate, + source: workout.source, + externalId: workout.externalId, + }), + ); + + const restoredDocument = await prisma.inboundDocument.findUniqueOrThrow({ + where: { id: document.id }, + }); + expect(Buffer.from(restoredDocument.contentEncrypted)).toEqual( + Buffer.from(document.contentEncrypted), + ); + expect(Buffer.from(restoredDocument.summaryEncrypted!)).toEqual( + Buffer.from(document.summaryEncrypted!), + ); + expect(restoredDocument).toEqual( + expect.objectContaining({ + title: document.title, + filename: document.filename, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentSha256: document.contentSha256, + contentCodec: document.contentCodec, + status: document.status, + providerType: document.providerType, + summaryState: document.summaryState, + reportDate: document.reportDate, + documentDate: document.documentDate, + errorReason: document.errorReason, + summaryGeneratedAt: document.summaryGeneratedAt, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }), + ); + + const restoredMood = await prisma.moodEntry.findUniqueOrThrow({ + where: { id: mood.id }, + include: { + tagLinks: { + include: { moodTag: { select: { key: true } } }, + }, + }, + }); + expect(restoredMood).toEqual( + expect.objectContaining({ + date: mood.date, + mood: mood.mood, + score: mood.score, + source: mood.source, + moodLoggedAt: mood.moodLoggedAt, + }), + ); + expect(restoredMood.tagLinks).toEqual([ + expect.objectContaining({ + rating: 5, + moodTag: { key: "dr_sleep_quality" }, + }), + ]); + + expect((await restore()).status).toBe(200); + expect( + await prisma.inboundDocument.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: document.id }]); + expect( + await prisma.workout.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: workout.id }]); + expect( + await prisma.labResult.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: lab.id }]); + expect( + await prisma.illnessEpisode.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }), + ).toEqual([{ id: illness.id }]); + }); + + it("rejects metadata-only documents before mutation or invalidation", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + await prisma.measurement.create({ + data: { + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T07:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-03T08:00:00.000Z", + userId: admin.id, + measurements: [], + moodEntries: [], + documents: [ + { + id: "metadata-only-document", + kind: "OTHER", + title: null, + filename: "metadata.pdf", + mimeType: "application/pdf", + byteSize: 12, + status: "STORED", + reportDate: null, + documentDate: null, + summary: null, + createdAt: "2026-07-03T08:00:00.000Z", + }, + ], + manifest: { + documents: { included: "metadata-only", note: "Portable export" }, + workouts: { included: "summary-only", note: "Summary only" }, + }, + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "PORTABLE_METADATA_ONLY", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(422); + expect( + await prisma.measurement.count({ where: { userId: admin.id } }), + ).toBe(1); + expect(invalidateUserData).not.toHaveBeenCalled(); + }); + + it("rolls back a failed full restore before invalidating caches", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const measurement = await prisma.measurement.create({ + data: { + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-04T07:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "1", + exportedAt: "2026-07-04T08:00:00.000Z", + userId: admin.id, + measurements: [], + moodEntries: [ + { + id: "failed-restore-mood", + date: "2026-07-04", + mood: "OKAY", + score: 3, + tags: null, + loggedAt: "2026-07-04T08:00:00.000Z", + factors: [{ key: "missing-backup-factor", rating: 4 }], + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "FAILED_FULL_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(500); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + select: { id: true }, + }), + ).toEqual([{ id: measurement.id }]); + expect( + await prisma.moodEntry.count({ where: { userId: admin.id } }), + ).toBe(0); + expect(invalidateUserData).not.toHaveBeenCalled(); + }); +}); From 7b515f4f828f9b97452ddd76954632d8e8495437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:25:48 +0200 Subject: [PATCH 07/35] fix(devices): reconcile APNs registrations --- src/app/api/devices/__tests__/route.test.ts | 77 +++++- src/app/api/devices/route.ts | 234 ++++++++++------- tests/integration/device-register.test.ts | 273 +++++++++++++++++--- 3 files changed, 437 insertions(+), 147 deletions(-) diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 89ca90a10..c16b20a0d 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -8,7 +8,13 @@ vi.mock("@/lib/db", () => ({ findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), + deleteMany: vi.fn(), }, + refreshToken: { + updateMany: vi.fn(), + }, + $queryRaw: vi.fn(), + $transaction: vi.fn(), notificationChannel: { upsert: vi.fn(), }, @@ -76,6 +82,12 @@ beforeEach(() => { vi.mocked(prisma.device.findFirst).mockResolvedValue(null); vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never); + vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.$transaction).mockImplementation( + async (callback) => callback(prisma as never), + ); vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ id: "ch-1", } as never); @@ -228,27 +240,76 @@ describe("POST /api/devices", () => { expect(prisma.device.update).not.toHaveBeenCalled(); }); - it("falls through idempotently when the apnsToken already belongs to the same user", async () => { - // v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used - // to filter `NOT: { userId }` so it skipped same-user collisions - // entirely, letting `findMany({ apnsToken })` fan a single push - // out to two Device rows. The guard now reads `userId` and only - // rejects cross-user collisions; same-user collisions fall - // through to the legacy-token upsert path. + it("reconciles a changed legacy token onto the same user's APNs device", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.device.findFirst).mockResolvedValue({ id: "self-dev", userId: "user-1", + token: "original-legacy-token", } as never); const res = await POST( req({ - token: "abcd1234efgh5678", + token: "changed-legacy-token", bundleId: "io.healthlog.app", apnsToken: "deadbeef".repeat(8), apnsEnvironment: "sandbox", }), ); expect(res.status).toBe(201); + expect(prisma.device.create).not.toHaveBeenCalled(); + expect(prisma.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "self-dev", userId: "user-1" }, + data: expect.objectContaining({ token: "changed-legacy-token" }), + }), + ); + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", deviceId: "original-legacy-token" }, + data: { deviceId: "changed-legacy-token" }, + }); + }); + + it("merges two same-user identities and migrates owned refresh tokens", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findUnique).mockResolvedValue({ + id: "legacy-dev", + userId: "user-1", + token: "changed-legacy-token", + medicationDelivery: "client", + liveActivityPushToken: "cafebabe".repeat(8), + } as never); + vi.mocked(prisma.device.findFirst).mockResolvedValue({ + id: "apns-dev", + userId: "user-1", + token: "original-legacy-token", + } as never); + + const res = await POST( + req({ + token: "changed-legacy-token", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + apnsEnvironment: "production", + }), + ); + + expect(res.status).toBe(201); + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { userId: "user-1", deviceId: "original-legacy-token" }, + data: { deviceId: "changed-legacy-token" }, + }); + expect(prisma.device.deleteMany).toHaveBeenCalledWith({ + where: { id: "legacy-dev", userId: "user-1" }, + }); + expect(prisma.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "apns-dev", userId: "user-1" }, + data: expect.objectContaining({ + medicationDelivery: "client", + liveActivityPushToken: "cafebabe".repeat(8), + }), + }), + ); }); // v1.17.1 (#22) — Live Activity push-token registration. diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index caeca15f2..cbbabdaef 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -3,8 +3,8 @@ * * Native-client device registration. The iOS app calls this on login, * on APNs token rotation, and whenever a fresh `apnsToken` arrives from - * `application:didRegisterForRemoteNotificationsWithDeviceToken:`. We - * upsert by `token` (the legacy generic identifier). + * `application:didRegisterForRemoteNotificationsWithDeviceToken:`. Both the + * legacy `token` and `apnsToken` resolve to one canonical device. * * Cross-user-hijack guard: * * A device `token` belongs to exactly one user. Re-registering the @@ -162,109 +162,143 @@ export const POST = apiHandler(async (request: NextRequest) => { liveActivityPushToken, } = parsed.data; - // APNs-token collision lookup. Two cases: - // * A row for ANOTHER user owns this token → 409 hijack guard. - // APNs tokens aren't secrets, so accepting wire input that - // references another user's token would let anyone who learns - // one redirect that user's pushes. - // * A row for THIS user already owns it → idempotent fall-through. - // The upsert below either updates the matching legacy `token` - // row in place, or creates a fresh legacy `token` row that the - // migration-0041 partial unique index now blocks (the upsert - // branch will hit the unique constraint and the request will - // fail with a 500 — but that's a programmer-error path the - // iOS client should never trigger; same-user re-registration - // keeps the same legacy `token`). - // The `NOT: { userId }` clause was removed in v1.4.23 W6 reconcile — - // it skipped the same-user collision and let `findMany({ apnsToken })` - // fan a single push out to two Device rows, double-charging the APNs - // quota. - if (apnsToken) { - const existingApns = await prisma.device.findFirst({ - where: { apnsToken }, - select: { id: true, userId: true }, - }); - if (existingApns && existingApns.userId !== user.id) { - await auditLog("device.register.denied", { - userId: user.id, - details: { - reason: "apns_token_owned_by_other_user", - deviceId: existingApns.id, + const registration = await prisma.$transaction(async (tx) => { + // Token ownership is decided from one database snapshot. Advisory + // transaction locks serialize registrations that share either identity, + // including cross-user attempts and the first-create race. Sorting avoids + // deadlocks when concurrent requests present the same pair in reverse. + const lockKeys = [ + `device-token:${token}`, + ...(apnsToken ? [`device-apns:${apnsToken}`] : []), + ].sort(); + for (const lockKey of lockKeys) { + // `pg_advisory_xact_lock` returns void, which Prisma cannot deserialize + // directly. Selecting through FROM yields a plain integer row. + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0)) + `; + } + + const [legacyDevice, apnsDevice] = await Promise.all([ + tx.device.findUnique({ + where: { token }, + select: { + id: true, + userId: true, + token: true, + medicationDelivery: true, + liveActivityPushToken: true, }, - }); - return apiError("APNs token already registered to another account", 409); + }), + apnsToken + ? tx.device.findFirst({ + where: { apnsToken }, + select: { + id: true, + userId: true, + token: true, + medicationDelivery: true, + liveActivityPushToken: true, + }, + }) + : null, + ]); + + if (apnsDevice && apnsDevice.userId !== user.id) { + return { + conflict: "apns_token_owned_by_other_user" as const, + deviceId: apnsDevice.id, + }; } + if (legacyDevice && legacyDevice.userId !== user.id) { + return { + conflict: "token_owned_by_other_user" as const, + deviceId: legacyDevice.id, + }; + } + + // RefreshToken.deviceId stores the client-supplied Device.token value, + // not the database row id. Keep this user's token family aligned when + // APNs identifies an existing device under a changed legacy token. + if (apnsDevice && apnsDevice.token !== token) { + await tx.refreshToken.updateMany({ + where: { userId: user.id, deviceId: apnsDevice.token }, + data: { deviceId: token }, + }); + } + + // An APNs match is the canonical physical-device identity. If the newly + // presented legacy token already has a second same-user row, carry its + // explicit per-device state (including null clears), remove it, and update + // the APNs row with the newly presented metadata. + let canonicalDevice = apnsDevice ?? legacyDevice; + let duplicateDevice: typeof legacyDevice = null; + if ( + apnsDevice && + legacyDevice && + apnsDevice.id !== legacyDevice.id + ) { + duplicateDevice = legacyDevice; + await tx.device.deleteMany({ + where: { id: legacyDevice.id, userId: user.id }, + }); + canonicalDevice = apnsDevice; + } + + const deviceData = { + platform: "ios", + token, + bundleId, + locale: locale ?? null, + appVersion: appVersion ?? null, + model: model ?? null, + apnsToken: apnsToken ?? null, + apnsEnvironment: apnsEnvironment ?? null, + ...(medicationDelivery !== undefined + ? { medicationDelivery } + : duplicateDevice + ? { medicationDelivery: duplicateDevice.medicationDelivery } + : {}), + ...(liveActivityPushToken !== undefined + ? { liveActivityPushToken } + : duplicateDevice + ? { liveActivityPushToken: duplicateDevice.liveActivityPushToken } + : {}), + }; + + const device = canonicalDevice + ? await tx.device.update({ + where: { id: canonicalDevice.id, userId: user.id }, + data: { ...deviceData, lastSeen: new Date() }, + select: { id: true }, + }) + : await tx.device.create({ + data: { userId: user.id, ...deviceData }, + select: { id: true }, + }); + + return { id: device.id }; + }); + + if ("conflict" in registration) { + await auditLog("device.register.denied", { + userId: user.id, + details: { + reason: registration.conflict, + deviceId: registration.deviceId, + }, + }); + return registration.conflict === "apns_token_owned_by_other_user" + ? apiError("APNs token already registered to another account", 409) + : apiError("Device token already registered to another account", 409); } - const existing = await prisma.device.findUnique({ where: { token } }); - let id: string; - if (existing) { - // Cross-user-hijack guard: a device-token belongs to exactly one user. - // Re-registering the same token under a different account is rejected - // — APNs tokens aren't a secret, so trusting the wire input would let - // anyone who learns/guesses a token redirect another user's pushes. - if (existing.userId !== user.id) { - await auditLog("device.register.denied", { - userId: user.id, - details: { reason: "token_owned_by_other_user", deviceId: existing.id }, - }); - return apiError( - "Device token already registered to another account", - 409, - ); - } - const updated = await prisma.device.update({ - where: { token }, - data: { - platform: "ios", - bundleId, - locale: locale ?? null, - appVersion: appVersion ?? null, - model: model ?? null, - apnsToken: apnsToken ?? null, - apnsEnvironment: apnsEnvironment ?? null, - // v1.7.0 — only touch the override when the client sends the - // field, so a re-register that omits it keeps the prior value. - ...(medicationDelivery !== undefined && { medicationDelivery }), - // v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly - // clears the token when the Activity ends. - ...(liveActivityPushToken !== undefined && { liveActivityPushToken }), - lastSeen: new Date(), - }, - select: { id: true }, - }); - id = updated.id; - } else { - const created = await prisma.device.create({ - data: { - userId: user.id, - platform: "ios", - token, - bundleId, - locale: locale ?? null, - appVersion: appVersion ?? null, - model: model ?? null, - apnsToken: apnsToken ?? null, - apnsEnvironment: apnsEnvironment ?? null, - // v1.7.0 — per-device delivery override (null = inherit default). - ...(medicationDelivery !== undefined && { medicationDelivery }), - // v1.17.1 (#22) — Live Activity push token (null = no Activity). - ...(liveActivityPushToken !== undefined && { liveActivityPushToken }), - }, - select: { id: true }, - }); - id = created.id; - } + const { id } = registration; - // Auto-create the APNS NotificationChannel row when the device - // registers with an apnsToken. Without this row the dispatcher's APNS - // branch (`dispatcher.ts:41-49`) never fires for the user — every - // production iOS install would otherwise need a manual settings - // toggle that doesn't exist. Mirrors the legacy Telegram on-first- - // dispatch auto-migration, but eager so the very first reminder - // after device registration already routes through APNs. Config is - // an encrypted empty record by design — the per-device token + - // environment live on the Device row. + // Reconcile APNs channel state only after the canonical device transaction + // commits. Config is an encrypted empty record by design — the per-device + // token and environment live on the Device row. if (apnsToken) { await prisma.notificationChannel.upsert({ where: { userId_type: { userId: user.id, type: "APNS" } }, diff --git a/tests/integration/device-register.test.ts b/tests/integration/device-register.test.ts index ebe1d54d8..94381cf5a 100644 --- a/tests/integration/device-register.test.ts +++ b/tests/integration/device-register.test.ts @@ -10,12 +10,12 @@ * 2. Registering without an `apnsToken` does NOT create the channel — * the iOS client may upsert device metadata before APNs grants a * token, and we must not enable APNs delivery prematurely. - * 3. Registering a SECOND device with the same `apnsToken` for the - * same user is idempotent — the channel row stays at one. The - * same-user collision path returns 200 (upsert) rather than 409 - * (the cross-user hijack guard). + * 3. Re-registering the same `apnsToken` is idempotent, including when the + * legacy token changes: the route returns 201 and keeps one canonical + * device and one channel. Foreign ownership still returns 409. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -75,23 +75,26 @@ function buildRequest(body: Record) { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), - }) as unknown as import("next/server").NextRequest; + }) as unknown as NextRequest; +} + +async function registerDevice(body: Record) { + // Load after the test env and next/headers mock are installed. + const { POST } = await import("@/app/api/devices/route"); + return (POST as (request: NextRequest) => Promise)( + buildRequest(body), + ); } describe("POST /api/devices", () => { it("auto-upserts a NotificationChannel { type: APNS } when apnsToken is supplied", async () => { const user = await seedSession("device-apns-channel"); - const { POST } = await import("@/app/api/devices/route"); - - const req = buildRequest({ + const res = await registerDevice({ token: "legacy-token-1", bundleId: "io.healthlog.ios", apnsToken: "abcdef0123456789", apnsEnvironment: "sandbox", }); - const res = await ( - POST as (r: import("next/server").NextRequest) => Promise - )(req); expect(res.status).toBe(201); const channel = await getPrismaClient().notificationChannel.findFirst({ @@ -103,15 +106,10 @@ describe("POST /api/devices", () => { it("does NOT create an APNS channel when apnsToken is omitted", async () => { const user = await seedSession("device-no-apns"); - const { POST } = await import("@/app/api/devices/route"); - - const req = buildRequest({ + const res = await registerDevice({ token: "legacy-token-2", bundleId: "io.healthlog.ios", }); - const res = await ( - POST as (r: import("next/server").NextRequest) => Promise - )(req); expect(res.status).toBe(201); const channel = await getPrismaClient().notificationChannel.findFirst({ @@ -122,30 +120,20 @@ describe("POST /api/devices", () => { it("re-registering the same apnsToken for the same user keeps the channel row at one", async () => { const user = await seedSession("device-apns-idempotent"); - const { POST } = await import("@/app/api/devices/route"); - - const first = await ( - POST as (r: import("next/server").NextRequest) => Promise - )( - buildRequest({ - token: "legacy-token-3", - bundleId: "io.healthlog.ios", - apnsToken: "deadbeef", - apnsEnvironment: "sandbox", - }), - ); + const first = await registerDevice({ + token: "legacy-token-3", + bundleId: "io.healthlog.ios", + apnsToken: "deadbeef", + apnsEnvironment: "sandbox", + }); expect(first.status).toBe(201); - const second = await ( - POST as (r: import("next/server").NextRequest) => Promise - )( - buildRequest({ - token: "legacy-token-3", - bundleId: "io.healthlog.ios", - apnsToken: "deadbeef", - apnsEnvironment: "sandbox", - }), - ); + const second = await registerDevice({ + token: "legacy-token-3", + bundleId: "io.healthlog.ios", + apnsToken: "deadbeef", + apnsEnvironment: "sandbox", + }); expect(second.status).toBe(201); const channels = await getPrismaClient().notificationChannel.findMany({ @@ -153,4 +141,211 @@ describe("POST /api/devices", () => { }); expect(channels).toHaveLength(1); }); + + it("reconciles a changed legacy token onto the same user's APNs device", async () => { + const user = await seedSession("device-changed-token"); + const prisma = getPrismaClient(); + + const first = await registerDevice({ + token: "legacy-token-old", + bundleId: "io.healthlog.ios", + apnsToken: "aaaabbbbccccdddd", + apnsEnvironment: "sandbox", + appVersion: "1.0.0", + }); + expect(first.status).toBe(201); + const firstBody = (await first.json()) as { data: { id: string } }; + const refreshToken = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: "changed-token-refresh-hash", + deviceId: "legacy-token-old", + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + const second = await registerDevice({ + token: "legacy-token-new", + bundleId: "io.healthlog.ios.beta", + apnsToken: "aaaabbbbccccdddd", + apnsEnvironment: "production", + appVersion: "2.0.0", + }); + expect(second.status).toBe(201); + const secondBody = (await second.json()) as { data: { id: string } }; + + expect(secondBody.data.id).toBe(firstBody.data.id); + const devices = await prisma.device.findMany({ where: { userId: user.id } }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + id: firstBody.data.id, + token: "legacy-token-new", + bundleId: "io.healthlog.ios.beta", + apnsToken: "aaaabbbbccccdddd", + apnsEnvironment: "production", + appVersion: "2.0.0", + }); + await expect( + prisma.refreshToken.findUniqueOrThrow({ + where: { id: refreshToken.id }, + select: { deviceId: true }, + }), + ).resolves.toEqual({ deviceId: "legacy-token-new" }); + }); + + it("merges same-user device identities and migrates only owned refresh-token bindings", async () => { + const user = await seedSession("device-identity-merge"); + const prisma = getPrismaClient(); + const otherUser = await prisma.user.create({ + data: { + username: "device-identity-merge-other", + email: "device-identity-merge-other@example.test", + }, + }); + const apnsDevice = await prisma.device.create({ + data: { + userId: user.id, + platform: "ios", + token: "legacy-token-original", + bundleId: "io.healthlog.ios", + apnsToken: "1111222233334444", + apnsEnvironment: "sandbox", + medicationDelivery: "server", + liveActivityPushToken: "feedface".repeat(8), + }, + }); + const legacyDevice = await prisma.device.create({ + data: { + userId: user.id, + platform: "ios", + token: "legacy-token-current", + bundleId: "io.healthlog.ios", + medicationDelivery: null, + liveActivityPushToken: null, + }, + }); + const ownedRefreshToken = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: "owned-refresh-token-hash", + deviceId: "legacy-token-original", + expiresAt: new Date(Date.now() + 60_000), + }, + }); + const foreignRefreshToken = await prisma.refreshToken.create({ + data: { + userId: otherUser.id, + tokenHash: "foreign-refresh-token-hash", + deviceId: "legacy-token-original", + expiresAt: new Date(Date.now() + 60_000), + }, + }); + + const response = await registerDevice({ + token: "legacy-token-current", + bundleId: "io.healthlog.ios.updated", + apnsToken: "1111222233334444", + apnsEnvironment: "production", + model: "iPhone17,1", + }); + expect(response.status).toBe(201); + const body = (await response.json()) as { data: { id: string } }; + expect(body.data.id).toBe(apnsDevice.id); + + const devices = await prisma.device.findMany({ where: { userId: user.id } }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ + id: apnsDevice.id, + token: "legacy-token-current", + bundleId: "io.healthlog.ios.updated", + apnsToken: "1111222233334444", + apnsEnvironment: "production", + model: "iPhone17,1", + medicationDelivery: null, + liveActivityPushToken: null, + }); + await expect( + prisma.refreshToken.findUniqueOrThrow({ + where: { id: ownedRefreshToken.id }, + select: { deviceId: true }, + }), + ).resolves.toEqual({ deviceId: "legacy-token-current" }); + await expect( + prisma.refreshToken.findUniqueOrThrow({ + where: { id: foreignRefreshToken.id }, + select: { deviceId: true }, + }), + ).resolves.toEqual({ deviceId: "legacy-token-original" }); + }); + + it("rejects an APNs token owned by another user without mutating either account", async () => { + const user = await seedSession("device-apns-conflict"); + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "device-apns-owner", + email: "device-apns-owner@example.test", + }, + }); + const ownedDevice = await prisma.device.create({ + data: { + userId: owner.id, + platform: "ios", + token: "foreign-legacy-token", + bundleId: "io.healthlog.ios", + apnsToken: "9999aaaabbbbcccc", + apnsEnvironment: "sandbox", + }, + }); + + const response = await registerDevice({ + token: "requesting-user-token", + bundleId: "io.healthlog.ios.changed", + apnsToken: "9999aaaabbbbcccc", + apnsEnvironment: "production", + }); + expect(response.status).toBe(409); + + await expect( + prisma.device.findUniqueOrThrow({ where: { id: ownedDevice.id } }), + ).resolves.toMatchObject({ + userId: owner.id, + token: "foreign-legacy-token", + bundleId: "io.healthlog.ios", + apnsEnvironment: "sandbox", + }); + await expect( + prisma.device.count({ where: { userId: user.id } }), + ).resolves.toBe(0); + }); + + it("serializes concurrent registration into one canonical device and channel", async () => { + const user = await seedSession("device-concurrent-register"); + const payload = { + token: "concurrent-legacy-token", + bundleId: "io.healthlog.ios", + apnsToken: "abcdabcdabcdabcd", + apnsEnvironment: "sandbox", + }; + + const responses = await Promise.all([ + registerDevice(payload), + registerDevice(payload), + ]); + expect(responses.map((response) => response.status)).toEqual([201, 201]); + const bodies = (await Promise.all( + responses.map((response) => response.json()), + )) as Array<{ data: { id: string } }>; + expect(bodies[0].data.id).toBe(bodies[1].data.id); + + const prisma = getPrismaClient(); + await expect( + prisma.device.count({ where: { userId: user.id } }), + ).resolves.toBe(1); + await expect( + prisma.notificationChannel.count({ + where: { userId: user.id, type: "APNS" }, + }), + ).resolves.toBe(1); + }); }); From 5f4017d0391d249e54dc9a34e9ea75f1ac3574fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:37:23 +0200 Subject: [PATCH 08/35] fix(withings): queue ECG webhooks durably --- .../webhook/[token]/__tests__/route.test.ts | 4 + .../withings/webhook/__tests__/route.test.ts | 81 +++++++++++- .../__tests__/withings-ecg-worker.test.ts | 124 ++++++++++++++++++ .../reminder/register-integration-sync.ts | 14 ++ src/lib/jobs/reminder/withings-sync.ts | 35 +++++ src/lib/jobs/withings-ecg-queue.ts | 66 ++++++++++ src/lib/withings/__tests__/sync-ecg.test.ts | 88 +++++++++++++ src/lib/withings/sync-ecg.ts | 49 +++++-- src/lib/withings/webhook-handler.ts | 45 ++++--- 9 files changed, 477 insertions(+), 29 deletions(-) create mode 100644 src/lib/jobs/__tests__/withings-ecg-worker.test.ts create mode 100644 src/lib/jobs/withings-ecg-queue.ts diff --git a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts index 480f34946..96e2402a8 100644 --- a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts @@ -48,6 +48,7 @@ import { prisma } from "@/lib/db"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { checkRateLimit } from "@/lib/rate-limit"; import { getEvent } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; @@ -64,6 +65,9 @@ beforeEach(() => { addWarning: vi.fn(), } as never); vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never); + vi.mocked(getGlobalBoss).mockReturnValue({ + send: vi.fn().mockResolvedValue("ecg-job"), + } as never); }); afterEach(() => { diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index f3372c968..9c9efb37b 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -69,9 +69,9 @@ beforeEach(() => { addWarning: vi.fn(), } as never); vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never); - // Default: pg-boss not available → activity / sleep paths fall back - // to inline sync. Individual tests override with a fake boss. - vi.mocked(getGlobalBoss).mockReturnValue(null); + vi.mocked(getGlobalBoss).mockReturnValue({ + send: vi.fn().mockResolvedValue("ecg-job"), + } as never); vi.mocked(syncUserActivity).mockResolvedValue(0 as never); vi.mocked(syncUserSleep).mockResolvedValue(0 as never); }); @@ -301,7 +301,7 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { userId: "user-act-inline", withingsUserId: "wu-act-inline", } as never); - // Default beforeEach already sets `getGlobalBoss → null`. + vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( formRequest( @@ -319,6 +319,7 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { userId: "user-sleep-inline", withingsUserId: "wu-sleep-inline", } as never); + vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( formRequest( @@ -364,6 +365,78 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { expect(res.status).toBe(200); expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy"); }); + it("returns retryable non-2xx when the ECG event cannot be durably enqueued", async () => { + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ + userId: "user-ecg", + withingsUserId: "wu-ecg", + } as never); + const send = vi.fn().mockRejectedValue(new Error("queue unavailable")); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + + const res = await POST( + formRequest( + { + userid: "wu-ecg", + appli: "1", + startdate: "1715000000", + enddate: "1715000060", + }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + + expect(res.status).toBe(503); + expect(syncUserMeasurements).not.toHaveBeenCalled(); + }); + + it("uses one logical ECG job identity for a provider replay", async () => { + vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ + userId: "user-ecg", + withingsUserId: "wu-ecg", + } as never); + const send = vi + .fn() + .mockResolvedValueOnce("job-1") + .mockResolvedValueOnce(null); + vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); + const delivery = { + userid: "wu-ecg", + appli: "1", + startdate: "1715000000", + enddate: "1715000060", + }; + + const first = await POST( + formRequest(delivery, { + "x-withings-webhook-secret": "test-secret", + }), + ); + const replay = await POST( + formRequest(delivery, { + "x-withings-webhook-secret": "test-secret", + }), + ); + + expect(first.status).toBe(200); + expect(replay.status).toBe(200); + expect(send).toHaveBeenCalledTimes(2); + expect(send.mock.calls[0][0]).toBe("withings-ecg-sync"); + expect(send.mock.calls[1][0]).toBe("withings-ecg-sync"); + expect(send.mock.calls[0][1]).toMatchObject({ + userId: "user-ecg", + startdate: 1715000000, + enddate: 1715000060, + }); + expect(send.mock.calls[0][2]).toMatchObject({ + singletonKey: expect.any(String), + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + }); + expect(send.mock.calls[0][2].singletonKey).toBe( + send.mock.calls[1][2].singletonKey, + ); + }); }); describe("HEAD /api/withings/webhook (Withings URL verification)", () => { diff --git a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts new file mode 100644 index 000000000..c873d070d --- /dev/null +++ b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job } from "pg-boss"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +const { syncUserEcg } = vi.hoisted(() => ({ + syncUserEcg: vi.fn< + ( + userId: string, + options?: { startdate?: number; enddate?: number }, + ) => Promise + >(), +})); + +vi.mock("@/lib/withings/sync-ecg", () => ({ + syncUserEcg: ( + userId: string, + options?: { startdate?: number; enddate?: number }, + ) => syncUserEcg(userId, options), +})); +vi.mock("@/lib/withings/sync", () => ({ + syncUserMeasurements: vi.fn(async () => 0), +})); +vi.mock("@/lib/withings/sync-activity", () => ({ + syncUserActivity: vi.fn(async () => 0), +})); +vi.mock("@/lib/withings/sync-sleep", () => ({ + syncUserSleep: vi.fn(async () => 0), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn(async () => undefined), +})); +vi.mock("@/lib/jobs/worker-status", () => ({ + recordError: vi.fn(), + recordWithingsSync: vi.fn(), +})); +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (event: { + setBackground: (value: unknown) => void; + setError: (error: unknown) => void; + addWarning: (warning: string) => void; + }) => Promise, + ) => + fn({ + setBackground: vi.fn(), + setError: vi.fn(), + addWarning: vi.fn(), + }), +})); +vi.mock("@/lib/logging/fire-and-forget", () => ({ + fireAndForget: vi.fn(), +})); +vi.mock("../reminder/shared", () => ({ + getWorkerPrisma: vi.fn(() => ({ + withingsConnection: { findMany: vi.fn(async () => []) }, + })), +})); + +import { + handleWithingsEcgSync, + type WithingsEcgSyncPayload, +} from "../reminder/withings-sync"; + +function job(data: WithingsEcgSyncPayload): Job { + return { data } as Job; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); +const registrarSource = readFileSync( + join( + process.cwd(), + "src/lib/jobs/reminder/register-integration-sync.ts", + ), + "utf8", +); + +describe("handleWithingsEcgSync", () => { + it("rejects a failed source write so pg-boss can retry the same job", async () => { + syncUserEcg + .mockRejectedValueOnce(new Error("source write failed")) + .mockResolvedValueOnce(1); + const queued = job({ + userId: "user-1", + eventId: "wu-1:1:1715000000:1715000060", + triggeredAt: "2026-07-20T12:00:00.000Z", + startdate: 1715000000, + enddate: 1715000060, + }); + + await expect(handleWithingsEcgSync([queued])).rejects.toThrow( + "source write failed", + ); + await expect(handleWithingsEcgSync([queued])).resolves.toBeUndefined(); + + expect(syncUserEcg).toHaveBeenCalledTimes(2); + expect(syncUserEcg).toHaveBeenNthCalledWith(1, "user-1", { + startdate: 1715000000, + enddate: 1715000060, + }); + expect(syncUserEcg).toHaveBeenNthCalledWith(2, "user-1", { + startdate: 1715000000, + enddate: 1715000060, + }); + }); +}); + +describe("withings ECG queue registration", () => { + it("deduplicates only queued replays and admits a rescue after work starts", () => { + const allQueues = registrarSource.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); + expect(allQueues).not.toBeNull(); + expect(allQueues![1]).toMatch(/\bWITHINGS_ECG_SYNC_QUEUE\b/); + expect(registrarSource).toMatch( + /\[WITHINGS_ECG_SYNC_QUEUE\]:\s*\{[\s\S]*?policy:\s*"short"/, + ); + expect(registrarSource).toMatch( + /boss\.work\([\s\S]*?WITHINGS_ECG_SYNC_QUEUE[\s\S]*?handleWithingsEcgSync/, + ); + }); +}); diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts index 1d3256e17..988f4c040 100644 --- a/src/lib/jobs/reminder/register-integration-sync.ts +++ b/src/lib/jobs/reminder/register-integration-sync.ts @@ -69,13 +69,16 @@ import { type QueuePolicyTable, type ScheduleEntry, } from "./registrar-shared"; +import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue"; import { WithingsSyncPayload, WithingsActivitySyncPayload, WithingsSleepSyncPayload, + WithingsEcgSyncPayload, handleWithingsFallbackSync, handleWithingsActivitySync, handleWithingsSleepSync, + handleWithingsEcgSync, } from "./withings-sync"; import { WhoopSyncPayload, @@ -242,6 +245,7 @@ const allQueues = [ WITHINGS_SYNC_QUEUE, WITHINGS_ACTIVITY_QUEUE, WITHINGS_SLEEP_QUEUE, + WITHINGS_ECG_SYNC_QUEUE, MOODLOG_SYNC_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, // v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for @@ -349,6 +353,11 @@ const schedules: ScheduleEntry[] = [ * the work is still outstanding, a suppressed send can never lose work. */ const queuePolicies: QueuePolicyTable = { + [WITHINGS_ECG_SYNC_QUEUE]: { + policy: "short", + reason: + "Webhook retries share one identity while queued; once work starts, a later replay remains admissible as a rescue if the original exhausts retries.", + }, [WHOOP_BACKFILL_QUEUE]: { policy: "exclusive", reason: @@ -410,6 +419,11 @@ export async function registerIntegrationSyncQueues( { localConcurrency: 1 }, handleWithingsSleepSync, ); + await boss.work( + WITHINGS_ECG_SYNC_QUEUE, + { localConcurrency: 1 }, + handleWithingsEcgSync, + ); await boss.work( WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 24320b06d..f42305b0b 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -11,8 +11,12 @@ import { withBackgroundEvent } from "@/lib/logging/background"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; import { syncUserSleep } from "@/lib/withings/sync-sleep"; +import { syncUserEcg } from "@/lib/withings/sync-ecg"; +import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; +export type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; + export interface WithingsSyncPayload { triggeredAt: string; @@ -96,6 +100,37 @@ export async function handleWithingsFallbackSync( }); } +/** + * Drain webhook-owned ECG events. A failed source read or write is allowed to + * reject the handler so pg-boss retains the job and applies its retry policy. + */ +export async function handleWithingsEcgSync( + jobs: Job[], +) { + await withBackgroundEvent("job.withings_ecg_sync", async (evt) => { + try { + let recordingsImported = 0; + for (const job of jobs) { + recordingsImported += await syncUserEcg(job.data.userId, { + startdate: job.data.startdate, + enddate: job.data.enddate, + }); + } + evt.setBackground({ + task_name: "job.withings_ecg_sync", + result: { + events_processed: jobs.length, + recordings_imported: recordingsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} + /** * v1.4.25 W17b — activity-sync handler. * diff --git a/src/lib/jobs/withings-ecg-queue.ts b/src/lib/jobs/withings-ecg-queue.ts new file mode 100644 index 000000000..9026d1b20 --- /dev/null +++ b/src/lib/jobs/withings-ecg-queue.ts @@ -0,0 +1,66 @@ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { z } from "zod/v4"; + +const unixSecondsSchema = z.coerce.number().int().nonnegative().safe(); + +export const WITHINGS_ECG_SYNC_QUEUE = "withings-ecg-sync"; + +export interface WithingsEcgSyncPayload { + userId: string; + eventId: string; + triggeredAt: string; + startdate?: number; + enddate?: number; +} + +export interface WithingsEcgEventIdentity { + userId: string; + withingsUserId: string; + appli: number | null; + startdate: string | null; + enddate: string | null; +} + +export async function enqueueWithingsEcgSync( + identity: WithingsEcgEventIdentity, +): Promise<"enqueued" | "known"> { + const boss = getGlobalBoss(); + if (!boss) { + throw new Error("pg-boss producer is unavailable"); + } + + const eventId = [ + identity.withingsUserId, + identity.appli ?? "unknown", + identity.startdate ?? "unknown", + identity.enddate ?? "unknown", + ].join(":"); + const parsedStartdate = + identity.startdate === null + ? null + : unixSecondsSchema.safeParse(identity.startdate); + const parsedEnddate = + identity.enddate === null + ? null + : unixSecondsSchema.safeParse(identity.enddate); + const jobId = await boss.send( + WITHINGS_ECG_SYNC_QUEUE, + { + userId: identity.userId, + eventId, + triggeredAt: new Date().toISOString(), + ...(parsedStartdate?.success + ? { startdate: parsedStartdate.data } + : {}), + ...(parsedEnddate?.success ? { enddate: parsedEnddate.data } : {}), + } satisfies WithingsEcgSyncPayload, + { + singletonKey: `withings-ecg:${identity.userId}:${eventId}`, + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + }, + ); + + return jobId ? "enqueued" : "known"; +} diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts index ba4d306af..92317bd21 100644 --- a/src/lib/withings/__tests__/sync-ecg.test.ts +++ b/src/lib/withings/__tests__/sync-ecg.test.ts @@ -16,6 +16,9 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { upsert: vi.fn(), }, + withingsConnection: { + findUnique: vi.fn(), + }, }, })); @@ -68,6 +71,8 @@ import { recordSyncFailure, recordSyncSuccess, } from "@/lib/integrations/status"; +import { getValidToken, recordWithingsSyncFailure } from "../sync"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { fetchWithingsHeartList, @@ -112,6 +117,12 @@ function installFetchMock( beforeEach(() => { vi.resetAllMocks(); vi.mocked(isReauthRequired).mockResolvedValue(false); + vi.mocked(getValidToken).mockResolvedValue({ + accessToken: "token", + } as never); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ + id: "connection-1", + } as never); vi.mocked(prisma.measurement.upsert).mockResolvedValue({ id: "m-1", } as never); @@ -120,6 +131,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe("mapWithingsAfib", () => { @@ -207,6 +219,30 @@ describe("syncUserEcg", () => { expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings"); }); + it("never narrows the broad catch-up to an ordinary webhook window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-20T12:00:00.000Z")); + const fetchMock = installFetchMock([]); + const recentStart = Math.floor( + new Date("2026-07-19T12:00:00.000Z").getTime() / 1000, + ); + const recentEnd = Math.floor( + new Date("2026-07-20T12:00:00.000Z").getTime() / 1000, + ); + + await syncUserEcg("user-1", { + startdate: recentStart, + enddate: recentEnd, + }); + + const init = fetchMock.mock.calls[0][1] as { body: string }; + const broadStart = Math.floor( + new Date("2026-06-20T12:00:00.000Z").getTime() / 1000, + ); + expect(init.body).toContain(`startdate=${broadStart}`); + expect(init.body).toContain(`enddate=${recentEnd}`); + }); + it("skips a heart entry that carries no ECG / afib verdict", async () => { installFetchMock([ { timestamp: 1715000000, ecg: null }, @@ -321,6 +357,58 @@ describe("syncUserEcg", () => { expect(keys[0]).toEqual(keys[1]); }); + it("retries token acquisition failures while the owned connection still exists", async () => { + installFetchMock([]); + vi.mocked(getValidToken).mockResolvedValue(null); + + await expect(syncUserEcg("user-1")).rejects.toThrow( + "Withings ECG token unavailable", + ); + expect(prisma.withingsConnection.findUnique).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + select: { id: true }, + }); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); + + it("does not mark the source complete when an ECG event write fails", async () => { + installFetchMock([ + { timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } }, + ]); + vi.mocked(prisma.measurement.upsert).mockRejectedValue( + new Error("source write failed"), + ); + + await expect(syncUserEcg("user-1")).rejects.toThrow("source write failed"); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + expect(recordWithingsSyncFailure).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ message: "source write failed" }), + ); + }); + + it("continues later ECG rows before retrying a failed source write", async () => { + installFetchMock([ + { timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } }, + { timestamp: 1715003600, ecg: { signalid: 222, afib: 0 } }, + ]); + const poison = new Error("poison row"); + vi.mocked(prisma.measurement.upsert) + .mockRejectedValueOnce(poison) + .mockResolvedValueOnce({ id: "m-2" } as never); + + await expect(syncUserEcg("user-1")).rejects.toThrow("poison row"); + + expect(prisma.measurement.upsert).toHaveBeenCalledTimes(2); + expect(recomputeBucketsForMeasurement).toHaveBeenCalledWith( + "user-1", + "IRREGULAR_RHYTHM_NOTIFICATION", + new Date("2024-05-06T00:00:00.000Z"), + ); + expect(recordWithingsSyncFailure).toHaveBeenCalledWith("user-1", poison); + expect(recordSyncSuccess).not.toHaveBeenCalled(); + }); + it("still captures the AFib verdict when the waveform fetch fails", async () => { // List returns an entry, but the signal GET returns a hard-fail body. const fetchMock = vi.fn(async (_url: string, init: { body: string }) => { diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 636d73f1c..61cfb92ec 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -340,14 +340,19 @@ async function captureEcgWaveform(params: { } /** - * Sync ECG / AFib recordings for a single user. Walks the trailing 30-day - * window, writing one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording - * tagged with the device's AFib verdict. Returns the number of upserted rows. + * Sync ECG / AFib recordings for a single user. Always walks at least the + * trailing 30 days and widens that catch-up to include an older webhook source + * window when supplied. Writes + * one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording tagged with the + * device's AFib verdict. Returns the number of upserted rows. * * Park behaviour mirrors the other Withings syncs — a connection at * `error_reauth` short-circuits before the upstream call. */ -export async function syncUserEcg(userId: string): Promise { +export async function syncUserEcg( + userId: string, + options: { startdate?: number; enddate?: number } = {}, +): Promise { if (await isReauthRequired(userId, "withings")) { getEvent()?.addWarning( `withings ecg sync skipped for ${userId}: parked at error_reauth`, @@ -356,14 +361,32 @@ export async function syncUserEcg(userId: string): Promise { } const tokenInfo = await getValidToken(userId); - if (!tokenInfo) return 0; + if (!tokenInfo) { + if (await isReauthRequired(userId, "withings")) return 0; + const connection = await prisma.withingsConnection.findUnique({ + where: { userId }, + select: { id: true }, + }); + if (!connection) return 0; + throw new Error("Withings ECG token unavailable"); + } const now = new Date(); - const start = new Date( - now.getTime() - ECG_BACKFILL_DAYS * 24 * 60 * 60 * 1000, + const defaultStartUnix = Math.floor( + (now.getTime() - ECG_BACKFILL_DAYS * 24 * 60 * 60 * 1000) / 1000, ); - const startUnix = Math.floor(start.getTime() / 1000); - const endUnix = Math.floor(now.getTime() / 1000); + const defaultEndUnix = Math.floor(now.getTime() / 1000); + const hasProviderWindow = + Number.isSafeInteger(options.startdate) && + Number.isSafeInteger(options.enddate) && + options.startdate! >= 0 && + options.startdate! <= options.enddate!; + const startUnix = hasProviderWindow + ? Math.min(defaultStartUnix, options.startdate!) + : defaultStartUnix; + const endUnix = hasProviderWindow + ? Math.max(defaultEndUnix, options.enddate!) + : defaultEndUnix; let entries: WithingsHeartEntry[]; try { @@ -395,6 +418,7 @@ export async function syncUserEcg(userId: string): Promise { let imported = 0; let waveformsCaptured = 0; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; + let sourceWriteError: { cause: unknown } | null = null; for (const entry of entries) { const classification = mapWithingsAfib(entry.ecg?.afib); @@ -444,6 +468,7 @@ export async function syncUserEcg(userId: string): Promise { getEvent()?.addWarning( `withings ecg: failed to upsert recording (${classification}, ${measuredAt.toISOString()}): ${err}`, ); + sourceWriteError ??= { cause: err }; continue; } @@ -471,6 +496,7 @@ export async function syncUserEcg(userId: string): Promise { } } + annotate({ action: { name: "withings.ecg.sync" }, meta: { @@ -500,6 +526,11 @@ export async function syncUserEcg(userId: string): Promise { ); } + if (sourceWriteError) { + await recordWithingsSyncFailure(userId, sourceWriteError.cause); + throw sourceWriteError.cause; + } + await recordSyncSuccess(userId, "withings"); return imported; } diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index 31770e9d9..136f7c0a1 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -30,6 +30,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getClientIp, safeJson } from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueWithingsEcgSync } from "@/lib/jobs/withings-ecg-queue"; /** * v1.4.25 W17b/c — appli categories that have their own sync routine @@ -94,6 +95,8 @@ export async function processWithingsNotification( const contentType = request.headers.get("content-type") ?? ""; let withingsUserId: string | null = null; let appli: number | null = null; + let startdate: string | null = null; + let enddate: string | null = null; if (contentType.includes("application/x-www-form-urlencoded")) { const formData = await request.formData(); @@ -103,10 +106,16 @@ export async function processWithingsNotification( const parsed = Number.parseInt(String(rawAppli), 10); if (Number.isFinite(parsed)) appli = parsed; } + const rawStartdate = formData.get("startdate"); + const rawEnddate = formData.get("enddate"); + startdate = rawStartdate == null ? null : String(rawStartdate); + enddate = rawEnddate == null ? null : String(rawEnddate); } else { const { data: body, error: jsonError } = await safeJson<{ userid?: string | number; appli?: string | number; + startdate?: string | number; + enddate?: string | number; }>(request); if (jsonError) return jsonError; withingsUserId = body.userid?.toString() ?? null; @@ -114,6 +123,8 @@ export async function processWithingsNotification( const parsed = Number.parseInt(String(body.appli), 10); if (Number.isFinite(parsed)) appli = parsed; } + startdate = body.startdate?.toString() ?? null; + enddate = body.enddate?.toString() ?? null; } if (!withingsUserId) { @@ -150,27 +161,29 @@ export async function processWithingsNotification( } else if (appli === WITHINGS_SLEEP_APPLI) { await enqueueWithingsSync(WITHINGS_SLEEP_QUEUE, connection.userId); } else { + try { + await enqueueWithingsEcgSync({ + userId: connection.userId, + withingsUserId, + appli, + startdate, + enddate, + }); + } catch (err) { + getEvent()?.addWarning( + `Failed to durably enqueue Withings ECG event for ${connection.userId}: ${err}`, + ); + return NextResponse.json( + { status: "unavailable" }, + { status: 503, headers: { "retry-after": "60" } }, + ); + } + syncUserMeasurements(connection.userId).catch((err) => { getEvent()?.addWarning( "Sync failed for user " + connection.userId + ": " + err, ); }); - // v1.18.11 — ECG / AFib capture rides the measure path. The Heart List - // endpoint shares the `user.metrics` scope with the measure family, so a - // measure-family notification (which is what a ScanWatch ECG recording - // delivers) is the right trigger. Fire it non-blocking and lazy-loaded so - // the webhook response stays fast and the cold path doesn't pull the ECG - // module in until a measure notification actually lands. - void (async () => { - try { - const { syncUserEcg } = await import("./sync-ecg"); - await syncUserEcg(connection.userId); - } catch (err) { - getEvent()?.addWarning( - "ECG sync failed for user " + connection.userId + ": " + err, - ); - } - })(); } return NextResponse.json({ status: "ok" }, { status: 200 }); From f8b0a64d691c765dfbdcacf98c9e30aac803eadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:44:29 +0200 Subject: [PATCH 09/35] fix(backups): preserve canonical recovery integrity --- .../api/admin/backups/[id]/restore/route.ts | 231 +++++++++-------- .../__tests__/full-backup-payload.test.ts | 137 ++++++++++ src/lib/export/full-backup-payload.ts | 40 ++- src/lib/jobs/__tests__/offhost-backup.test.ts | 150 +++++++---- src/lib/jobs/offhost-backup.ts | 150 +---------- .../__tests__/backup-handlers.test.ts | 97 ++----- src/lib/jobs/reminder/backup-handlers.ts | 148 +---------- src/lib/validations/__tests__/backup.test.ts | 132 ++++++++++ src/lib/validations/backup.ts | 81 ++++-- .../admin-backups-canonical-roundtrip.test.ts | 238 ++++++++++++++---- .../integration/admin-backups-upload.test.ts | 64 ++++- 11 files changed, 853 insertions(+), 615 deletions(-) create mode 100644 src/lib/export/__tests__/full-backup-payload.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 68772b223..86876f8e5 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -27,6 +27,7 @@ import { defaultUserIdResolver, withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { parseBackupPayload, + isCompatibleSchemaVersion, summarizeBackup, type BackupSummary, } from "@/lib/validations/backup"; @@ -72,43 +73,6 @@ function decodeEncryptedBytes(encoded: string): Uint8Array { return bytes; } -/** - * Maps a measurement-type string from the backup back into the - * Prisma enum literal. Backups are written from `enum MeasurementType` - * (Postgres-mapped to lowercase column type), so the round-trip is - * normally identity. But the schema is a `passthrough` schema so a - * malformed type slips through Zod — guard it explicitly here so the - * restore fails fast instead of erroring deep in `prisma.create()`. - */ -const MEASUREMENT_TYPES = new Set([ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "SLEEP_DURATION", - "ACTIVITY_STEPS", - "BLOOD_GLUCOSE", - "TOTAL_BODY_WATER", - "BONE_MASS", - "OXYGEN_SATURATION", - // ── v1.4.23 Apple Health additions ── - "HEART_RATE_VARIABILITY", - "RESTING_HEART_RATE", - "ACTIVE_ENERGY_BURNED", - "FLIGHTS_CLIMBED", - "WALKING_RUNNING_DISTANCE", - "VO2_MAX", - "BODY_TEMPERATURE", -]); - -const MEASUREMENT_SOURCES = new Set([ - "MANUAL", - "WITHINGS", - "IMPORT", - "APPLE_HEALTH", -]); -const INTAKE_SOURCES = new Set(["WEB", "API", "REMINDER", "IMPORT"]); const handler = apiHandler( async ( @@ -185,7 +149,24 @@ const handler = apiHandler( message: err instanceof Error ? err.message : String(err), }, }); - return apiError("Backup payload failed schema validation", 500); + return apiError("Backup payload failed schema validation", 422); + } + + if (!isCompatibleSchemaVersion(payload.schemaVersion)) { + await auditLog("admin.backups.restore.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId: backup.userId, + reason: "incompatible_schema_version", + schemaVersion: payload.schemaVersion, + }, + }); + return apiError( + `Backup schema version '${payload.schemaVersion}' is not supported by this server`, + 422, + ); } // The payload declares its own owner, and the backup ROW records who the @@ -250,55 +231,6 @@ const handler = apiHandler( }, }); - // Pre-validate enum values OUTSIDE the transaction so a malformed - // payload doesn't half-wipe the user before failing the create - // step. Cheap up-front guard. - for (const m of payload.measurements) { - if (!MEASUREMENT_TYPES.has(m.type)) { - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "unknown_measurement_type", - type: m.type, - }, - }); - return apiError(`Unknown measurement type in backup: '${m.type}'`, 422); - } - if (m.source && !MEASUREMENT_SOURCES.has(m.source)) { - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "unknown_measurement_source", - source: m.source, - }, - }); - return apiError( - `Unknown measurement source in backup: '${m.source}'`, - 422, - ); - } - } - for (const e of payload.intakeEvents) { - if (e.source && !INTAKE_SOURCES.has(e.source)) { - await auditLog("admin.backups.restore.failed", { - userId: admin.id, - ipAddress: getClientIp(request), - details: { - backupId: id, - ownerId, - reason: "unknown_intake_source", - source: e.source, - }, - }); - return apiError(`Unknown intake source in backup: '${e.source}'`, 422); - } - } // Portable exports intentionally omit document ciphertext. They remain // valid upload/download artifacts, but cannot be used to manufacture an @@ -398,27 +330,56 @@ const handler = apiHandler( where: { userId: ownerId }, }); - // ── re-create from payload ── - if (payload.measurements.length > 0) { - await tx.measurement.createMany({ - data: payload.measurements.map((m) => ({ + // Stable ids are authoritative for canonical snapshots. Legacy payloads + // without ids fall back to the historic natural measurement key. + for (const measurement of payload.measurements) { + const measuredAt = new Date(measurement.measuredAt); + const source = measurement.source ?? "MANUAL"; + const restoredData = { + type: measurement.type, + value: measurement.value, + unit: measurement.unit, + source, + measuredAt, + notes: null, + notesEncrypted: encryptNote(measurement.notes ?? null), + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, + }; + + if (measurement.id) { + await tx.measurement.upsert({ + where: { id: measurement.id, userId: ownerId }, + create: { + id: measurement.id, + userId: ownerId, + ...restoredData, + }, + update: restoredData, + }); + continue; + } + + const existing = await tx.measurement.findFirst({ + where: { userId: ownerId, - type: m.type as never, // already enum-validated above - value: m.value, - unit: m.unit, - source: (m.source ?? "MANUAL") as never, - measuredAt: new Date(m.measuredAt), - // v1.23 — re-encrypt the note from the (decrypted) backup payload - // into the at-rest column; legacy plaintext column nulled. - notes: null, - notesEncrypted: encryptNote(m.notes ?? null), - })), - // Backups can replay the same data — the unique - // (userId, type, measuredAt, source) constraint catches - // accidental dupes. `skipDuplicates` keeps the restore - // idempotent across retries instead of throwing. - skipDuplicates: true, + type: measurement.type, + source, + measuredAt, + }, + select: { id: true }, }); + if (existing) { + await tx.measurement.update({ + where: { id: existing.id, userId: ownerId }, + data: restoredData, + }); + } else { + await tx.measurement.create({ + data: { userId: ownerId, ...restoredData }, + }); + } } // Medications + schedules — restore one at a time so we can @@ -501,23 +462,55 @@ const handler = apiHandler( } for (const entry of payload.moodEntries) { - const created = await tx.moodEntry.create({ - data: { - ...(entry.id ? { id: entry.id } : {}), - userId: ownerId, - date: entry.date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ?? null, - source: entry.source ?? "MOODLOG", - externalId: entry.externalId ?? null, - moodLoggedAt: new Date(entry.loggedAt), - }, + const moodLoggedAt = new Date(entry.loggedAt); + const restoredData = { + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ?? null, + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt, + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + const createData = { userId: ownerId, ...restoredData }; + const restored = entry.id + ? await tx.moodEntry.upsert({ + where: { id: entry.id, userId: ownerId }, + create: { id: entry.id, ...createData }, + update: restoredData, + }) + : entry.externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId: ownerId, + source: restoredData.source, + externalId: entry.externalId, + }, + }, + create: createData, + update: restoredData, + }) + : await tx.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: ownerId, + date: entry.date, + moodLoggedAt, + }, + }, + create: createData, + update: restoredData, + }); + + await tx.moodEntryTagLink.deleteMany({ + where: { moodEntryId: restored.id }, }); if (entry.factors.length > 0) { await tx.moodEntryTagLink.createMany({ data: entry.factors.map((factor) => ({ - moodEntryId: created.id, + moodEntryId: restored.id, moodTagId: factorByKey.get(factor.key)!, rating: factor.rating, })), diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts new file mode 100644 index 000000000..29b327804 --- /dev/null +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMeasurementsPaged: vi.fn(), + buildCycleBackupSection: vi.fn(), + buildRecordsBackupSection: vi.fn(), +})); + +vi.mock("@/lib/export/paged-measurements", () => ({ + findMeasurementsPaged: mocks.findMeasurementsPaged, +})); +vi.mock("@/lib/cycle/backup", () => ({ + buildCycleBackupSection: mocks.buildCycleBackupSection, +})); +vi.mock("@/lib/export/records-backup", () => ({ + buildRecordsBackupSection: mocks.buildRecordsBackupSection, + countRecordsBackupSection: vi.fn(() => ({ + labResults: 0, + biomarkers: 0, + illnessEpisodes: 0, + illnessDayLogs: 0, + allergies: 0, + familyHistory: 0, + workouts: 0, + documents: 0, + })), +})); + +import { buildFullBackupPayload } from "../full-backup-payload"; + +const deletedAt = new Date("2026-07-19T12:00:00.000Z"); + +function makePrisma() { + return { + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { + findMany: vi.fn().mockResolvedValue([ + { + id: "mood-tombstone", + date: "2026-07-19", + mood: "OKAY", + score: 3, + tags: null, + source: "MOODLOG", + externalId: "moodlog-42", + moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), + deletedAt, + tagLinks: [ + { rating: 4, moodTag: { key: "sleep_quality" } }, + ], + }, + ]), + }, + }; +} + +function installSectionMocks() { + mocks.findMeasurementsPaged.mockResolvedValue([ + { + id: "measurement-tombstone", + type: "WEIGHT", + value: 75, + unit: "kg", + measuredAt: new Date("2026-07-19T07:00:00.000Z"), + source: "MANUAL", + notes: null, + notesEncrypted: null, + deletedAt, + }, + ]); + mocks.buildCycleBackupSection.mockResolvedValue({ + cycleProfile: null, + cycles: [], + cycleDayLogs: [], + }); + mocks.buildRecordsBackupSection.mockResolvedValue({ + labResults: [], + biomarkers: [], + illnessEpisodes: [], + allergies: [], + familyHistory: [], + workouts: [], + documents: [], + manifest: { + documents: { included: "encrypted-content", note: "included" }, + workouts: { included: "summary-only", note: "included" }, + }, + }); +} + +describe("buildFullBackupPayload disaster-recovery mode", () => { + it("preserves measurement and mood tombstones with stable ids", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload } = await buildFullBackupPayload(prisma as never, "user-1", { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }); + + expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith( + prisma, + { userId: "user-1" }, + expect.objectContaining({ id: true, deletedAt: true }), + ); + expect(prisma.moodEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1" }, + include: expect.any(Object), + }), + ); + expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( + prisma, + "user-1", + { purpose: "disaster-recovery" }, + ); + expect(payload).toMatchObject({ + schemaVersion: "1", + exportedAt: "2026-07-20T00:00:00.000Z", + measurements: [ + { + id: "measurement-tombstone", + deletedAt: deletedAt.toISOString(), + }, + ], + moodEntries: [ + { + id: "mood-tombstone", + externalId: "moodlog-42", + deletedAt: deletedAt.toISOString(), + factors: [{ key: "sleep_quality", rating: 4 }], + }, + ], + }); + }); +}); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 85229e03c..211cdbf17 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -35,14 +35,21 @@ export interface FullBackupResult { counts: FullBackupCounts; } +export interface FullBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; + exportedAt?: Date; +} /** - * Build the canonical full-backup payload for `userId`. Soft-deleted rows are - * excluded so a round-trip via admin restore never resurrects deleted records. + * Build the canonical full-backup payload for `userId`. Portable exports omit + * tombstones and document ciphertext; disaster-recovery payloads preserve + * both so weekly and off-host snapshots share one restorable wire format. */ export async function buildFullBackupPayload( prisma: PrismaClient, userId: string, + options: FullBackupOptions = {}, ): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; const [measurements, medications, intakeEvents, moodEntries, cycle, records] = await Promise.all([ // v1.28.25 — keyset-paginated read with a narrow select. The backup @@ -55,7 +62,7 @@ export async function buildFullBackupPayload( // note columns `readNote` decrypts + the `id` cursor key. findMeasurementsPaged( prisma, - { userId, deletedAt: null }, + disasterRecovery ? { userId } : { userId, deletedAt: null }, { id: true, type: true, @@ -65,6 +72,7 @@ export async function buildFullBackupPayload( source: true, notes: true, notesEncrypted: true, + deletedAt: true, }, ), prisma.medication.findMany({ @@ -89,24 +97,37 @@ export async function buildFullBackupPayload( orderBy: { scheduledFor: "desc" }, }), prisma.moodEntry.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { moodLoggedAt: "desc" }, + include: { + tagLinks: { + where: { rating: { not: null } }, + select: { + rating: true, + moodTag: { select: { key: true } }, + }, + }, + }, }), buildCycleBackupSection(prisma, userId), - buildRecordsBackupSection(prisma, userId), + buildRecordsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), ]); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, - exportedAt: new Date().toISOString(), + exportedAt: (options.exportedAt ?? new Date()).toISOString(), userId, measurements: measurements.map((m) => ({ + id: m.id, type: m.type, value: m.value, unit: m.unit, measuredAt: m.measuredAt.toISOString(), source: m.source, notes: readNote(m.notesEncrypted, m.notes), + deletedAt: m.deletedAt?.toISOString() ?? null, })), medications: medications.map((m) => ({ name: m.name, @@ -127,12 +148,19 @@ export async function buildFullBackupPayload( source: e.source, })), moodEntries: moodEntries.map((e) => ({ + id: e.id, date: e.date, mood: e.mood, score: e.score, tags: e.tags, source: e.source, loggedAt: e.moodLoggedAt.toISOString(), + externalId: e.externalId, + deletedAt: e.deletedAt?.toISOString() ?? null, + factors: e.tagLinks.map((link) => ({ + key: link.moodTag.key, + rating: link.rating!, + })), })), cycleProfile: cycle.cycleProfile, cycles: cycle.cycles, diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 307617c02..6918cd0ca 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -1,4 +1,16 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + BACKUP_SCHEMA_VERSION, + backupPayloadSchema, +} from "@/lib/validations/backup"; + +const mocks = vi.hoisted(() => ({ + buildFullBackupPayload: vi.fn(), +})); + +vi.mock("@/lib/export/full-backup-payload", () => ({ + buildFullBackupPayload: mocks.buildFullBackupPayload, +})); import { encryptBackup, decryptBackup, @@ -6,7 +18,6 @@ import { runOffhostBackup, runOffhostRoundtripTest, } from "../offhost-backup"; -import { MEASUREMENT_BACKUP_PAGE_SIZE } from "../backup/measurement-page"; const ENC_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -89,12 +100,36 @@ function makeS3Mock() { describe("runOffhostBackup", () => { beforeEach(() => { vi.unstubAllEnvs(); + vi.clearAllMocks(); vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA"); vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret"); vi.stubEnv("BACKUP_S3_REGION", "auto"); vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY); + mocks.buildFullBackupPayload.mockImplementation( + async (_prisma: unknown, userId: string) => ({ + payload: { + schemaVersion: BACKUP_SCHEMA_VERSION, + exportedAt: "2026-05-08T03:00:00.000Z", + userId, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [ + { + id: `mood-${userId}`, + date: "2026-05-08", + mood: "GUT", + score: 4, + loggedAt: "2026-05-08T20:00:00.000Z", + factors: [], + }, + ], + }, + counts: {}, + }), + ); }); it("uploads one object per user and never deletes existing ones", async () => { @@ -137,56 +172,66 @@ describe("runOffhostBackup", () => { const decoded = decryptBackup(ct, Buffer.from(ENC_KEY, "hex")); const parsed = JSON.parse(decoded); expect(parsed.userId).toBe("u1"); + expect(() => backupPayloadSchema.parse(parsed)).not.toThrow(); + expect(parsed.schemaVersion).toBe(BACKUP_SCHEMA_VERSION); + expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(prisma, "u1", { + purpose: "disaster-recovery", + exportedAt: now, + }); }); - it("keyset-pages the intake/mood/cycle tables across multiple pages", async () => { + it("uploads the canonical builder output without reshaping it", async () => { const s3 = makeS3Mock(); - // A full first page (=== page size) followed by a short page proves the - // reader walks the id cursor instead of one unbounded findMany. - const fullPage = Array.from( - { length: MEASUREMENT_BACKUP_PAGE_SIZE }, - (_, i) => ({ - id: `e${String(i).padStart(5, "0")}`, - userId: "u1", - takenAt: new Date("2026-05-01T00:00:00Z"), - }), - ); - const tailPage = [ - { id: "z9999", userId: "u1", takenAt: new Date("2026-05-02T00:00:00Z") }, - ]; - const intakeFindMany = vi.fn( - async (args: { where?: { id?: { gt?: string } } }) => - args?.where?.id?.gt ? tailPage : fullPage, - ); - const prisma = { user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, - measurement: { findMany: vi.fn().mockResolvedValue([]) }, - medication: { findMany: vi.fn().mockResolvedValue([]) }, - medicationIntakeEvent: { findMany: intakeFindMany }, - moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, - cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, - menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, - cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }; + const canonicalPayload = { + schemaVersion: BACKUP_SCHEMA_VERSION, + exportedAt: "2026-05-08T00:00:00.000Z", + userId: "u1", + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [], + documents: [ + { + id: "document-1", + kind: "LAB_RESULT", + mimeType: "application/pdf", + byteSize: 4, + status: "STORED", + contentEncrypted: Buffer.from([1, 2, 3, 4]).toString("base64"), + contentSha256: null, + contentCodec: "binary2", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + summaryEncrypted: null, + summaryGeneratedAt: null, + summaryState: "NONE", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + ], + }; + mocks.buildFullBackupPayload.mockResolvedValueOnce({ + payload: canonicalPayload, + counts: {}, + }); const report = await runOffhostBackup( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prisma as any, + prisma as never, s3, new Date("2026-05-08T00:00:00Z"), ); + expect(report.uploaded).toBe(1); - // Two round-trips: the full page, then the short tail that ends the walk. - expect(intakeFindMany).toHaveBeenCalledTimes(2); - expect(intakeFindMany.mock.calls[0][0]).toMatchObject({ - orderBy: { id: "asc" }, - take: MEASUREMENT_BACKUP_PAGE_SIZE, - }); - // Every paged row lands in the decrypted payload — no rows dropped. - const ct = s3.store.get("2026-05-08/user-u1.json.enc")!; - const parsed = JSON.parse(decryptBackup(ct, Buffer.from(ENC_KEY, "hex"))); - expect(parsed.intakeEvents).toHaveLength(MEASUREMENT_BACKUP_PAGE_SIZE + 1); + const ciphertext = s3.store.get("2026-05-08/user-u1.json.enc")!; + expect( + JSON.parse(decryptBackup(ciphertext, Buffer.from(ENC_KEY, "hex"))), + ).toEqual(canonicalPayload); + expect(() => backupPayloadSchema.parse(canonicalPayload)).not.toThrow(); }); it("counts per-user failures without aborting the whole run", async () => { @@ -195,25 +240,24 @@ describe("runOffhostBackup", () => { user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), }, - measurement: { - findMany: vi - .fn() - .mockResolvedValueOnce([]) - .mockRejectedValueOnce(new Error("db gone")), - }, - medication: { findMany: vi.fn().mockResolvedValue([]) }, - medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, - moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, - cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, - menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, - cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }; + mocks.buildFullBackupPayload + .mockResolvedValueOnce({ + payload: { + schemaVersion: BACKUP_SCHEMA_VERSION, + exportedAt: "2026-05-08T00:00:00.000Z", + userId: "u1", + }, + counts: {}, + }) + .mockRejectedValueOnce(new Error("db gone")); + const report = await runOffhostBackup( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prisma as any, + prisma as never, s3, new Date("2026-05-08T00:00:00Z"), ); + expect(report.uploaded).toBe(1); expect(report.failed).toBe(1); }); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index d0c2ee672..00cb3a2d7 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -19,11 +19,8 @@ */ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import type { PrismaClient } from "@/generated/prisma/client"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { getEvent } from "@/lib/logging/context"; -import { - collectPagedMeasurements, - MEASUREMENT_BACKUP_PAGE_SIZE, -} from "@/lib/jobs/backup/measurement-page"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -218,147 +215,12 @@ export async function runOffhostBackup( const evt = getEvent(); for (const user of users) { try { - const [ - measurements, - medications, - intakeEvents, - moodEntries, - cycleProfile, - cycles, - cycleDayLogs, - ] = await Promise.all([ - // includes soft-deleted rows because this is the DR snapshot, - // not a user-facing export — see - // `/api/export/full-backup/route.ts` for the symmetric exclusion. - // - // Keyset-paged narrow read instead of one full-set findMany: a - // heavy multi-year tenant otherwise loads every measurement ORM - // row into a single array alongside the giant JSON.stringify - // below, the worker's dominant heap spike. The DR dump keeps the - // FULL row shape, so the select lists every scalar column (no - // relations) — identical bytes to the prior default findMany — - // while the page reader bounds peak heap to the page size. No - // `deletedAt` filter: the tombstoned rows stay in the snapshot. - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.measurement.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - select: { - id: true, - userId: true, - type: true, - value: true, - valueMin: true, - valueMax: true, - unit: true, - source: true, - measuredAt: true, - notes: true, - notesEncrypted: true, - externalId: true, - externalSourceVersion: true, - glucoseContext: true, - sleepStage: true, - rhythmClassification: true, - deviceType: true, - syncVersion: true, - deletedAt: true, - createdAt: true, - updatedAt: true, - }, - orderBy: { id: "asc" }, - take, - }), - project: (row) => row, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }), - prisma.medication.findMany({ - where: { userId: user.id }, - include: { schedules: true }, - }), - // The intake / mood / cycle tables scale with history depth the same - // way measurements do (multiple intakes + a mood + a cycle-day-log per - // day over years), so they get the same keyset-paged read: at most one - // page is resident per round-trip instead of the entire table landing - // in one array alongside the giant JSON.stringify below. The projection - // keeps the full row (no select), so payload bytes are identical to the - // prior default findMany — only peak heap during the read is bounded. - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - orderBy: { id: "asc" }, - take, - }), - project: (row) => row, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }), - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.moodEntry.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - orderBy: { id: "asc" }, - take, - }), - project: (row) => row, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }), - // v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a - // disaster-recovery dump, not the canonical user-facing backup); - // `notesEncrypted` stays ciphertext. The profile is a single row, so it - // stays a direct read; the cycle + day-log tables are paged. - prisma.cycleProfile.findUnique({ where: { userId: user.id } }), - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.menstrualCycle.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - orderBy: { id: "asc" }, - take, - }), - project: (row) => row, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }), - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.cycleDayLog.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - include: { symptomLinks: true }, - orderBy: { id: "asc" }, - take, - }), - project: (row) => row, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }), - ]); - - const payload = JSON.stringify({ - exportedAt: now.toISOString(), - userId: user.id, - measurements, - medications, - intakeEvents, - moodEntries, - cycleProfile, - cycles, - cycleDayLogs, + const { payload } = await buildFullBackupPayload(prisma, user.id, { + purpose: "disaster-recovery", + exportedAt: now, }); - - const ciphertext = encryptBackup(payload, cfg.encryptionKey); + const plaintext = JSON.stringify(payload); + const ciphertext = encryptBackup(plaintext, cfg.encryptionKey); const key = `${dateKey}/user-${user.id}.json.enc`; await s3.putObject(key, ciphertext); uploaded++; diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index f829ed0be..5ee8ff29d 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -3,22 +3,14 @@ import { Buffer } from "node:buffer"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - buildRecordsBackupSection: vi.fn(), + buildFullBackupPayload: vi.fn(), encrypt: vi.fn((value: string) => value), getWorkerPrisma: vi.fn(), upsert: vi.fn(), })); -vi.mock("@/lib/export/records-backup", () => ({ - buildRecordsBackupSection: mocks.buildRecordsBackupSection, -})); - -vi.mock("@/lib/cycle/backup", () => ({ - buildCycleBackupSection: vi.fn().mockResolvedValue({ - cycleProfile: null, - cycles: [], - cycleDayLogs: [], - }), +vi.mock("@/lib/export/full-backup-payload", () => ({ + buildFullBackupPayload: mocks.buildFullBackupPayload, })); vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt })); @@ -50,26 +42,6 @@ function buildPrismaMock() { { id: "user-dr", username: "backup-owner" }, ]), }, - measurement: { findMany: vi.fn().mockResolvedValue([]) }, - medication: { findMany: vi.fn().mockResolvedValue([]) }, - medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, - moodEntry: { - findMany: vi.fn().mockResolvedValue([ - { - id: "mood-dr", - date: "2026-07-01", - mood: "GUT", - score: 4, - tags: null, - source: "MOODLOG", - externalId: "mood-external-dr", - moodLoggedAt: new Date("2026-07-01T20:00:00.000Z"), - tagLinks: [ - { rating: 5, moodTag: { key: "sleep_quality" } }, - ], - }, - ]), - }, dataBackup: { upsert: mocks.upsert }, }; } @@ -79,51 +51,36 @@ describe("handleDataBackup canonical DR payload", () => { vi.clearAllMocks(); mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock()); mocks.upsert.mockResolvedValue({}); - mocks.buildRecordsBackupSection.mockResolvedValue({ - labResults: [], - biomarkers: [], - illnessEpisodes: [], - allergies: [], - familyHistory: [], - workouts: [], - documents: [ - { - id: "document-dr", - kind: "OTHER", - title: null, - filename: "record.pdf", - mimeType: "application/pdf", - byteSize: 4, - status: "STORED", - reportDate: null, - documentDate: null, - createdAt: "2026-07-01T00:00:00.000Z", - updatedAt: "2026-07-01T00:00:00.000Z", - contentEncrypted: documentCiphertext, - contentSha256: null, - contentCodec: "binary2", - providerType: null, - errorReason: null, - summaryEncrypted: null, - summaryGeneratedAt: null, - summaryState: "NONE", - }, - ], - manifest: { - documents: { - included: "encrypted-content", - note: "Encrypted content included", - }, - workouts: { included: "summary-only", note: "Summary only" }, + mocks.buildFullBackupPayload.mockResolvedValue({ + payload: { + schemaVersion: "1", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: "user-dr", + moodEntries: [ + { + id: "mood-dr", + externalId: "mood-external-dr", + factors: [{ key: "sleep_quality", rating: 5 }], + }, + ], + documents: [ + { + id: "document-dr", + contentEncrypted: documentCiphertext, + contentCodec: "binary2", + }, + ], }, + counts: {}, }); }); - it("requests canonical records and serializes rated mood factors", async () => { + it("serializes the shared canonical disaster-recovery payload", async () => { await handleDataBackup([]); - expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith( - expect.anything(), + const prisma = mocks.getWorkerPrisma.mock.results[0]!.value; + expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith( + prisma, "user-dr", { purpose: "disaster-recovery" }, ); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index f289a3846..8b419ae2e 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -6,17 +6,10 @@ */ import { type Job } from "pg-boss"; import { recordError } from "@/lib/jobs/worker-status"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; import { encrypt } from "@/lib/crypto"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { buildCycleBackupSection } from "@/lib/cycle/backup"; -import { buildRecordsBackupSection } from "@/lib/export/records-backup"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; -import { - collectPagedMeasurements, - MEASUREMENT_BACKUP_PAGE_SIZE, - sortWeeklyMeasurementsDesc, - toWeeklyBackupMeasurement, -} from "@/lib/jobs/backup/measurement-page"; import { getWorkerPrisma } from "./shared"; export interface DataBackupPayload { @@ -65,139 +58,12 @@ export async function handleDataBackup(jobs: Job[]) { let backed = 0; for (const user of users) { try { - const [ - measurements, - medications, - intakeEvents, - moodEntries, - cycle, - records, - ] = await Promise.all([ - // Keyset-paged narrow read instead of one full-set - // findMany + map: a heavy multi-year tenant otherwise - // loads every measurement ORM row into a single array - // alongside the giant JSON.stringify below, the worker's - // dominant heap spike. The page reader projects each page - // to the compact backup shape before the next page loads, - // bounding peak heap to the page size. Row scope is - // unchanged — no `deletedAt` filter, so tombstoned rows - // still round-trip exactly as before. The order is - // restored to measuredAt-desc to match the prior output. - collectPagedMeasurements({ - fetchPage: (afterId, take) => - prisma.measurement.findMany({ - where: { - userId: user.id, - ...(afterId ? { id: { gt: afterId } } : {}), - }, - // Exactly the columns the admin restore recreates a - // row from, plus `id` for the keyset cursor. - select: { - id: true, - type: true, - value: true, - unit: true, - source: true, - measuredAt: true, - notes: true, - notesEncrypted: true, - }, - orderBy: { id: "asc" }, - take, - }), - project: toWeeklyBackupMeasurement, - pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, - }).then(sortWeeklyMeasurementsDesc), - prisma.medication.findMany({ - where: { userId: user.id }, - include: { schedules: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId: user.id }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: { userId: user.id }, - orderBy: { moodLoggedAt: "desc" }, - include: { - tagLinks: { - where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, - }, - }, - }), - // v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim). - buildCycleBackupSection(prisma, user.id), - // Canonical DR mode includes InboundDocument ciphertext and the - // exact codec/hash metadata needed to re-create the row. - buildRecordsBackupSection(prisma, user.id, { - purpose: "disaster-recovery", - }), - ]); - - const backupJson = JSON.stringify({ - // Bumped only when the on-disk shape changes incompatibly. - // Mirrors `BACKUP_SCHEMA_VERSION` in - // `src/lib/validations/backup.ts` — keep them in sync. - schemaVersion: "1", - exportedAt: new Date().toISOString(), - userId: user.id, - // Already projected + ordered by the paged reader above; the - // decrypted note rode into each row so an admin restore - // re-encrypts on re-insert (v1.23 contract). - measurements, - medications: medications.map((m) => ({ - name: m.name, - dose: m.dose, - active: m.active, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label, - dose: s.dose, - })), - })), - intakeEvents: intakeEvents.map((e) => ({ - medication: e.medication.name, - scheduledFor: e.scheduledFor.toISOString(), - takenAt: e.takenAt?.toISOString() ?? null, - skipped: e.skipped, - source: e.source, - })), - moodEntries: moodEntries.map((e) => ({ - id: e.id, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags, - source: e.source, - loggedAt: e.moodLoggedAt.toISOString(), - externalId: e.externalId, - factors: e.tagLinks.map((link) => ({ - key: link.moodTag.key, - rating: link.rating, - })), - })), - // v1.15.0 — cycle slice (profile + observed spans + day-logs). - cycleProfile: cycle.cycleProfile, - cycles: cycle.cycles, - cycleDayLogs: cycle.cycleDayLogs, - // Canonical disaster-recovery records. Documents carry encrypted - // bytes plus codec/hash metadata; workout route/sample sidecars - // remain outside the serialized workout-summary contract. - labResults: records.labResults, - biomarkers: records.biomarkers, - illnessEpisodes: records.illnessEpisodes, - allergies: records.allergies, - familyHistory: records.familyHistory, - workouts: records.workouts, - documents: records.documents, - manifest: records.manifest, - }); + const { payload } = await buildFullBackupPayload( + prisma, + user.id, + { purpose: "disaster-recovery" }, + ); + const backupJson = JSON.stringify(payload); // Encrypt the backup data (contains sensitive health information) const encryptedBackup = encrypt(backupJson); diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 69317751f..595f3e375 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -383,3 +383,135 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { expect(summary.documents).toBe(0); }); }); + +describe("backupPayloadSchema — database enum boundaries", () => { + const enumPayload = { + schemaVersion: "1", + exportedAt: "2026-07-20T00:00:00.000Z", + userId: "u1", + measurements: [ + { + id: "measurement-1", + type: "WEIGHT", + value: 75, + unit: "kg", + measuredAt: "2026-07-19T07:00:00.000Z", + source: "MANUAL", + }, + ], + intakeEvents: [ + { + medication: "Example", + scheduledFor: "2026-07-19T08:00:00.000Z", + source: "WEB", + }, + ], + cycleProfile: { + goal: "GENERAL_HEALTH", + secondarySymptom: "MUCUS", + }, + cycleDayLogs: [ + { + date: "2026-07-19", + flow: "MEDIUM", + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + pregnancyTest: "NEGATIVE", + progesteroneTest: "INDETERMINATE", + contraceptive: "NONE", + source: "MANUAL", + }, + ], + illnessEpisodes: [ + { + id: "illness-1", + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-07-01T00:00:00.000Z", + }, + ], + allergies: [ + { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + }, + ], + familyHistory: [ + { + id: "family-1", + relationship: "MOTHER", + condition: "Diabetes", + }, + ], + workouts: [ + { + id: "workout-1", + sportType: "RUNNING", + startedAt: "2026-07-19T06:00:00.000Z", + endedAt: "2026-07-19T06:30:00.000Z", + durationSec: 1800, + source: "APPLE_HEALTH", + }, + ], + documents: [ + { + id: "document-1", + kind: "LAB_RESULT", + mimeType: "application/pdf", + byteSize: 42, + status: "STORED", + summaryState: "NONE", + }, + ], + }; + + const enumPaths: Array<[string, Array]> = [ + ["measurement type", ["measurements", 0, "type"]], + ["measurement source", ["measurements", 0, "source"]], + ["intake source", ["intakeEvents", 0, "source"]], + ["cycle goal", ["cycleProfile", "goal"]], + ["cycle secondary symptom", ["cycleProfile", "secondarySymptom"]], + ["cycle flow", ["cycleDayLogs", 0, "flow"]], + ["cycle ovulation test", ["cycleDayLogs", 0, "ovulationTest"]], + ["cycle cervical mucus", ["cycleDayLogs", 0, "cervicalMucus"]], + ["cycle cervix position", ["cycleDayLogs", 0, "cervixPosition"]], + ["cycle cervix firmness", ["cycleDayLogs", 0, "cervixFirmness"]], + ["cycle cervix opening", ["cycleDayLogs", 0, "cervixOpening"]], + ["cycle pregnancy test", ["cycleDayLogs", 0, "pregnancyTest"]], + ["cycle progesterone test", ["cycleDayLogs", 0, "progesteroneTest"]], + ["cycle contraceptive", ["cycleDayLogs", 0, "contraceptive"]], + ["cycle source", ["cycleDayLogs", 0, "source"]], + ["illness type", ["illnessEpisodes", 0, "type"]], + ["illness lifecycle", ["illnessEpisodes", 0, "lifecycle"]], + ["allergy category", ["allergies", 0, "category"]], + ["allergy type", ["allergies", 0, "type"]], + ["allergy severity", ["allergies", 0, "severity"]], + ["allergy status", ["allergies", 0, "status"]], + ["family relationship", ["familyHistory", 0, "relationship"]], + ["workout source", ["workouts", 0, "source"]], + ["document kind", ["documents", 0, "kind"]], + ["document status", ["documents", 0, "status"]], + ["document summary state", ["documents", 0, "summaryState"]], + ]; + + it.each(enumPaths)("rejects an unsupported %s", (_label, path) => { + const candidate = structuredClone(enumPayload); + let cursor: Record = candidate; + for (const segment of path.slice(0, -1)) { + cursor = cursor[segment] as Record; + } + cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM"; + + const parsed = backupPayloadSchema.safeParse(candidate); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 1b0980090..fb50d852b 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -20,6 +20,32 @@ * a useful summary for them. */ import { z } from "zod/v4"; +import { + AllergyCategory, + AllergySeverity, + AllergyStatus, + AllergyType, + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + CycleTrackingGoal, + DocumentSummaryState, + FamilyRelationship, + FlowLevel, + HomeTestResult, + IllnessLifecycle, + IllnessType, + InboundDocumentKind, + InboundDocumentStatus, + IntakeSource, + MeasurementSource, + MeasurementType, + OvulationTest, + SecondarySymptom, +} from "@/generated/prisma/enums"; + export const BACKUP_SCHEMA_VERSION = "1" as const; @@ -32,12 +58,14 @@ const isoDateTime = z const measurementSchema = z .object({ - type: z.string().min(1), + id: z.string().min(1).optional(), + type: z.enum(MeasurementType), value: z.number(), unit: z.string().min(1), measuredAt: isoDateTime, - source: z.string().min(1).optional(), + source: z.enum(MeasurementSource).optional(), notes: z.string().nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); @@ -65,7 +93,7 @@ const intakeEventSchema = z scheduledFor: isoDateTime, takenAt: isoDateTime.nullable().optional(), skipped: z.boolean().optional(), - source: z.string().min(1).optional(), + source: z.enum(IntakeSource).optional(), }) .passthrough(); @@ -114,6 +142,7 @@ const moodEntrySchema = z source: z.string().min(1).optional(), externalId: z.string().nullable().optional(), loggedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), factors: z.array(moodFactorSchema).default([]), }) .passthrough(); @@ -148,23 +177,23 @@ const cycleSpanSchema = z const cycleDayLogSchema = z .object({ date: z.string().min(1), - flow: z.string().nullable().optional(), + flow: z.enum(FlowLevel).nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), temperatureExcluded: z.boolean().optional(), - ovulationTest: z.string().nullable().optional(), - cervicalMucus: z.string().nullable().optional(), - cervixPosition: z.string().nullable().optional(), - cervixFirmness: z.string().nullable().optional(), - cervixOpening: z.string().nullable().optional(), + ovulationTest: z.enum(OvulationTest).nullable().optional(), + cervicalMucus: z.enum(CervicalMucus).nullable().optional(), + cervixPosition: z.enum(CervixPosition).nullable().optional(), + cervixFirmness: z.enum(CervixFirmness).nullable().optional(), + cervixOpening: z.enum(CervixOpening).nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), - pregnancyTest: z.string().nullable().optional(), - progesteroneTest: z.string().nullable().optional(), - contraceptive: z.string().nullable().optional(), + pregnancyTest: z.enum(HomeTestResult).nullable().optional(), + progesteroneTest: z.enum(HomeTestResult).nullable().optional(), + contraceptive: z.enum(ContraceptiveKind).nullable().optional(), sensitiveEncrypted: z.string().nullable().optional(), notesEncrypted: z.string().nullable().optional(), - source: z.string().min(1).optional(), + source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), symptomKeys: z.array(z.string()).default([]), @@ -174,12 +203,12 @@ const cycleDayLogSchema = z /** Cycle-tracking preferences (one row per user). */ const cycleProfileSchema = z .object({ - goal: z.string().min(1).optional(), + goal: z.enum(CycleTrackingGoal).optional(), cycleTrackingEnabled: z.boolean().nullable().optional(), typicalCycleLength: z.number().int().nullable().optional(), typicalPeriodLength: z.number().int().nullable().optional(), lutealPhaseLength: z.number().int().nullable().optional(), - secondarySymptom: z.string().nullable().optional(), + secondarySymptom: z.enum(SecondarySymptom).nullable().optional(), predictionEnabled: z.boolean().optional(), rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), @@ -254,8 +283,8 @@ const illnessEpisodeBackupSchema = z .object({ id: z.string().min(1), label: z.string().min(1), - type: z.string().min(1), - lifecycle: z.string().min(1), + type: z.enum(IllnessType), + lifecycle: z.enum(IllnessLifecycle), onsetAt: isoDateTime, resolvedAt: isoDateTime.nullable().optional(), // Self-referencing flare/exacerbation link, carried as the exported @@ -272,10 +301,10 @@ const allergyBackupSchema = z .object({ id: z.string().min(1), substance: z.string().min(1), - category: z.string().min(1), - type: z.string().min(1), - severity: z.string().nullable().optional(), - status: z.string().min(1), + category: z.enum(AllergyCategory), + type: z.enum(AllergyType), + severity: z.enum(AllergySeverity).nullable().optional(), + status: z.enum(AllergyStatus), onsetAt: isoDateTime.nullable().optional(), reaction: z.string().nullable().optional(), note: z.string().nullable().optional(), @@ -287,7 +316,7 @@ const allergyBackupSchema = z const familyHistoryBackupSchema = z .object({ id: z.string().min(1), - relationship: z.string().min(1), + relationship: z.enum(FamilyRelationship), condition: z.string().min(1), ageAtOnset: z.number().int().nullable().optional(), note: z.string().nullable().optional(), @@ -311,7 +340,7 @@ const workoutBackupSchema = z stepCount: z.number().int().nullable().optional(), elevationM: z.number().nullable().optional(), pauseDurationSec: z.number().int().nullable().optional(), - source: z.string().min(1), + source: z.enum(MeasurementSource), externalId: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), @@ -330,12 +359,12 @@ const base64BytesSchema = z const documentBackupSchema = z .object({ id: z.string().min(1), - kind: z.string().min(1), + kind: z.enum(InboundDocumentKind), title: z.string().nullable().optional(), filename: z.string().nullable().optional(), mimeType: z.string().min(1), byteSize: z.number().int(), - status: z.string().min(1), + status: z.enum(InboundDocumentStatus), reportDate: z.string().nullable().optional(), documentDate: z.string().nullable().optional(), contentEncrypted: base64BytesSchema.optional(), @@ -349,7 +378,7 @@ const documentBackupSchema = z errorReason: z.string().nullable().optional(), summaryEncrypted: base64BytesSchema.nullable().optional(), summaryGeneratedAt: isoDateTime.nullable().optional(), - summaryState: z.string().min(1).optional(), + summaryState: z.enum(DocumentSummaryState).optional(), summary: z.string().nullable().optional(), createdAt: isoDateTime.optional(), updatedAt: isoDateTime.optional(), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 3aa274805..ee2b12f1a 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -4,11 +4,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OFFHOST_ENCRYPTION_KEY = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +process.env.BACKUP_S3_ENDPOINT = "https://s3.example.test"; +process.env.BACKUP_S3_BUCKET = "healthlog-test"; +process.env.BACKUP_S3_ACCESS_KEY = "test-access"; +process.env.BACKUP_S3_SECRET_KEY = "test-secret"; +process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY; + import { encrypt, encryptBytes } from "@/lib/crypto"; import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; -import { buildRecordsBackupSection } from "@/lib/export/records-backup"; -import { backupPayloadSchema } from "@/lib/validations/backup"; +import { + decryptBackup, + runOffhostBackup, + type S3Like, +} from "@/lib/jobs/offhost-backup"; +import { + backupPayloadSchema, + parseBackupPayload, +} from "@/lib/validations/backup"; import { POST } from "@/app/api/admin/backups/[id]/restore/route"; import { invalidateUserData } from "@/lib/cache/invalidate"; @@ -74,8 +89,32 @@ function makeRequest(id: string) { }); } +function makeS3Store(): S3Like & { objects: Map } { + const objects = new Map(); + return { + objects, + putObject: vi.fn(async (key, body) => { + objects.set(key, Buffer.from(body)); + }), + getObject: vi.fn(async (key) => { + const body = objects.get(key); + if (!body) throw new Error(`Missing S3 object: ${key}`); + return body; + }), + headObject: vi.fn(async (key) => objects.has(key)), + listObjects: vi.fn(async (prefix) => + [...objects.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => ({ key })), + ), + deleteObject: vi.fn(async (key) => { + objects.delete(key); + }), + }; +} + describe("canonical disaster-recovery backup round-trip", () => { - it("restores every serialized record class and document ciphertext verbatim", async () => { + it("restores an off-host payload with tombstones and encrypted documents", async () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); const ownerId = admin.id; @@ -91,6 +130,19 @@ describe("canonical disaster-recovery backup round-trip", () => { source: "MANUAL", }, }); + const tombstoneDeletedAt = new Date("2026-07-01T12:00:00.000Z"); + const deletedMeasurement = await prisma.measurement.create({ + data: { + id: "measurement-deleted-dr", + userId: ownerId, + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: new Date("2026-07-01T08:00:00.000Z"), + source: "MANUAL", + deletedAt: tombstoneDeletedAt, + }, + }); const factorCategory = await prisma.moodTagCategory.create({ data: { @@ -129,6 +181,18 @@ describe("canonical disaster-recovery backup round-trip", () => { }, }, }); + const deletedMood = await prisma.moodEntry.create({ + data: { + id: "mood-deleted-dr", + userId: ownerId, + date: "2026-06-30", + mood: "OKAY", + score: 3, + source: "MOODLOG", + moodLoggedAt: new Date("2026-06-30T20:00:00.000Z"), + deletedAt: tombstoneDeletedAt, + }, + }); const biomarker = await prisma.biomarker.create({ data: { @@ -265,48 +329,27 @@ describe("canonical disaster-recovery backup round-trip", () => { }, }); - const records = await buildRecordsBackupSection(prisma, ownerId, { - purpose: "disaster-recovery", + const s3 = makeS3Store(); + const snapshotAt = new Date("2026-07-02T00:00:00.000Z"); + const offhostReport = await runOffhostBackup(prisma, s3, snapshotAt); + expect(offhostReport).toMatchObject({ + uploaded: 1, + failed: 0, + totalUsers: 1, }); - - const payload = backupPayloadSchema.parse({ + const offhostObject = s3.objects.get( + `2026-07-02/user-${ownerId}.json.enc`, + ); + expect(offhostObject).toBeDefined(); + const payload = parseBackupPayload( + decryptBackup( + offhostObject!, + Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"), + ), + ); + expect(payload).toMatchObject({ schemaVersion: "1", - exportedAt: "2026-07-02T00:00:00.000Z", userId: ownerId, - measurements: [ - { - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - measuredAt: measurement.measuredAt.toISOString(), - source: measurement.source, - }, - ], - medications: [], - intakeEvents: [], - moodEntries: [ - { - id: mood.id, - date: mood.date, - mood: mood.mood, - score: mood.score, - tags: mood.tags, - source: mood.source, - loggedAt: mood.moodLoggedAt.toISOString(), - factors: mood.tagLinks.map((link) => ({ - key: link.moodTag.key, - rating: link.rating, - })), - }, - ], - labResults: records.labResults, - biomarkers: records.biomarkers, - illnessEpisodes: records.illnessEpisodes, - allergies: records.allergies, - familyHistory: records.familyHistory, - workouts: records.workouts, - documents: records.documents, - manifest: records.manifest, }); expect(payload.documents[0]).not.toHaveProperty("summary"); expect(JSON.stringify(payload.documents[0])).not.toContain( @@ -345,25 +388,30 @@ describe("canonical disaster-recovery backup round-trip", () => { expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1); expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1); expect( - await prisma.measurement.findMany({ - where: { userId: ownerId }, - select: { - type: true, - value: true, - unit: true, - measuredAt: true, - source: true, - }, + await prisma.measurement.findUniqueOrThrow({ + where: { id: measurement.id }, }), - ).toEqual([ - { + ).toEqual( + expect.objectContaining({ + userId: ownerId, type: measurement.type, value: measurement.value, unit: measurement.unit, measuredAt: measurement.measuredAt, source: measurement.source, - }, - ]); + deletedAt: null, + }), + ); + expect( + await prisma.measurement.findUniqueOrThrow({ + where: { id: deletedMeasurement.id }, + }), + ).toEqual( + expect.objectContaining({ + userId: ownerId, + deletedAt: tombstoneDeletedAt, + }), + ); const restoredLab = await prisma.labResult.findUniqueOrThrow({ where: { id: lab.id }, @@ -519,6 +567,16 @@ describe("canonical disaster-recovery backup round-trip", () => { moodTag: { key: "dr_sleep_quality" }, }), ]); + expect( + await prisma.moodEntry.findUniqueOrThrow({ + where: { id: deletedMood.id }, + }), + ).toEqual( + expect.objectContaining({ + userId: ownerId, + deletedAt: tombstoneDeletedAt, + }), + ); expect((await restore()).status).toBe(200); expect( @@ -546,6 +604,76 @@ describe("canonical disaster-recovery backup round-trip", () => { }), ).toEqual([{ id: illness.id }]); }); + it.each([ + { + label: "invalid enum", + mutate: (payload: Record) => { + payload.measurements = [ + { + type: "INVALID_MEASUREMENT_TYPE", + value: 72, + unit: "kg", + measuredAt: "2026-07-03T07:00:00.000Z", + source: "MANUAL", + }, + ]; + }, + }, + { + label: "unsupported schema version", + mutate: (payload: Record) => { + payload.schemaVersion = "999"; + }, + }, + ])("rejects $label with 422 before writes or invalidation", async ({ mutate }) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const existing = await prisma.measurement.create({ + data: { + id: "pre-validation-measurement", + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T06:00:00.000Z"), + }, + }); + const payload: Record = { + schemaVersion: "1", + exportedAt: "2026-07-03T08:00:00.000Z", + userId: admin.id, + measurements: [], + moodEntries: [], + }; + mutate(payload); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`, + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ + data: null, + error: expect.any(String), + }); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + select: { id: true }, + }), + ).toEqual([{ id: existing.id }]); + expect(invalidateUserData).not.toHaveBeenCalled(); + }); + it("rejects metadata-only documents before mutation or invalidation", async () => { const prisma = getPrismaClient(); diff --git a/tests/integration/admin-backups-upload.test.ts b/tests/integration/admin-backups-upload.test.ts index b5c692553..39c822251 100644 --- a/tests/integration/admin-backups-upload.test.ts +++ b/tests/integration/admin-backups-upload.test.ts @@ -70,7 +70,34 @@ async function seedAdminSession() { return admin; } -function buildPayload(userId: string) { +interface UploadPayloadFixture { + schemaVersion: string; + exportedAt: string; + userId: string; + measurements: Array<{ + type: string; + value: number; + unit: string; + measuredAt: string; + source: string; + notes: null; + }>; + medications: Array<{ + name: string; + dose: string; + active: boolean; + schedules: Array<{ + windowStart: string; + windowEnd: string; + label: string; + dose: null; + }>; + }>; + intakeEvents: []; + moodEntries: []; +} + +function buildPayload(userId: string): UploadPayloadFixture { return { schemaVersion: "1", exportedAt: "2026-05-09T10:00:00.000Z", @@ -211,6 +238,36 @@ describe("POST /api/admin/backups/upload", () => { expect(denied[0]?.details ?? "").toContain("owner_not_found"); }); + it.each([ + ["measurement type", (payload: UploadPayloadFixture) => { + payload.measurements[0]!.type = "NOT_A_MEASUREMENT"; + }], + ["measurement source", (payload: UploadPayloadFixture) => { + payload.measurements[0]!.source = "NOT_A_SOURCE"; + }], + ])("rejects an invalid %s with 422 and zero backup writes", async (_label, mutate) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const payload = buildPayload(admin.id); + mutate(payload); + const file = new File([JSON.stringify(payload)], "invalid-enum.json", { + type: "application/json", + }); + + const { POST } = await import("@/app/api/admin/backups/upload/route"); + const res = await POST( + buildMultipart(file) as unknown as Parameters[0], + ); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + data: null, + error: "Backup payload failed schema validation", + meta: { issues: expect.any(Array) }, + }); + expect(await prisma.dataBackup.count()).toBe(0); + }); + it("rejects an incompatible schemaVersion with 422", async () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); @@ -224,6 +281,11 @@ describe("POST /api/admin/backups/upload", () => { buildMultipart(file) as unknown as Parameters[0], ); + expect(await res.json()).toMatchObject({ + data: null, + error: expect.stringContaining("not supported"), + }); + expect(await prisma.dataBackup.count()).toBe(0); expect(res.status).toBe(422); const denied = await prisma.auditLog.findMany({ where: { action: "admin.backups.upload.denied", userId: admin.id }, From a8ac376cf7b11a50e297f6da1c9bbb285f0973a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:55:09 +0200 Subject: [PATCH 10/35] fix(measurements): reconcile dual identities atomically --- docs/api/openapi.yaml | 12 +- .../__tests__/aggregated-hr-wire.test.ts | 139 +++++--- .../insert-returning-arrivals.test.ts | 59 +++- .../batch/__tests__/pr-detection-hook.test.ts | 45 +++ src/app/api/measurements/batch/route.ts | 313 +++++++----------- src/lib/__tests__/idempotency.test.ts | 18 + src/lib/idempotency.ts | 10 +- .../reconcile-external-measurement.test.ts | 233 +++++++++++++ .../reconcile-external-measurement.ts | 296 +++++++++++++++++ src/lib/openapi/routes/measurements.ts | 5 +- src/lib/oura/__tests__/sync.test.ts | 77 +++-- src/lib/oura/sync.ts | 104 +++--- src/lib/polar/__tests__/sync.test.ts | 71 +++- src/lib/polar/sync.ts | 111 +++---- src/lib/whoop/__tests__/sync.test.ts | 66 +++- src/lib/whoop/sync.ts | 102 +++--- src/lib/withings/__tests__/sync-sleep.test.ts | 162 +++++---- src/lib/withings/sync-sleep.ts | 239 ++++--------- .../measurement-identity-reconcile.test.ts | 224 +++++++++++++ tests/integration/measurements-batch.test.ts | 60 ++-- 20 files changed, 1549 insertions(+), 797 deletions(-) create mode 100644 src/lib/measurements/__tests__/reconcile-external-measurement.test.ts create mode 100644 src/lib/measurements/reconcile-external-measurement.ts create mode 100644 tests/integration/measurement-identity-reconcile.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f05c28259..f91e1dd7b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -17434,6 +17434,10 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + failed: + type: integer + minimum: 0 + maximum: 9007199254740991 skipped: type: array items: @@ -17458,6 +17462,7 @@ components: - inserted - updated - duplicates + - failed - skipped - entries additionalProperties: false @@ -17475,9 +17480,10 @@ components: - updated - duplicate - skipped - description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day - cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. - unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`." + - failed + description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote + an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must + not advance the entry cursor; the response is marked `Cache-Control: no-store`." reason: type: string required: diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 46fd0de7a..51de39107 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -31,6 +31,62 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: async ( + tx: { + measurement: { + findMany: (args: unknown) => Promise< + Array<{ + id?: string; + type?: string; + source?: string; + externalId?: string | null; + }> + >; + createManyAndReturn: (args: { + data: Record; + }) => Promise>>; + updateMany: (args: { + where: Record; + data: Record; + }) => Promise; + }; + }, + input: Record & { + userId: string; + type: string; + source: string; + externalId: string; + }, + options: { exactExternalMatch?: "update" | "duplicate" } = {}, + ) => { + const existing = await tx.measurement.findMany({}); + const exact = existing.find( + (row: { type?: string; source?: string; externalId?: string | null }) => + row.type === input.type && + row.source === input.source && + row.externalId === input.externalId, + ); + if (exact && options.exactExternalMatch !== "update") { + return { status: "duplicate", row: exact }; + } + if (exact) { + await tx.measurement.updateMany({ + where: { id: exact.id }, + data: { ...input, deletedAt: null }, + }); + return { status: "updated", row: { ...exact, ...input } }; + } + const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + if (inserted) return { status: "inserted", row: inserted }; + if (options.exactExternalMatch === "update") { + await tx.measurement.updateMany({ where: {}, data: input }); + return { status: "updated", row: input }; + } + return { status: "duplicate" }; + }, +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -159,12 +215,10 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) expect(data.entries[0].status).toBe("inserted"); // Stored as a PULSE row carrying the 10-min average value. - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - const rows = (createArg as { data: { type: string; value: number }[] }) - .data; - expect(rows[0].type).toBe("PULSE"); - expect(rows[0].value).toBe(72); + const row = vi.mocked(prisma.measurement.createManyAndReturn).mock + .calls[0]![0]!.data as { type: string; value: number }; + expect(row.type).toBe("PULSE"); + expect(row.value).toBe(72); }); it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => { @@ -176,16 +230,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) }), ); expect(res.status).toBe(200); - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - const rows = ( - createArg as { - data: { value: number; valueMin: number; valueMax: number }[]; - } - ).data; - expect(rows[0].value).toBe(72); - expect(rows[0].valueMin).toBe(58); - expect(rows[0].valueMax).toBe(96); + const row = vi.mocked(prisma.measurement.createManyAndReturn).mock + .calls[0]![0]!.data as { + value: number; + valueMin: number; + valueMax: number; + }; + expect(row.value).toBe(72); + expect(row.valueMin).toBe(58); + expect(row.valueMax).toBe(96); }); it.each([ @@ -202,21 +255,15 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }), ); expect(res.status).toBe(200); - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - const rows = ( - createArg as { - data: { - value: number; - valueMin: number | null; - valueMax: number | null; - }[]; - } - ).data; - // The trustworthy average survives; the invalid spread is dropped. - expect(rows[0].value).toBe(72); - expect(rows[0].valueMin).toBeNull(); - expect(rows[0].valueMax).toBeNull(); + const row = vi.mocked(prisma.measurement.createManyAndReturn).mock + .calls[0]![0]!.data as { + value: number; + valueMin: number | null; + valueMax: number | null; + }; + expect(row.value).toBe(72); + expect(row.valueMin).toBeNull(); + expect(row.valueMax).toBeNull(); }, ); @@ -251,15 +298,13 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) }), ); expect(res.status).toBe(200); - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - const rows = ( - createArg as { - data: { valueMin: number | null; valueMax: number | null }[]; - } - ).data; - expect(rows[0].valueMin).toBeNull(); - expect(rows[0].valueMax).toBeNull(); + const row = vi.mocked(prisma.measurement.createManyAndReturn).mock + .calls[0]![0]!.data as { + valueMin: number | null; + valueMax: number | null; + }; + expect(row.valueMin).toBeNull(); + expect(row.valueMax).toBeNull(); }); it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => { @@ -445,12 +490,10 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", ( expect(res.status).toBe(200); const { data } = await readJson(res); - // Exactly ONE row reaches the insert, carrying the NEWER value. - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - const rows = (createArg as { data: { value: number }[] }).data; - expect(rows).toHaveLength(1); - expect(rows[0].value).toBe(78); + expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(1); + const row = vi.mocked(prisma.measurement.createManyAndReturn).mock + .calls[0]![0]!.data as { value: number }; + expect(row.value).toBe(78); // The superseded entry is reported honestly, not as a phantom insert. expect(data.entries[0].status).toBe("duplicate"); @@ -500,9 +543,7 @@ describe("POST /api/measurements/batch — intra-batch `stats:` supersession", ( expect(res.status).toBe(200); const { data } = await readJson(res); - const createArg = vi.mocked(prisma.measurement.createManyAndReturn).mock - .calls[0][0]; - expect((createArg as { data: unknown[] }).data).toHaveLength(2); + expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(2); expect(data.inserted).toBe(2); expect(data.duplicates).toBe(0); }); diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index 5f0bf6752..d376658b3 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -15,6 +15,58 @@ vi.mock("@/lib/db", () => ({ }), }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: async ( + tx: { + measurement: { + findMany: (args: unknown) => Promise< + Array<{ + id?: string; + type?: string; + source?: string; + externalId?: string | null; + }> + >; + createManyAndReturn: (args: { + data: Record; + }) => Promise>>; + updateMany: (args: { + where: Record; + data: Record; + }) => Promise; + }; + }, + input: Record & { + userId: string; + type: string; + source: string; + externalId: string; + }, + options: { exactExternalMatch?: "update" | "duplicate" } = {}, + ) => { + const existing = await tx.measurement.findMany({}); + const exact = existing.find( + (row: { type?: string; source?: string; externalId?: string | null }) => + row.type === input.type && + row.source === input.source && + row.externalId === input.externalId, + ); + if (exact && options.exactExternalMatch !== "update") { + return { status: "duplicate", row: exact }; + } + if (exact) { + await tx.measurement.updateMany({ where: { id: exact.id }, data: input }); + return { status: "updated", row: { ...exact, ...input } }; + } + const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + if (inserted) return { status: "inserted", row: inserted }; + if (options.exactExternalMatch === "update") { + await tx.measurement.updateMany({ where: {}, data: input }); + return { status: "updated", row: input }; + } + return { status: "duplicate" }; + }, +})); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -190,12 +242,7 @@ describe("POST /api/measurements/batch — exact INSERT RETURNING identity", () "duplicate", "duplicate", ]); - expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledWith( - expect.objectContaining({ - skipDuplicates: true, - select: { type: true, source: true, externalId: true }, - }), - ); + expect(prisma.measurement.createManyAndReturn).toHaveBeenCalledTimes(4); expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("user-1", [ new Date(insertedSleepAt), ]); diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index eeeb051ea..ab9ff6113 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +const reconcileOverrideMock = vi.hoisted(() => vi.fn()); + vi.mock("@/lib/db", () => ({ prisma: { measurement: { @@ -23,6 +25,26 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: async ( + tx: { + measurement: { + createManyAndReturn: (args: { + data: Record; + }) => Promise>>; + }; + }, + input: Record, + ) => { + const override = await reconcileOverrideMock(tx, input); + if (override !== undefined) return override; + const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + return inserted + ? { status: "inserted", row: inserted } + : { status: "duplicate" }; + }, +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -118,6 +140,29 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", }); }); + it("surfaces a hard reconciliation failure without checkpointing it", async () => { + reconcileOverrideMock.mockResolvedValueOnce({ + status: "failed", + error: { message: "write rejected", code: "P2002" }, + }); + + const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] })); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toContain("no-store"); + expect(await res.json()).toMatchObject({ + data: { + processed: 1, + inserted: 0, + updated: 0, + duplicates: 0, + failed: 1, + entries: [{ index: 0, status: "failed", reason: "P2002" }], + }, + error: null, + }); + expect(enqueuePrDetection).not.toHaveBeenCalled(); + }); + it("sets silent=true once the batch crosses the 50-entry threshold", async () => { const big = Array.from({ length: 51 }, (_, i) => validStepEntry(`ext-${i}`), diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 13d259ceb..25013650d 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -60,6 +60,7 @@ import { oppositeMergeSource, type MergeCandidate, } from "@/lib/measurements/cross-source-merge"; +import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -173,11 +174,15 @@ type BatchEntry = z.infer; * we already have, the server overwrites the row's `value` rather than * dropping the new payload as a duplicate. Sample-class rows (every * other HK metric) keep the strict `duplicate` semantics because each - * sample is a canonical, immutable reading. `skipped` is the only case - * where the client may want to surface a diagnostic — typically because - * Apple introduced a new identifier the server doesn't know about yet. + * sample is a canonical, immutable reading. `skipped` is a validation no-op; + * `failed` is a hard database verdict and must not be checkpointed as landed. */ -type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped"; +type EntryStatus = + | "inserted" + | "updated" + | "duplicate" + | "skipped" + | "failed"; // v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative // totals that the iOS HealthKit observer re-posts as the day progresses. @@ -372,47 +377,20 @@ async function postBatch(request: NextRequest): Promise { let insertedCount = 0; let updatedCount = 0; let duplicateCount = 0; + let failedCount = 0; // v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the // MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain // composite-key duplicate). Surfaced as a dedicated wide-event count so // an operator can see how often the standalone-pair mirror collapses. let crossSourceMergedCount = 0; - // Exact prepared rows returned by INSERT ... RETURNING. This is the only - // source for post-write arrival and sleep side effects: preflight checks can - // lose a unique-key race after they run. + // Only rows the reconciler actually inserted drive arrival side effects. const insertedPrepared: Prepared[] = []; + const writtenIdentities: Array<{ + type: MeasurementType; + measuredAt: Date; + }> = []; if (prepared.length > 0) { - // Pre-flight duplicate detection classifies rows already present before - // this request. The later INSERT ... RETURNING remains authoritative when - // a competing request wins the unique-key race after this probe. - // v1.8.6 W6 — `source` is now part of the dedup key, so the lookup - // matches on (type, source, externalId) per incoming row rather than - // a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an - // `APPLE_HEALTH` row sharing an externalId are distinct, so each must - // carry its source through the existence probe and the composite key. - const incomingKeys = prepared.map((p) => ({ - type: p.row.type as Prisma.MeasurementCreateManyInput["type"], - source: p.row.source as Prisma.MeasurementCreateManyInput["source"], - externalId: p.row.externalId as string, - })); - - const existing = await prisma.measurement.findMany({ - where: { - userId: user.id, - OR: incomingKeys.map((k) => ({ - type: k.type, - source: k.source, - externalId: k.externalId, - })), - }, - select: { type: true, source: true, externalId: true }, - }); - - const existingSet = new Set( - existing.map((row) => `${row.type}::${row.source}::${row.externalId}`), - ); - // v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH // mirror duplicate. When the iOS app logs a manual reading offline it // mirrors that reading into HealthKit; on pairing, adopt-on-pair @@ -485,20 +463,8 @@ async function postBatch(request: NextRequest): Promise { return false; } - // Intra-batch `stats:` supersession. Two entries carrying the same - // (type, source, externalId) `stats:` key inside ONE batch both miss - // `existingSet` — neither is in the DB yet — so the overwrite branch - // never runs, both land in `toInsert`, and - // `createMany({ skipDuplicates: true })` keeps whichever row the unique - // index saw first. The OLDER snapshot of a per-day cumulative total (or - // of a still-filling ten-minute heart-rate bucket) therefore won and the - // newer, larger figure was dropped silently: both entries reported - // `inserted`, the counters still summed, and the client checkpointed - // past both. `stats:` rows carry overwrite semantics by contract — a - // re-post replaces value / unit / measuredAt — so within one batch the - // LAST entry for a key is the authoritative one. Resolve it here and - // mark the superseded earlier entries `duplicate`, the same status the - // client already checkpoints past for a composite-key duplicate. + // `stats:` rows carry overwrite semantics and the last entry in one batch + // is authoritative. Earlier snapshots are safe duplicates. const lastStatsIndexByKey = new Map(); for (const p of prepared) { if (!isStatsExternalId(p.row.externalId as string)) continue; @@ -508,57 +474,96 @@ async function postBatch(request: NextRequest): Promise { ); } - const toInsert: Prepared[] = []; - const insertKeys = new Set(); - const toOverwrite: Prepared[] = []; for (const p of prepared) { - const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`; - if ( - isStatsExternalId(p.row.externalId as string) && - lastStatsIndexByKey.get(key) !== p.index - ) { - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "superseded_in_batch", - }; - duplicateCount += 1; - continue; - } - if (existingSet.has(key) || insertKeys.has(key)) { - // v1.5.0 issue #213 — per-day cumulative `stats:*` rows are - // intentionally overwritten on a re-post so today's tile reflects - // the latest HealthKit total instead of freezing at the - // first-sync value. Sample-class rows keep the immutable - // duplicate contract. - if (isStatsExternalId(p.row.externalId as string)) { - toOverwrite.push(p); - } else { - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount += 1; + const externalId = p.row.externalId as string; + const statsRow = isStatsExternalId(externalId); + const key = `${p.row.type}::${p.row.source}::${externalId}`; + if (statsRow && lastStatsIndexByKey.get(key) !== p.index) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "superseded_in_batch", + }; + duplicateCount++; + continue; } - } else if ( - !isStatsExternalId(p.row.externalId as string) && - hasSameReadingSibling(p) - ) { - // v1.11.4 (iOS #2) — cross-source same-reading collapse. The - // physical reading already exists under the opposite client - // source; drop this mirror copy. Status stays `duplicate` so the - // iOS sync cursor checkpoints past it exactly as it does for a - // composite-key duplicate; the `reason` distinguishes it for ops. - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "cross_source_merge", - }; - duplicateCount += 1; - crossSourceMergedCount += 1; - } else { - results[p.index] = { index: p.index, status: "inserted" }; - toInsert.push(p); - insertKeys.add(key); - // Track this row so a same-reading sibling later in the SAME - // batch collapses against it. + + if (!statsRow && hasSameReadingSibling(p)) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "cross_source_merge", + }; + duplicateCount++; + crossSourceMergedCount++; + continue; + } + + const verdict = await prisma.$transaction((tx) => + reconcileExternalMeasurement( + tx, + { + userId: user.id, + type: p.row.type as MeasurementType, + value: p.row.value as number, + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, + unit: p.row.unit as string, + source: + p.row.source as Prisma.MeasurementUncheckedCreateInput["source"], + measuredAt: p.row.measuredAt as Date, + externalId, + externalSourceVersion: p.row + .externalSourceVersion as string | null, + glucoseContext: + p.row.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], + sleepStage: + p.row.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], + rhythmClassification: + p.row + .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], + deviceType: p.row.deviceType as string | null, + }, + { exactExternalMatch: statsRow ? "update" : "duplicate" }, + ), + ); + + switch (verdict.status) { + case "inserted": + results[p.index] = { index: p.index, status: "inserted" }; + insertedCount++; + insertedPrepared.push(p); + writtenIdentities.push({ + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }); + break; + case "updated": + case "resurrected": + results[p.index] = { index: p.index, status: "updated" }; + updatedCount++; + writtenIdentities.push( + { + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }, + ...(verdict.dirtyIdentities ?? []), + ); + break; + case "duplicate": + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount++; + break; + case "failed": + results[p.index] = { + index: p.index, + status: "failed", + reason: verdict.error.code ?? "write_failed", + }; + failedCount++; + continue; + } + if (isMergeableSource(p.row.source as string)) { inBatchCandidates.push({ type: p.row.type as MeasurementType, @@ -567,95 +572,6 @@ async function postBatch(request: NextRequest): Promise { measuredAt: p.row.measuredAt as Date, }); } - } - } - - if (toOverwrite.length > 0) { - await prisma.$transaction(async (tx) => { - for (const p of toOverwrite) { - await tx.measurement.updateMany({ - where: { - userId: user.id, - // v1.8.6 W6 — scope the overwrite to the row's own source - // so a `stats:*` re-post only touches the matching - // (type, source, externalId) row. - source: p.row - .source as Prisma.MeasurementCreateManyInput["source"], - type: p.row.type as MeasurementType, - externalId: p.row.externalId as string, - }, - data: { - value: p.row.value as number, - // v1.19.2 (iOS #34 extension) — overwrite the per-bucket - // spread alongside `value` so a within-hour re-post (the - // running mean + range shift as more samples land) replaces - // both. Null for every non-HR-bucket `stats:` overwrite. - valueMin: p.row.valueMin as number | null, - valueMax: p.row.valueMax as number | null, - unit: p.row.unit as string, - measuredAt: p.row.measuredAt as Date, - externalSourceVersion: p.row.externalSourceVersion as - string | null, - deviceType: p.row.deviceType as Prepared["row"]["deviceType"], - sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"], - // No-op on a live row, a deliberate RESURRECTION on a - // tombstoned one — the observer re-posts the day's canonical - // total, so a `stats:` day-total row follows the - // source-owned resurrect rule (mirrors Google / Fitbit). - // SAMPLE-grain rows never reach this branch: their - // tombstones stick (the iOS LWW reconciler propagates - // HealthKit deletions and reports them as `duplicate`). - deletedAt: null, - }, - }); - results[p.index] = { index: p.index, status: "updated" }; - updatedCount += 1; - } - }); - } - - if (toInsert.length > 0) { - // PostgreSQL RETURNING identifies the exact winner when another request - // races the same composite unique key. An aggregate insert count can say - // how many rows landed but not which prepared rows won, which would make - // arrival and morning-refresh side effects attach to arbitrary rows. - const CHUNK = 200; - const insertedIndexes = new Set(); - await prisma.$transaction(async (tx) => { - for (let i = 0; i < toInsert.length; i += CHUNK) { - const chunk = toInsert.slice(i, i + CHUNK); - const pendingByKey = new Map(); - for (const p of chunk) { - pendingByKey.set( - `${p.row.type}::${p.row.source}::${p.row.externalId}`, - p, - ); - } - - const inserted = await tx.measurement.createManyAndReturn({ - data: chunk.map((p) => p.row), - skipDuplicates: true, - select: { type: true, source: true, externalId: true }, - }); - // Count only returned identities that map to an attempted row, so - // aggregate counts and per-entry statuses share one source of truth. - for (const row of inserted) { - const preparedRow = pendingByKey.get( - `${row.type}::${row.source}::${row.externalId}`, - ); - if (!preparedRow) continue; - insertedIndexes.add(preparedRow.index); - insertedCount += 1; - insertedPrepared.push(preparedRow); - } - } - }); - - for (const p of toInsert) { - if (insertedIndexes.has(p.index)) continue; - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount += 1; - } } } @@ -680,6 +596,7 @@ async function postBatch(request: NextRequest): Promise { updated: updatedCount, duplicates: duplicateCount, skipped: skipped.length, + failed: failedCount, }, }); @@ -760,6 +677,7 @@ async function postBatch(request: NextRequest): Promise { updated: updatedCount, duplicates: duplicateCount, skipped: skipped.length, + failed: failedCount, }, }); @@ -784,15 +702,7 @@ async function postBatch(request: NextRequest): Promise { // by day so a 500-row Apple Health batch fires only one recompute per // type/day pair. Best-effort — a populator hiccup never fails ingest. try { - const insertedKeys = collapseToTypeDayKeys( - prepared.map((p) => ({ - type: p.row.type as MeasurementType, - measuredAt: - p.row.measuredAt instanceof Date - ? p.row.measuredAt - : new Date(p.row.measuredAt as string), - })), - ); + const insertedKeys = collapseToTypeDayKeys(writtenIdentities); for (const k of insertedKeys) { await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); } @@ -851,12 +761,17 @@ async function postBatch(request: NextRequest): Promise { } } - return apiSuccess({ + const response = apiSuccess({ processed: entries.length, inserted: insertedCount, updated: updatedCount, duplicates: duplicateCount, + failed: failedCount, skipped, entries: results, }); + if (failedCount > 0) { + response.headers.set("Cache-Control", "private, no-store"); + } + return response; } diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 15c10ca01..5d15ba7db 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -145,6 +145,24 @@ describe("withIdempotency", () => { expect(handler).toHaveBeenCalledTimes(1); // not called again }); + it("does not cache a successful response marked no-store", async () => { + const handler = vi.fn(async () => + NextResponse.json( + { data: { failed: 1 }, error: null }, + { headers: { "Cache-Control": "private, no-store" } }, + ), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + + const response = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(response.status).toBe(200); + expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalled(); + }); + it("ignores expired cache rows and re-runs the handler", async () => { const handler = vi.fn(async () => NextResponse.json({ data: "fresh", error: null }, { status: 200 }), diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index a10155bc2..7c5dbb220 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -411,7 +411,11 @@ export function withIdempotency< throw err; } - if (isCachableStatus(response.status)) { + const noStore = response.headers + .get("Cache-Control") + ?.split(",") + .some((directive) => directive.trim().toLowerCase() === "no-store"); + if (isCachableStatus(response.status) && !noStore) { // Defence-in-depth: never persist a body that carries a freshly-issued // bearer / refresh token or a third-party AI provider key. Auth and // settings routes shouldn't be wrapped in withIdempotency to begin @@ -437,8 +441,8 @@ export function withIdempotency< await releaseClaim(ctx); } } else { - // Non-cachable status (401/403/408/429/5xx) — release the claim so a - // retry gets a fresh attempt rather than a stuck pending row. + // Non-cachable status or an explicit `no-store` response releases the + // claim so a retry gets a fresh attempt. await releaseClaim(ctx); } diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts new file mode 100644 index 000000000..41ca58bb2 --- /dev/null +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { + reconcileExternalMeasurement, + type ExternalMeasurementWrite, +} from "../reconcile-external-measurement"; + +function desired( + overrides: Partial = {}, +): ExternalMeasurementWrite { + return { + userId: "user-1", + type: "SLEEP_DURATION", + value: 42, + unit: "minutes", + source: "OURA", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + sleepStage: "DEEP", + externalId: "sleep:new", + externalSourceVersion: "v2", + deviceType: "ring", + ...overrides, + }; +} + +function row( + overrides: Record = {}, +): Record { + return { + id: "row-1", + userId: "user-1", + type: "SLEEP_DURATION", + value: 30, + valueMin: null, + valueMax: null, + unit: "minutes", + source: "OURA", + measuredAt: new Date("2026-07-20T06:00:00.000Z"), + notes: null, + notesEncrypted: null, + externalId: "sleep:new", + externalSourceVersion: null, + glucoseContext: null, + sleepStage: "DEEP", + rhythmClassification: null, + deviceType: "ring", + syncVersion: 1, + deletedAt: null, + createdAt: new Date("2026-07-20T07:00:00.000Z"), + updatedAt: new Date("2026-07-20T07:00:00.000Z"), + ...overrides, + }; +} + +function transaction(rows: Record[]) { + const measurement = { + findMany: vi.fn().mockResolvedValue(rows), + create: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + findFirst: vi.fn().mockResolvedValue(null), + }; + const tx = { + measurement, + $queryRaw: vi.fn().mockResolvedValue([]), + $executeRawUnsafe: vi.fn().mockResolvedValue(0), + } as unknown as Prisma.TransactionClient; + return { tx, measurement }; +} + +describe("reconcileExternalMeasurement", () => { + it("keeps the external-id row canonical when its desired natural key is occupied", async () => { + const external = row({ id: "external-row" }); + const natural = row({ + id: "natural-row", + externalId: "sleep:legacy", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + }); + const { tx, measurement } = transaction([external, natural]); + measurement.update.mockResolvedValue( + row({ + id: "external-row", + value: 42, + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + externalSourceVersion: "v2", + }), + ); + + const verdict = await reconcileExternalMeasurement(tx, desired()); + expect(verdict).toMatchObject({ + status: "updated", + row: { id: "external-row", externalId: "sleep:new" }, + retiredCollisionId: "natural-row", + }); + expect(measurement.deleteMany).not.toHaveBeenCalled(); + expect(measurement.update).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: "external-row" }, + data: expect.objectContaining({ + value: 42, + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + externalId: "sleep:new", + externalSourceVersion: "v2", + deviceType: "ring", + deletedAt: null, + }), + }), + ); + }); + + it("retires the redundant collision as a durable tombstone", async () => { + const external = row({ id: "external-row" }); + const natural = row({ + id: "natural-row", + externalId: "sleep:legacy", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + }); + const { tx, measurement } = transaction([external, natural]); + measurement.update + .mockResolvedValueOnce( + row({ + id: "natural-row", + externalId: "sleep:legacy", + measuredAt: new Date(0), + deletedAt: expect.any(Date), + }), + ) + .mockResolvedValueOnce( + row({ + id: "external-row", + value: 42, + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + }), + ); + + const verdict = await reconcileExternalMeasurement(tx, desired()); + + expect(verdict).toMatchObject({ + status: "updated", + retiredCollisionId: "natural-row", + }); + expect(measurement.deleteMany).not.toHaveBeenCalled(); + expect(measurement.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: "natural-row" }, + data: expect.objectContaining({ + measuredAt: new Date(0), + }), + }), + ); + }); + + it("resurrects a tombstoned natural-key row and adopts the desired external identity", async () => { + const tombstone = row({ + id: "tombstone-row", + externalId: "sleep:legacy", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + deletedAt: new Date("2026-07-20T08:00:00.000Z"), + }); + const { tx, measurement } = transaction([tombstone]); + measurement.update.mockResolvedValue( + row({ + id: "tombstone-row", + value: 42, + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + deletedAt: null, + }), + ); + + const verdict = await reconcileExternalMeasurement(tx, desired()); + + expect(verdict).toMatchObject({ + status: "resurrected", + row: { id: "tombstone-row", externalId: "sleep:new" }, + }); + }); + + it("returns duplicate for an immutable exact external/natural match", async () => { + const existing = row({ + id: "exact-row", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + }); + const { tx, measurement } = transaction([existing]); + + const verdict = await reconcileExternalMeasurement(tx, desired(), { + exactExternalMatch: "duplicate", + }); + + expect(verdict).toMatchObject({ + status: "duplicate", + row: { id: "exact-row" }, + }); + expect(measurement.update).not.toHaveBeenCalled(); + expect(measurement.deleteMany).not.toHaveBeenCalled(); + }); + + it("keeps an immutable exact-match tombstone deleted", async () => { + const tombstone = row({ + id: "deleted-sample", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + deletedAt: new Date("2026-07-20T08:00:00.000Z"), + }); + const { tx, measurement } = transaction([tombstone]); + + const verdict = await reconcileExternalMeasurement(tx, desired(), { + exactExternalMatch: "duplicate", + }); + + expect(verdict.status).toBe("duplicate"); + expect(measurement.update).not.toHaveBeenCalled(); + }); + + it("rolls back its savepoint and returns failed for a non-benign write error", async () => { + const existing = row({ + id: "exact-row", + measuredAt: new Date("2026-07-20T06:30:00.000Z"), + }); + const { tx, measurement } = transaction([existing]); + measurement.update.mockRejectedValue(new Error("connection reset")); + + const verdict = await reconcileExternalMeasurement(tx, desired()); + + expect(verdict).toEqual({ + status: "failed", + error: { message: "connection reset" }, + }); + expect(tx.$executeRawUnsafe).toHaveBeenCalledWith( + "ROLLBACK TO SAVEPOINT measurement_identity_reconcile", + ); + }); +}); diff --git a/src/lib/measurements/reconcile-external-measurement.ts b/src/lib/measurements/reconcile-external-measurement.ts new file mode 100644 index 000000000..95ed27a51 --- /dev/null +++ b/src/lib/measurements/reconcile-external-measurement.ts @@ -0,0 +1,296 @@ +import { + Prisma, + type Measurement, + type Prisma as PrismaTypes, +} from "@/generated/prisma/client"; + +const SAVEPOINT = "measurement_identity_reconcile"; + +const rowSelect = { + id: true, + userId: true, + type: true, + source: true, + measuredAt: true, + sleepStage: true, + externalId: true, + deletedAt: true, +} satisfies PrismaTypes.MeasurementSelect; + +export type ExternalMeasurementWrite = Omit< + PrismaTypes.MeasurementUncheckedCreateInput, + | "id" + | "createdAt" + | "updatedAt" + | "syncVersion" + | "deletedAt" + | "externalId" + | "measuredAt" +> & { + externalId: string; + measuredAt: Date; +}; + +export type ReconciledMeasurementRow = Pick< + Measurement, + "id" | "type" | "measuredAt" | "externalId" +>; + +export type DirtyMeasurementIdentity = Pick< + Measurement, + "type" | "measuredAt" +>; + +export type MeasurementReconciliationVerdict = + | { + status: "inserted" | "updated" | "resurrected" | "duplicate"; + row: ReconciledMeasurementRow; + retiredCollisionId?: string; + dirtyIdentities?: DirtyMeasurementIdentity[]; + } + | { + status: "failed"; + error: { message: string; code?: string }; + }; + +export type FailedMeasurementReconciliation = Extract< + MeasurementReconciliationVerdict, + { status: "failed" } +>; + +export class MeasurementReconciliationError extends Error { + readonly verdict: FailedMeasurementReconciliation; + + constructor(verdict: FailedMeasurementReconciliation) { + super(verdict.error.message); + this.name = "MeasurementReconciliationError"; + this.verdict = verdict; + } +} + +export interface MeasurementReconciliationOptions { + /** + * Provider re-scores overwrite an exact identity match. Immutable sample + * uploads instead report an exact live match as a duplicate. Identity moves, + * collision merges, and tombstone resurrection always reconcile. + */ + exactExternalMatch?: "update" | "duplicate"; +} + +function errorVerdict(err: unknown): MeasurementReconciliationVerdict { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return { status: "failed", error: { message, code: err.code } }; + } + return { status: "failed", error: { message } }; +} + +function sameNaturalIdentity( + row: Pick, + desired: ExternalMeasurementWrite, +): boolean { + return ( + row.measuredAt.getTime() === desired.measuredAt.getTime() && + row.sleepStage === (desired.sleepStage ?? null) + ); +} + +function lockKeys(desired: ExternalMeasurementWrite): string[] { + const prefix = [desired.userId, desired.type, desired.source]; + return [ + JSON.stringify([...prefix, "external", desired.externalId]), + JSON.stringify([ + ...prefix, + "natural", + desired.measuredAt.toISOString(), + desired.sleepStage ?? null, + ]), + ].sort(); +} + +async function lockUser( + tx: PrismaTypes.TransactionClient, + userId: string, +): Promise { + const key = JSON.stringify(["measurement-reconcile-user", userId]); + await tx.$queryRaw( + Prisma.sql` + SELECT 1::int AS "locked" + FROM ( + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0)) + ) AS user_lock + `, + ); +} + +async function lockAndReloadCandidates( + tx: PrismaTypes.TransactionClient, + desired: ExternalMeasurementWrite, +): Promise>> { + const where = { + userId: desired.userId, + OR: [ + { + type: desired.type, + source: desired.source, + externalId: desired.externalId, + }, + { + type: desired.type, + source: desired.source, + measuredAt: desired.measuredAt, + sleepStage: desired.sleepStage ?? null, + }, + ], + } satisfies PrismaTypes.MeasurementWhereInput; + const candidates = await tx.measurement.findMany({ + where, + select: rowSelect, + }); + if (candidates.length > 0) { + await tx.$queryRaw( + Prisma.sql` + SELECT "id" + FROM "measurements" + WHERE "id" IN (${Prisma.join(candidates.map((row) => row.id))}) + FOR UPDATE + `, + ); + } + return tx.measurement.findMany({ where, select: rowSelect }); +} + +async function retireCollision( + tx: PrismaTypes.TransactionClient, + row: PrismaTypes.MeasurementGetPayload<{ select: typeof rowSelect }>, +): Promise { + let measuredAt = new Date(0); + while ( + await tx.measurement.findFirst({ + where: { + id: { not: row.id }, + userId: row.userId, + type: row.type, + source: row.source, + measuredAt, + sleepStage: row.sleepStage, + }, + select: { id: true }, + }) + ) { + measuredAt = new Date(measuredAt.getTime() + 1); + } + await tx.measurement.update({ + where: { id: row.id }, + data: { + measuredAt, + deletedAt: new Date(), + syncVersion: { increment: 1 }, + }, + select: rowSelect, + }); +} + +/** + * Atomically reconcile the two full unique identities held by Measurement. + * + * The caller supplies an interactive Prisma transaction. A transaction-local + * advisory lock serializes cooperative writers sharing either identity, and a + * savepoint turns a non-benign database error into a structured `failed` + * verdict without poisoning the caller's surrounding transaction. + */ +export async function reconcileExternalMeasurement( + tx: PrismaTypes.TransactionClient, + desired: ExternalMeasurementWrite, + options: MeasurementReconciliationOptions = {}, +): Promise { + await tx.$executeRawUnsafe(`SAVEPOINT ${SAVEPOINT}`); + + try { + await lockUser(tx, desired.userId); + for (const key of lockKeys(desired)) { + await tx.$queryRaw( + Prisma.sql` + SELECT 1::int AS "locked" + FROM ( + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0)) + ) AS identity_lock + `, + ); + } + + const candidates = await lockAndReloadCandidates(tx, desired); + + const externalHit = candidates.find( + (row) => row.externalId === desired.externalId, + ); + const naturalHit = candidates.find((row) => + sameNaturalIdentity(row, desired), + ); + + if (!externalHit && !naturalHit) { + const created = await tx.measurement.create({ + data: { + ...desired, + sleepStage: desired.sleepStage ?? null, + deletedAt: null, + }, + select: rowSelect, + }); + await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`); + return { status: "inserted", row: created }; + } + + const canonical = externalHit ?? naturalHit!; + const redundant = + externalHit && naturalHit && externalHit.id !== naturalHit.id + ? naturalHit + : undefined; + const exactExternalMatch = externalHit?.id === naturalHit?.id; + + if ( + options.exactExternalMatch === "duplicate" && + exactExternalMatch && + !redundant + ) { + await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`); + return { status: "duplicate", row: canonical }; + } + + if (redundant) { + await retireCollision(tx, redundant); + } + + const { userId: _ownerId, ...mutable } = desired; + const updated = await tx.measurement.update({ + where: { id: canonical.id }, + data: { + ...mutable, + sleepStage: desired.sleepStage ?? null, + deletedAt: null, + syncVersion: { increment: 1 }, + }, + select: rowSelect, + }); + await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`); + return { + status: canonical.deletedAt === null ? "updated" : "resurrected", + row: updated, + dirtyIdentities: [ + { type: canonical.type, measuredAt: canonical.measuredAt }, + ...(redundant + ? [{ type: redundant.type, measuredAt: redundant.measuredAt }] + : []), + ], + ...(redundant ? { retiredCollisionId: redundant.id } : {}), + }; + } catch (err) { + try { + await tx.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${SAVEPOINT}`); + await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${SAVEPOINT}`); + } catch { + // The original database error is the actionable failure verdict. + } + return errorVerdict(err); + } +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 23ad8f028..b02c6c217 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -121,9 +121,9 @@ const batchEntryResult = z .object({ index: z.number().int().nonnegative(), status: z - .enum(["inserted", "updated", "duplicate", "skipped"]) + .enum(["inserted", "updated", "duplicate", "skipped", "failed"]) .describe( - "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`.", + "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must not advance the entry cursor; the response is marked `Cache-Control: no-store`.", ), reason: z.string().optional(), }) @@ -135,6 +135,7 @@ const batchResponse = z inserted: z.number().int().nonnegative(), updated: z.number().int().nonnegative(), duplicates: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), skipped: z.array( z.object({ index: z.number().int().nonnegative(), diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 8c3a77fce..af6578fee 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -23,6 +23,8 @@ const { recordFailureMock, recomputeMock, invalidateMock, + transactionMock, + reconcileMock, } = vi.hoisted(() => ({ getConnMock: vi.fn(), storeTokensMock: vi.fn(), @@ -46,6 +48,8 @@ const { recordFailureMock: vi.fn(), recomputeMock: vi.fn(), invalidateMock: vi.fn(), + transactionMock: vi.fn(), + reconcileMock: vi.fn(), })); vi.mock("../credentials", () => ({ @@ -62,9 +66,15 @@ vi.mock("@/lib/db", () => ({ findMany: findManyMock, updateMany: updateManyMock, }, + $transaction: transactionMock, }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: reconcileMock, + MeasurementReconciliationError: class extends Error {}, +})); + vi.mock("@/lib/integrations/status", async (importOriginal) => ({ ...(await importOriginal()), recordSyncSuccess: recordSuccessMock, @@ -129,6 +139,20 @@ beforeEach(() => { fetchResilienceMock.mockReset().mockResolvedValue([]); fetchCyclePhasesMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); + transactionMock + .mockReset() + .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); + reconcileMock.mockReset().mockImplementation( + async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, @@ -168,7 +192,7 @@ function err401() { } function createdRows() { - return createManyAndReturnMock.mock.calls.flatMap((call) => call[0].data); + return reconcileMock.mock.calls.map((call) => call[1]); } describe("syncUserOura", () => { @@ -327,7 +351,7 @@ describe("syncUserOura", () => { // Replace-then-write: the sweep runs before the insert statement. expect(updateManyMock.mock.invocationCallOrder[0]!).toBeLessThan( - createManyAndReturnMock.mock.invocationCallOrder[0]!, + reconcileMock.mock.invocationCallOrder[0]!, ); }); @@ -408,7 +432,7 @@ describe("syncUserOura", () => { ]); const imported = await syncUserOura("u1"); expect(imported).toBe(0); - expect(createManyAndReturnMock).not.toHaveBeenCalled(); + expect(reconcileMock).not.toHaveBeenCalled(); }); it("re-sync of the same day upserts RESILIENCE in place (overwrite, not duplicate)", async () => { @@ -421,26 +445,34 @@ describe("syncUserOura", () => { { id: "r", day: "2026-06-10", level: "adequate" }, ]) .mockResolvedValueOnce([{ id: "r", day: "2026-06-10", level: "solid" }]); - createManyAndReturnMock - .mockResolvedValueOnce([ - { + reconcileMock + .mockResolvedValueOnce({ + status: "inserted", + row: { id: "inserted-resilience", type: "RESILIENCE", measuredAt: new Date("2026-06-10T00:00:00.000Z"), externalId: "resilience:2026-06-10:resilience", }, - ]) - .mockResolvedValueOnce([]); + }) + .mockResolvedValueOnce({ + status: "updated", + row: { + id: "inserted-resilience", + type: "RESILIENCE", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + externalId: "resilience:2026-06-10:resilience", + }, + }); await syncUserOura("u1"); await syncUserOura("u1"); - const createCalls = createManyAndReturnMock.mock.calls; - expect(createCalls.map((call) => call[0].data[0].externalId)).toEqual([ + const writes = reconcileMock.mock.calls.map((call) => call[1]); + expect(writes.map((row) => row.externalId)).toEqual([ "resilience:2026-06-10:resilience", "resilience:2026-06-10:resilience", ]); - // The second sync carries the re-scored value through the update branch. - expect(createCalls[0]![0].data[0].value).toBe(2); - expect(updateMock.mock.calls[0]![0].data.value).toBe(3); + expect(writes[0].value).toBe(2); + expect(writes[1].value).toBe(3); }); it("does NOT refresh on a 403 (not an expiry case)", async () => { @@ -599,7 +631,17 @@ describe("upsertOuraMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - createManyAndReturnMock.mockResolvedValue([inserted]); + reconcileMock + .mockResolvedValueOnce({ status: "inserted", row: inserted }) + .mockResolvedValueOnce({ + status: "updated", + row: { + id: "existing-2", + type: "HRV_RMSSD", + measuredAt, + externalId: readings[1].externalId, + }, + }); const onInserted = vi.fn(); expect(await upsertOuraMeasurements("u1", readings, { onInserted })).toBe( @@ -608,10 +650,7 @@ describe("upsertOuraMeasurements — exact insertion results", () => { expect(onInserted).toHaveBeenCalledWith([inserted]); expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "oura"); - expect(updateMock).toHaveBeenCalledTimes(1); - expect( - updateMock.mock.calls[0]![0].where.userId_type_source_externalId, - ).toEqual({ + expect(reconcileMock.mock.calls[1]![1]).toMatchObject({ userId: "u1", type: "HRV_RMSSD", source: "OURA", @@ -627,7 +666,7 @@ describe("upsertOuraMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - createManyAndReturnMock.mockResolvedValue([inserted]); + reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted }); await upsertOuraMeasurements("u1", [readings[0]]); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 1fb2c5c95..c534fabc1 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -27,9 +27,9 @@ * Idempotency: `externalId = ::` for the day-keyed * collections; sleep rows carry a record-scoped `sleep::` * key (per-segment timeline + nightly scalars) so a nap and the main sleep on - * one day stay distinct instead of overwriting each other (B2). Upsert keyed on - * `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores - * after the night, so the `update` branch overwrites in place (re-score). + * one day stay distinct instead of overwriting each other (B2). The shared + * reconciler protects both external and natural identity. Oura finalises a + * day's scores after the night, so an exact re-post overwrites in place. * * The measurement-write tail mirrors the shared WHOOP / Nightscout sync tail. */ @@ -56,6 +56,10 @@ import { sweepStaleSleepSegments, type SleepSegmentSweep, } from "@/lib/sleep/sweep-stale-segments"; +import { + MeasurementReconciliationError, + reconcileExternalMeasurement, +} from "@/lib/measurements/reconcile-external-measurement"; import { fetchCardiovascularAge, fetchDailyActivity, @@ -453,76 +457,46 @@ export async function upsertOuraMeasurements( let imported = 0; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; - let insertedRows: Array< + const insertedRows: Array< InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - try { - insertedRows = await prisma.measurement.createManyAndReturn({ - data: readings.map((r) => ({ - userId, - type: r.type as MeasurementType, - source: "OURA" as const, - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - })), - skipDuplicates: true, - select: { - id: true, - type: true, - measuredAt: true, - externalId: true, - }, - }); - imported += insertedRows.length; - for (const row of insertedRows) { - touched.push({ type: row.type, measuredAt: row.measuredAt }); - } - } catch (err) { - getEvent()?.addWarning(`oura: failed to create measurements: ${err}`); - } - - const insertedIdentityCounts = new Map(); - for (const row of insertedRows) { - const key = `${row.type}:${row.externalId ?? ""}`; - insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1); - } - - for (const r of readings) { - const type = r.type as MeasurementType; - const key = `${type}:${r.externalId}`; - const insertedCount = insertedIdentityCounts.get(key) ?? 0; - if (insertedCount > 0) { - insertedIdentityCounts.set(key, insertedCount - 1); - continue; - } - - try { - await prisma.measurement.update({ - where: { - userId_type_source_externalId: { - userId, - type, - source: "OURA", - externalId: r.externalId, - }, - }, - data: { + const verdicts = await prisma.$transaction(async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "OURA", value: r.value, unit: r.unit, measuredAt: r.measuredAt, + externalId: r.externalId, sleepStage: r.sleepStage ?? null, - deletedAt: null, - syncVersion: { increment: 1 }, }, - }); - touched.push({ type, measuredAt: r.measuredAt }); - imported++; - } catch (err) { - getEvent()?.addWarning(`oura: failed to update measurement: ${err}`); + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); + } + return outcomes; + }, { timeout: 60_000 }); + + for (let index = 0; index < readings.length; index++) { + const reading = readings[index]!; + const verdict = verdicts[index]!; + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + const type = reading.type as MeasurementType; + imported++; + touched.push({ type, measuredAt: reading.measuredAt }); + if (verdict.status === "inserted") { + insertedRows.push(verdict.row); } } diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index c00c2bb3c..da00e02cd 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -15,6 +15,8 @@ const { recordSuccessMock, recordFailureMock, recomputeMock, + transactionMock, + reconcileMock, invalidateMock, } = vi.hoisted(() => ({ getConnMock: vi.fn(), @@ -31,6 +33,8 @@ const { recordSuccessMock: vi.fn(), recordFailureMock: vi.fn(), recomputeMock: vi.fn(), + transactionMock: vi.fn(), + reconcileMock: vi.fn(), invalidateMock: vi.fn(), })); @@ -46,9 +50,15 @@ vi.mock("@/lib/db", () => ({ findMany: findManyMock, updateMany: updateManyMock, }, + $transaction: transactionMock, }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: reconcileMock, + MeasurementReconciliationError: class extends Error {}, +})); + vi.mock("@/lib/integrations/status", async (importOriginal) => ({ ...(await importOriginal()), recordSyncSuccess: recordSuccessMock, @@ -93,6 +103,20 @@ beforeEach(() => { fetchActivitiesMock.mockReset(); fetchCardioLoadsMock.mockReset(); fetchSpo2Mock.mockReset(); + transactionMock + .mockReset() + .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); + reconcileMock.mockReset().mockImplementation( + async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, @@ -128,7 +152,7 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()); function createdRows() { - return createManyAndReturnMock.mock.calls.flatMap((call) => call[0].data); + return reconcileMock.mock.calls.map((call) => call[1]); } describe("syncUserPolar", () => { @@ -245,7 +269,7 @@ describe("syncUserPolar", () => { // Replace-then-write: the sweep runs before the insert statement. expect(updateManyMock.mock.invocationCallOrder[0]!).toBeLessThan( - createManyAndReturnMock.mock.invocationCallOrder[0]!, + reconcileMock.mock.invocationCallOrder[0]!, ); }); @@ -263,21 +287,29 @@ describe("syncUserPolar", () => { fetchRechargesMock.mockResolvedValue([ { date: "2026-06-10", nightly_recharge_status: 3 }, ]); - createManyAndReturnMock - .mockResolvedValueOnce([ - { + reconcileMock + .mockResolvedValueOnce({ + status: "inserted", + row: { id: "inserted-recharge", type: "RECOVERY_SCORE", measuredAt: new Date("2026-06-10T00:00:00.000Z"), externalId: "recharge:2026-06-10:recovery", }, - ]) - .mockResolvedValueOnce([]); + }) + .mockResolvedValueOnce({ + status: "updated", + row: { + id: "inserted-recharge", + type: "RECOVERY_SCORE", + measuredAt: new Date("2026-06-10T00:00:00.000Z"), + externalId: "recharge:2026-06-10:recovery", + }, + }); await syncUserPolar("u1"); await syncUserPolar("u1"); - expect(createManyAndReturnMock.mock.calls[0]![0].data[0].externalId).toBe( - updateMock.mock.calls[0]![0].where.userId_type_source_externalId - .externalId, + expect(reconcileMock.mock.calls[0]![1].externalId).toBe( + reconcileMock.mock.calls[1]![1].externalId, ); }); @@ -330,7 +362,17 @@ describe("upsertPolarMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - createManyAndReturnMock.mockResolvedValue([inserted]); + reconcileMock + .mockResolvedValueOnce({ status: "inserted", row: inserted }) + .mockResolvedValueOnce({ + status: "updated", + row: { + id: "existing-2", + type: "CARDIO_LOAD", + measuredAt, + externalId: readings[1].externalId, + }, + }); const onInserted = vi.fn(); expect(await upsertPolarMeasurements("u1", readings, { onInserted })).toBe( @@ -339,10 +381,7 @@ describe("upsertPolarMeasurements — exact insertion results", () => { expect(onInserted).toHaveBeenCalledWith([inserted]); expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "polar"); - expect(updateMock).toHaveBeenCalledTimes(1); - expect( - updateMock.mock.calls[0]![0].where.userId_type_source_externalId, - ).toEqual({ + expect(reconcileMock.mock.calls[1]![1]).toMatchObject({ userId: "u1", type: "CARDIO_LOAD", source: "POLAR", @@ -358,7 +397,7 @@ describe("upsertPolarMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - createManyAndReturnMock.mockResolvedValue([inserted]); + reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted }); await upsertPolarMeasurements("u1", [readings[0]]); diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index ddd8f4a50..f321239a5 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -12,10 +12,10 @@ * as a 401 → `reauth_required` on the shared integration ledger (`polar` key), * exactly like Nightscout's token-rejected path. * - * Idempotency: each row's `externalId` is `:` and the write is - * an upsert keyed on `(userId, type, source = POLAR, externalId)`. Polar - * re-scores a night for a short window after the fact, so the `update` branch - * overwrites in place (WHOOP-style re-score), not first-write-wins. + * Idempotency: each row's `externalId` is `:`. The shared + * reconciler protects both external and natural identity. Polar re-scores a + * night for a short window after the fact, so an exact re-post overwrites in + * place (WHOOP-style re-score), not first-write-wins. * * The measurement-write tail (per-row upsert → rollup fold → status-insight * invalidate) mirrors the shared WHOOP / Nightscout sync tail; it is NOT a new @@ -40,6 +40,10 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; +import { + MeasurementReconciliationError, + reconcileExternalMeasurement, +} from "@/lib/measurements/reconcile-external-measurement"; import { fetchActivities, fetchCardioLoads, @@ -187,8 +191,9 @@ export async function syncUserPolar(userId: string): Promise { /** * Upsert a batch of mapped Polar readings, then fold the rollup tier + * invalidate status-insight caches once at the end (mirrors the WHOOP / - * Nightscout sync tail). Idempotent: the `(userId, type, source, externalId)` - * unique key makes a re-post overwrite in place. Best-effort on the rollup fold. + * Nightscout sync tail). The shared reconciler protects both external and + * natural identity; an exact re-post overwrites in place. Best-effort on the + * rollup fold. */ export async function upsertPolarMeasurements( userId: string, @@ -201,76 +206,46 @@ export async function upsertPolarMeasurements( let imported = 0; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; - let insertedRows: Array< + const insertedRows: Array< InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - try { - insertedRows = await prisma.measurement.createManyAndReturn({ - data: readings.map((r) => ({ - userId, - type: r.type as MeasurementType, - source: "POLAR" as const, - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - })), - skipDuplicates: true, - select: { - id: true, - type: true, - measuredAt: true, - externalId: true, - }, - }); - imported += insertedRows.length; - for (const row of insertedRows) { - touched.push({ type: row.type, measuredAt: row.measuredAt }); - } - } catch (err) { - getEvent()?.addWarning(`polar: failed to create measurements: ${err}`); - } - - const insertedIdentityCounts = new Map(); - for (const row of insertedRows) { - const key = `${row.type}:${row.externalId ?? ""}`; - insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1); - } - - for (const r of readings) { - const type = r.type as MeasurementType; - const key = `${type}:${r.externalId}`; - const insertedCount = insertedIdentityCounts.get(key) ?? 0; - if (insertedCount > 0) { - insertedIdentityCounts.set(key, insertedCount - 1); - continue; - } - - try { - await prisma.measurement.update({ - where: { - userId_type_source_externalId: { - userId, - type, - source: "POLAR", - externalId: r.externalId, - }, - }, - data: { + const verdicts = await prisma.$transaction(async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "POLAR", value: r.value, unit: r.unit, measuredAt: r.measuredAt, + externalId: r.externalId, sleepStage: r.sleepStage ?? null, - deletedAt: null, - syncVersion: { increment: 1 }, }, - }); - touched.push({ type, measuredAt: r.measuredAt }); - imported++; - } catch (err) { - getEvent()?.addWarning(`polar: failed to update measurement: ${err}`); + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); + } + return outcomes; + }, { timeout: 60_000 }); + + for (let index = 0; index < readings.length; index++) { + const reading = readings[index]!; + const verdict = verdicts[index]!; + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + const type = reading.type as MeasurementType; + imported++; + touched.push({ type, measuredAt: reading.measuredAt }); + if (verdict.status === "inserted") { + insertedRows.push(verdict.row); } } diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index c9c1d0acf..91dff2c21 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -16,6 +16,7 @@ const { recordSyncSuccess, isReauthRequired, emitArrivalMock, + reconcileMock, } = vi.hoisted(() => ({ prismaMock: { whoopConnection: { @@ -30,6 +31,7 @@ const { findMany: vi.fn(), }, $executeRaw: vi.fn(), + $transaction: vi.fn(), }, refreshAccessTokenMock: vi.fn(), fetchRecoveriesMock: vi.fn(), @@ -38,6 +40,7 @@ const { isReauthRequired: vi.fn<(...a: unknown[]) => Promise>( async () => false, ), + reconcileMock: vi.fn(), emitArrivalMock: vi.fn(), })); @@ -57,6 +60,11 @@ vi.mock("../client", async (orig) => { }; }); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: reconcileMock, + MeasurementReconciliationError: class extends Error {}, +})); + vi.mock("../credentials", () => ({ getUserWhoopCredentials: vi.fn(async () => ({ clientId: "cid", @@ -105,6 +113,20 @@ import { syncUserRecovery } from "../sync-recovery"; beforeEach(() => { vi.clearAllMocks(); + prismaMock.$transaction.mockImplementation(async (run: (tx: unknown) => unknown) => + run({}), + ); + reconcileMock.mockImplementation( + async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); isReauthRequired.mockResolvedValue(false); emitArrivalMock.mockResolvedValue(undefined); }); @@ -371,8 +393,17 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - prismaMock.measurement.createManyAndReturn.mockResolvedValue([inserted]); - prismaMock.measurement.update.mockResolvedValue({}); + reconcileMock + .mockResolvedValueOnce({ status: "inserted", row: inserted }) + .mockResolvedValueOnce({ + status: "updated", + row: { + id: "existing-2", + type: "HRV_RMSSD", + measuredAt, + externalId: readings[1].externalId, + }, + }); const onInserted = vi.fn(); expect( @@ -381,11 +412,7 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { expect(onInserted).toHaveBeenCalledWith([inserted]); expect(emitArrivalMock).toHaveBeenCalledWith("user1", [inserted], "whoop"); - expect(prismaMock.measurement.update).toHaveBeenCalledTimes(1); - expect( - prismaMock.measurement.update.mock.calls[0]![0].where - .userId_type_source_externalId, - ).toEqual({ + expect(reconcileMock.mock.calls[1]![1]).toMatchObject({ userId: "user1", type: "HRV_RMSSD", source: "WHOOP", @@ -403,7 +430,7 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { measuredAt, externalId: readings[0].externalId, }; - prismaMock.measurement.createManyAndReturn.mockResolvedValue([inserted]); + reconcileMock.mockResolvedValueOnce({ status: "inserted", row: inserted }); await upsertWhoopMeasurements("user1", [readings[0]]); @@ -412,20 +439,23 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { }); it("updates a re-post in place without reporting another insert", async () => { - prismaMock.measurement.createManyAndReturn.mockResolvedValue([]); - prismaMock.measurement.update.mockResolvedValue({}); + reconcileMock.mockResolvedValueOnce({ + status: "updated", + row: { + id: "existing-1", + type: "RECOVERY_SCORE", + measuredAt, + externalId: readings[0].externalId, + }, + }); expect(await upsertWhoopMeasurements("user1", [readings[0]])).toBe(1); expect(emitArrivalMock).toHaveBeenCalledWith("user1", [], "whoop"); - expect(prismaMock.measurement.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - value: 71, - syncVersion: { increment: 1 }, - }), - }), - ); + expect(reconcileMock.mock.calls[0]![1]).toMatchObject({ + value: 71, + externalId: readings[0].externalId, + }); }); }); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index d7043ae0c..c3802ba14 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -45,6 +45,10 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; +import { + MeasurementReconciliationError, + reconcileExternalMeasurement, +} from "@/lib/measurements/reconcile-external-measurement"; import { refreshAccessToken } from "./client"; import { getUserWhoopCredentials } from "./credentials"; import { @@ -324,8 +328,8 @@ export interface WhoopMeasurementUpsert { /** * Upsert a batch of mapped WHOOP readings for one user and fold the rollup * tier + invalidate status-insight caches once at the end (mirrors the - * Withings sync tail). Idempotent: the `(userId, type, source, externalId)` - * unique key makes a re-post (WHOOP re-score) overwrite in place. Returns the + * Withings sync tail). The shared reconciler protects both external and + * natural identity; an exact WHOOP re-score overwrites in place. Returns the * count of rows written. * * Best-effort on the rollup fold + insight invalidate — a populator hiccup @@ -342,76 +346,46 @@ export async function upsertWhoopMeasurements( let imported = 0; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; - let insertedRows: Array< + const insertedRows: Array< InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - try { - insertedRows = await prisma.measurement.createManyAndReturn({ - data: readings.map((r) => ({ - userId, - type: r.type as MeasurementType, - source: "WHOOP" as const, - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - })), - skipDuplicates: true, - select: { - id: true, - type: true, - measuredAt: true, - externalId: true, - }, - }); - imported += insertedRows.length; - for (const row of insertedRows) { - touched.push({ type: row.type, measuredAt: row.measuredAt }); - } - } catch (err) { - getEvent()?.addWarning(`WHOOP: failed to create measurements: ${err}`); - } - - const insertedIdentityCounts = new Map(); - for (const row of insertedRows) { - const key = `${row.type}:${row.externalId ?? ""}`; - insertedIdentityCounts.set(key, (insertedIdentityCounts.get(key) ?? 0) + 1); - } - - for (const r of readings) { - const type = r.type as MeasurementType; - const key = `${type}:${r.externalId}`; - const insertedCount = insertedIdentityCounts.get(key) ?? 0; - if (insertedCount > 0) { - insertedIdentityCounts.set(key, insertedCount - 1); - continue; - } - - try { - await prisma.measurement.update({ - where: { - userId_type_source_externalId: { - userId, - type, - source: "WHOOP", - externalId: r.externalId, - }, - }, - data: { + const verdicts = await prisma.$transaction(async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "WHOOP", value: r.value, unit: r.unit, measuredAt: r.measuredAt, + externalId: r.externalId, sleepStage: r.sleepStage ?? null, - deletedAt: null, - syncVersion: { increment: 1 }, }, - }); - touched.push({ type, measuredAt: r.measuredAt }); - imported++; - } catch (err) { - getEvent()?.addWarning(`WHOOP: failed to update measurement: ${err}`); + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); + } + return outcomes; + }, { timeout: 60_000 }); + + for (let index = 0; index < readings.length; index++) { + const reading = readings[index]!; + const verdict = verdicts[index]!; + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + const type = reading.type as MeasurementType; + imported++; + touched.push({ type, measuredAt: reading.measuredAt }); + if (verdict.status === "inserted") { + insertedRows.push(verdict.row); } } diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index f2c6b8912..dd5271afd 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -8,6 +8,11 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { reconcileMock, transactionMock } = vi.hoisted(() => ({ + reconcileMock: vi.fn(), + transactionMock: vi.fn(), +})); + vi.mock("@/lib/db", () => ({ prisma: { measurement: { @@ -18,9 +23,15 @@ vi.mock("@/lib/db", () => ({ updateMany: vi.fn(), }, withingsConnection: { findUnique: vi.fn() }, + $transaction: transactionMock, }, })); +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: reconcileMock, + MeasurementReconciliationError: class extends Error {}, +})); + vi.mock("@/lib/integrations/status", () => ({ isReauthRequired: vi.fn().mockResolvedValue(false), parkIntegrationAtReauth: vi.fn(), @@ -96,6 +107,20 @@ function installFetchMock(segments: FakeSegment[]) { beforeEach(() => { vi.resetAllMocks(); + transactionMock.mockImplementation( + async (run: (tx: unknown) => unknown) => run({}), + ); + reconcileMock.mockImplementation( + async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); // v1.4.26 — every syncUserSleep call now reads the connection's // scope to short-circuit legacy `user.metrics`-only connections. // Default-mock to "scope is fine" so the existing segment-mapping @@ -249,16 +274,14 @@ describe("syncUserSleep — segment writes + idempotency", () => { const imported = await syncUserSleep("user-1"); expect(imported).toBe(4); - const stages = vi - .mocked(prisma.measurement.create) - .mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage); - expect(stages).toEqual(["CORE", "DEEP", "REM", "AWAKE"]); - - // First segment: 60 minutes (3600s / 60). - const firstDuration = vi.mocked(prisma.measurement.create).mock - .calls[0][0] as { data: { value: number; unit: string } }; - expect(firstDuration.data.value).toBe(60); - expect(firstDuration.data.unit).toBe("minutes"); + const writes = reconcileMock.mock.calls.map((call) => call[1]); + expect(writes.map((row) => row.sleepStage)).toEqual([ + "CORE", + "DEEP", + "REM", + "AWAKE", + ]); + expect(writes[0]).toMatchObject({ value: 60, unit: "minutes" }); }); it("stamps measuredAt at the segment END (enddate, unix seconds → Date)", async () => { @@ -269,13 +292,11 @@ describe("syncUserSleep — segment writes + idempotency", () => { vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); await syncUserSleep("user-1"); - const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { - data: { measuredAt: Date }; - }; + const row = reconcileMock.mock.calls[0]![1] as { measuredAt: Date }; // measuredAt is the segment END — every reader treats it as the END and // resolves the span as start = end − duration. Stamping the START shifted // the night one segment-length earlier. - expect(arg.data.measuredAt.getTime()).toBe(1715003600 * 1000); + expect(row.measuredAt.getTime()).toBe(1715003600 * 1000); }); it("skips state 4 (synthetic marker) without throwing", async () => { @@ -288,9 +309,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { const imported = await syncUserSleep("user-1"); expect(imported).toBe(1); - const stages = vi - .mocked(prisma.measurement.create) - .mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage); + const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage); expect(stages).toEqual(["DEEP"]); }); @@ -298,27 +317,23 @@ describe("syncUserSleep — segment writes + idempotency", () => { installFetchMock([ { startdate: 1715000000, enddate: 1715003600, state: 2, id: 1 }, ]); - // findFirst returns an existing row → writer takes the update path. - vi.mocked(prisma.measurement.findFirst).mockResolvedValue({ - id: "row-1", - } as never); - vi.mocked(prisma.measurement.update).mockResolvedValue({} as never); + reconcileMock.mockResolvedValueOnce({ + status: "updated", + row: { + id: "row-1", + type: "SLEEP_DURATION", + measuredAt: new Date(1715003600 * 1000), + externalId: "withings:sleep:user-1:1:1715000000", + }, + }); const imported = await syncUserSleep("user-1"); expect(imported).toBe(1); - expect(prisma.measurement.create).not.toHaveBeenCalled(); - // Re-sync keys on the stable externalId and refreshes value, the - // END-stamped measuredAt (enddate may shift) and the stage. - expect(prisma.measurement.update).toHaveBeenCalledWith({ - where: { id: "row-1" }, - data: { - value: 60, - measuredAt: new Date(1715003600 * 1000), - sleepStage: "DEEP", - // No-op on a live row, resurrection on a tombstoned one — the - // source-owned resurrect rule. - deletedAt: null, - }, + expect(reconcileMock.mock.calls[0]![1]).toMatchObject({ + value: 60, + measuredAt: new Date(1715003600 * 1000), + sleepStage: "DEEP", + externalId: "withings:sleep:user-1:1:1715000000", }); }); @@ -333,32 +348,13 @@ describe("syncUserSleep — segment writes + idempotency", () => { installFetchMock([ { startdate: 1715000500, enddate: 1715003600, state: 2, id: 42 }, ]); - vi.mocked(prisma.measurement.findFirst).mockImplementation((async (args: { - where: Record; - }) => { - const where = args.where; - // externalId probe (START shifted ⇒ new id) → miss. - if ("externalId" in where) return null; - // natural-key probe (END + stage unchanged) → the surviving prior row. - if ("measuredAt" in where) return { id: "old-row" }; - return null; - }) as never); - vi.mocked(prisma.measurement.update).mockResolvedValue({} as never); - vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); - const imported = await syncUserSleep("user-1"); expect(imported).toBe(1); - - // No collision: the survivor is re-keyed in place, never re-created. - expect(prisma.measurement.create).not.toHaveBeenCalled(); - expect(prisma.measurement.update).toHaveBeenCalledWith({ - where: { id: "old-row" }, - data: { - value: expect.any(Number), - // Re-keyed onto the shifted START-based externalId and resurrected. - externalId: "withings:sleep:user-1:42:1715000500", - deletedAt: null, - }, + expect(reconcileMock.mock.calls[0]![1]).toMatchObject({ + value: expect.any(Number), + measuredAt: new Date(1715003600 * 1000), + sleepStage: "DEEP", + externalId: "withings:sleep:user-1:42:1715000500", }); }); @@ -370,14 +366,12 @@ describe("syncUserSleep — segment writes + idempotency", () => { vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); await syncUserSleep("user-1"); - const arg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { - data: { externalId: string }; - }; + const row = reconcileMock.mock.calls[0]![1] as { externalId: string }; // Session id + the segment's own startdate — both stable within a session. // The retired running index counted across the whole rolling 30-day fetch // window, so the window slide renumbered every segment on each sync and a // re-aggregated night inserted a duplicate set. - expect(arg.data.externalId).toBe("withings:sleep:user-1:42:1715000000"); + expect(row.externalId).toBe("withings:sleep:user-1:42:1715000000"); }); it("mints IDENTICAL externalIds when the fetch window slides (re-fetch of the same night)", async () => { @@ -395,19 +389,18 @@ describe("syncUserSleep — segment writes + idempotency", () => { installFetchMock(night); await syncUserSleep("user-1"); - const firstIds = vi - .mocked(prisma.measurement.create) - .mock.calls.map((c) => (c[0].data as { externalId: string }).externalId); + const firstIds = reconcileMock.mock.calls.map( + (call) => call[1].externalId as string, + ); - vi.mocked(prisma.measurement.create).mockClear(); + reconcileMock.mockClear(); installFetchMock([ { startdate: 1714990000, enddate: 1714993600, state: 1, id: 7 }, ...night, ]); await syncUserSleep("user-1"); - const secondIds = vi - .mocked(prisma.measurement.create) - .mock.calls.map((c) => (c[0].data as { externalId: string }).externalId) + const secondIds = reconcileMock.mock.calls + .map((call) => call[1].externalId as string) .filter((id) => id.includes(":42:")); expect(secondIds).toEqual(firstIds); @@ -496,27 +489,22 @@ describe("syncUserSleep — segment writes + idempotency", () => { }, ], ); - vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never); + reconcileMock.mockResolvedValue({ status: "updated", row: {} }); const imported = await syncUserSleep("user-1"); // Five vitals upserted (no stage segments in this fixture). expect(imported).toBe(5); - const calls = vi.mocked(prisma.measurement.upsert).mock.calls; const byType = new Map( - calls.map((c) => { - const w = c[0] as { - where: { - userId_type_source_externalId: { type: string; externalId: string }; - }; - create: { value: number }; + reconcileMock.mock.calls.map((call) => { + const row = call[1] as { + type: string; + externalId: string; + value: number; }; return [ - w.where.userId_type_source_externalId.type, - { - externalId: w.where.userId_type_source_externalId.externalId, - value: w.create.value, - }, + row.type, + { externalId: row.externalId, value: row.value }, ]; }), ); @@ -565,7 +553,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { const imported = await syncUserSleep("user-1"); // The one stage segment is still written; the summary failure is swallowed. expect(imported).toBe(1); - expect(prisma.measurement.create).toHaveBeenCalledTimes(1); + expect(reconcileMock).toHaveBeenCalledTimes(1); expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings"); }); @@ -582,9 +570,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { const imported = await syncUserSleep("user-1"); expect(imported).toBe(2); - const stages = vi - .mocked(prisma.measurement.create) - .mock.calls.map((c) => (c[0].data as { sleepStage: string }).sleepStage); + const stages = reconcileMock.mock.calls.map((call) => call[1].sleepStage); expect(stages).not.toContain("REM"); }); }); @@ -601,7 +587,7 @@ describe("syncUserSleep — scope-skip guard (v1.4.26)", () => { expect(imported).toBe(0); expect(fetchMock).not.toHaveBeenCalled(); - expect(prisma.measurement.create).not.toHaveBeenCalled(); + expect(reconcileMock).not.toHaveBeenCalled(); expect(recordSyncSuccess).not.toHaveBeenCalled(); }); diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 00e14c5ee..6ba1cb710 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -54,6 +54,10 @@ import { sweepStaleSleepSegments, type SleepSegmentSweep, } from "@/lib/sleep/sweep-stale-segments"; +import { + MeasurementReconciliationError, + reconcileExternalMeasurement, +} from "@/lib/measurements/reconcile-external-measurement"; import { hasActivityScope } from "./client"; import { @@ -408,131 +412,48 @@ export async function syncUserSleep( // and are excluded (their `no-id` prefix would span every id-less night in // history, not just this fetch). const freshBySession = new Map(); - for (const segment of segments) { - const stage = mapWithingsSleepState(segment.state); - if (!stage) { - // State 4 (manual / synthetic marker) is ignored — see file - // header. - continue; - } - // `measuredAt` is the segment END (`enddate`). Every reader treats - // `measuredAt` as the END instant — `segmentOf` resolves the span as - // `start = measuredAt − value·60_000` (sleep-night.ts). Stamping the START - // here shifted the whole night one segment-length earlier; END is correct. - const measuredAt = new Date(segment.enddate * 1000); - const durationSec = Math.max(0, segment.enddate - segment.startdate); - // Canonical SLEEP_DURATION unit is minutes (Apple Health alignment, - // v1.4.23 schema note). Round so re-sync doesn't flip values on - // floating-point noise. - const minutes = Math.round(durationSec / 60); + await prisma.$transaction(async (tx) => { + for (const segment of segments) { + const stage = mapWithingsSleepState(segment.state); + if (!stage) continue; - // Keyed by the segment's own START instant — a coordinate that is stable - // within a session — NOT a positional index. The old running index counted - // across the whole 30-day fetch window, so the sliding window renumbered - // every segment on each sync: the probe below never matched, the update - // branch was dead code, and a re-aggregated night inserted a second set of - // rows next to the first (the same double-count bug class Google Health - // fixed with its stable session anchor). Legacy indexed rows are swept by - // the session-scoped cleanup below, so the format change self-heals. - const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; - if (typeof segment.id === "number") { - const fresh = freshBySession.get(segment.id) ?? []; - fresh.push(externalId); - freshBySession.set(segment.id, fresh); - } + const measuredAt = new Date(segment.enddate * 1000); + const durationSec = Math.max(0, segment.enddate - segment.startdate); + const minutes = Math.round(durationSec / 60); + const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; + if (typeof segment.id === "number") { + const fresh = freshBySession.get(segment.id) ?? []; + fresh.push(externalId); + freshBySession.set(segment.id, fresh); + } - try { - // Key the re-sync lookup on the STABLE `externalId` (session id + - // segment start), not `measuredAt`. Withings re-aggregates a night - // between syncs, so a segment's `enddate` — and therefore its - // END-stamped `measuredAt` — shifts; keying on `measuredAt` would miss - // the prior row and then collide with the unique `externalId`, leaving - // the night stuck at the stale value. Update both `value` and - // `measuredAt`. - const existing = await prisma.measurement.findFirst({ - where: { + const verdict = await reconcileExternalMeasurement( + tx, + { userId, type: SLEEP_TYPE, + value: minutes, + unit: getUnitForType(SLEEP_TYPE), + measuredAt, source: "WITHINGS", + sleepStage: stage, externalId, }, - select: { id: true }, - }); - if (existing) { - await prisma.measurement.update({ - where: { id: existing.id }, - // `deletedAt: null` is a no-op on a live row, a deliberate - // RESURRECTION on a tombstoned one — Withings is the source of - // truth for its own rows, so a re-fetched segment brings the row - // back (mirrors Google / Fitbit). - data: { - value: minutes, - measuredAt, - sleepStage: stage, - deletedAt: null, - }, - }); - } else { - // Natural-key rescue — mirrors the measure path's findFirst in - // `sync.ts`. When Withings re-scores a night it can shift a segment's - // START (its externalId) while the END (`measuredAt`) and the stage - // stay put: the externalId probe above then MISSES, but the 0055 - // natural key `(userId, type, measuredAt, source, sleepStage)` - // (NULLS NOT DISTINCT) is already occupied by the prior row. A blind - // `create` P2002s on that key; the collision is swallowed by the catch - // below and the sweep then tombstones the old row — so the re-keyed - // segment is lost and, because a tombstoned row still holds the natural - // key, every later sync re-collides: the night wedges permanently (the - // "0055 erased re-keyed sleep" class). Probe the natural key - // (tombstone-inclusive — no `deletedAt` filter) and re-key that row in - // place, so a re-scored segment updates rather than colliding. - const naturalKeyRow = await prisma.measurement.findFirst({ - where: { - userId, - type: SLEEP_TYPE, - source: "WITHINGS", - measuredAt, - sleepStage: stage, - }, - select: { id: true }, - }); - if (naturalKeyRow) { - await prisma.measurement.update({ - where: { id: naturalKeyRow.id }, - // Re-key the surviving row onto the new START-based externalId and - // resurrect it if the sweep had tombstoned it. `measuredAt` and - // `sleepStage` already match by construction (they ARE the natural - // key we matched on), so only value + externalId + deletedAt move. - data: { - value: minutes, - externalId, - deletedAt: null, - }, - }); - } else { - await prisma.measurement.create({ - data: { - userId, - type: SLEEP_TYPE, - value: minutes, - unit: getUnitForType(SLEEP_TYPE), - measuredAt, - source: "WITHINGS", - sleepStage: stage, - externalId, - }, - }); - insertedSleepMeasuredAts.push(measuredAt); - } + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + if (verdict.status === "inserted") { + insertedSleepMeasuredAts.push(measuredAt); } touched.push({ type: SLEEP_TYPE, measuredAt }); imported++; - } catch (err) { - getEvent()?.addWarning( - `Failed to upsert sleep segment (${stage}, ${measuredAt.toISOString()}): ${err}`, - ); } - } + }, { timeout: 60_000 }); // Session-scoped sweep (mirrors Google Health's replace-by-window): for each // session this fetch re-produced, soft-delete any live WITHINGS @@ -550,69 +471,51 @@ export async function syncUserSleep( await sweepStaleSleepSegments(userId, "WITHINGS", sweeps); // v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV - // / avg SpO2 / sleep score) from the per-night summary. A summary-fetch - // failure must not lose the stage segments already written, so it runs in - // its own try/catch and only annotates on error. Each vital upserts on the - // stable `(userId, type, source, externalId)` composite so a re-sync - // overwrites the night's value rather than inserting a duplicate. + // / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays + // isolated from the stage writes; database failures are hard sync failures. + let summaries: WithingsSleepSummary[] = []; try { - const startYmd = ymdUtc(start); - const endYmd = ymdUtc(now); - const summaries = await fetchWithingsSleepSummary( + summaries = await fetchWithingsSleepSummary( tokenInfo.accessToken, - startYmd, - endYmd, + ymdUtc(start), + ymdUtc(now), ); - for (const summary of summaries) { - const measuredAt = new Date(summary.enddate * 1000); - const sessionId = summary.id ?? "no-id"; - const vitals = mapWithingsSleepSummary(summary.data); - for (const vital of vitals) { - const externalId = `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`; - try { - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: vital.type, - source: "WITHINGS", - externalId, - }, - }, - create: { - userId, - type: vital.type, - value: vital.value, - unit: getUnitForType(vital.type), - measuredAt, - source: "WITHINGS", - externalId, - }, - update: { - value: vital.value, - measuredAt, - // No-op on a live row, a deliberate RESURRECTION on a - // tombstoned one — Withings is the source of truth for its - // own rows (mirrors Google / Fitbit). - deletedAt: null, - syncVersion: { increment: 1 }, - }, - }); - touched.push({ type: vital.type, measuredAt }); - imported++; - } catch (err) { - getEvent()?.addWarning( - `withings sleep: failed to upsert vital (${vital.type}, ${measuredAt.toISOString()}): ${err}`, - ); - } - } - } } catch (err) { getEvent()?.addWarning( `withings sleep: summary fetch failed for ${userId}: ${err}`, ); } + await prisma.$transaction(async (tx) => { + for (const summary of summaries) { + const measuredAt = new Date(summary.enddate * 1000); + const sessionId = summary.id ?? "no-id"; + for (const vital of mapWithingsSleepSummary(summary.data)) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: vital.type, + value: vital.value, + unit: getUnitForType(vital.type), + measuredAt, + source: "WITHINGS", + externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + touched.push({ type: vital.type, measuredAt }); + imported++; + } + } + }, { timeout: 60_000 }); + // v1.4.39.1 — refresh the persistent rollup table for every distinct // (type, day) the sync touched. Sleep segments collapse heavily — // ~10 stage rows per night land in one DAY recompute. diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts new file mode 100644 index 000000000..451c4b0f6 --- /dev/null +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + reconcileExternalMeasurement, + type ExternalMeasurementWrite, +} from "@/lib/measurements/reconcile-external-measurement"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +async function createUser(id: string) { + return getPrismaClient().user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + role: "USER", + }, + }); +} + +const measuredAt = new Date("2026-07-20T06:30:00.000Z"); + +function desired( + userId: string, + externalId: string, + overrides: Partial = {}, +): ExternalMeasurementWrite { + return { + userId, + type: "SLEEP_DURATION" as const, + value: 42, + unit: "minutes", + source: "OURA" as const, + measuredAt, + sleepStage: "DEEP" as const, + externalId, + externalSourceVersion: "v2", + deviceType: "ring", + ...overrides, + }; +} + +async function reconcile( + input: ExternalMeasurementWrite, + options?: { exactExternalMatch?: "update" | "duplicate" }, +) { + const prisma = getPrismaClient(); + return prisma.$transaction((tx) => + reconcileExternalMeasurement(tx, input, options), + ); +} + +describe("external measurement identity reconciliation (real Postgres)", () => { + it("moves an external-id hit onto an occupied natural key and retires the redundant row", async () => { + const prisma = getPrismaClient(); + const user = await createUser("identity-external-move"); + const external = await prisma.measurement.create({ + data: { + ...desired(user.id, "sleep:new"), + value: 30, + measuredAt: new Date("2026-07-20T06:00:00.000Z"), + externalSourceVersion: null, + }, + }); + const collision = await prisma.measurement.create({ + data: { + ...desired(user.id, "sleep:legacy"), + value: 35, + }, + }); + + const verdict = await reconcile(desired(user.id, "sleep:new")); + + expect(verdict).toMatchObject({ + status: "updated", + row: { id: external.id, externalId: "sleep:new" }, + retiredCollisionId: collision.id, + }); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.id === external.id)).toMatchObject({ + value: 42, + measuredAt, + externalId: "sleep:new", + externalSourceVersion: "v2", + deviceType: "ring", + deletedAt: null, + }); + expect(rows.find((row) => row.id === collision.id)).toMatchObject({ + externalId: "sleep:legacy", + deletedAt: expect.any(Date), + }); + }); + + it("adopts a live natural-key row when a new external id collides", async () => { + const prisma = getPrismaClient(); + const user = await createUser("identity-natural-live"); + const occupied = await prisma.measurement.create({ + data: { ...desired(user.id, "sleep:legacy"), value: 30 }, + }); + + const verdict = await reconcile(desired(user.id, "sleep:new")); + + expect(verdict).toMatchObject({ + status: "updated", + row: { id: occupied.id, externalId: "sleep:new" }, + }); + expect( + await prisma.measurement.count({ where: { userId: user.id } }), + ).toBe(1); + }); + + it("resurrects and re-keys a tombstone occupying the natural key", async () => { + const prisma = getPrismaClient(); + const user = await createUser("identity-natural-tombstone"); + const occupied = await prisma.measurement.create({ + data: { + ...desired(user.id, "sleep:legacy"), + value: 30, + deletedAt: new Date("2026-07-20T08:00:00.000Z"), + }, + }); + + const verdict = await reconcile(desired(user.id, "sleep:new")); + + expect(verdict).toMatchObject({ + status: "resurrected", + row: { id: occupied.id, externalId: "sleep:new" }, + }); + const stored = await prisma.measurement.findUniqueOrThrow({ + where: { id: occupied.id }, + }); + expect(stored.deletedAt).toBeNull(); + expect(stored.value).toBe(42); + }); + + it("never merges a row owned by another user", async () => { + const prisma = getPrismaClient(); + const first = await createUser("identity-owner-first"); + const second = await createUser("identity-owner-second"); + const other = await prisma.measurement.create({ + data: desired(first.id, "sleep:shared"), + }); + + const verdict = await reconcile(desired(second.id, "sleep:shared")); + + expect(verdict.status).toBe("inserted"); + expect(await prisma.measurement.count()).toBe(2); + expect( + await prisma.measurement.findUniqueOrThrow({ where: { id: other.id } }), + ).toMatchObject({ userId: first.id, value: 42 }); + }); + + it("serializes concurrent attempts that share the same natural identity", async () => { + const prisma = getPrismaClient(); + const user = await createUser("identity-concurrent"); + + const verdicts = await Promise.all([ + reconcile(desired(user.id, "sleep:race-a", { value: 41 })), + reconcile(desired(user.id, "sleep:race-b", { value: 43 })), + ]); + + expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true); + expect(verdicts.map((verdict) => verdict.status).sort()).toEqual([ + "inserted", + "updated", + ]); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id }, + }); + expect(rows).toHaveLength(1); + expect(["sleep:race-a", "sleep:race-b"]).toContain(rows[0]?.externalId); + expect([41, 43]).toContain(rows[0]?.value); + }); + + it("serializes crossing external and natural identity moves", async () => { + const prisma = getPrismaClient(); + const user = await createUser("identity-crossing-race"); + const firstTime = new Date("2026-07-20T06:00:00.000Z"); + const secondTime = new Date("2026-07-20T07:00:00.000Z"); + await prisma.measurement.create({ + data: desired(user.id, "sleep:race-a", { measuredAt: firstTime }), + }); + + const verdicts = await Promise.all([ + reconcile( + desired(user.id, "sleep:race-a", { + measuredAt: secondTime, + value: 41, + }), + ), + reconcile( + desired(user.id, "sleep:race-b", { + measuredAt: firstTime, + value: 43, + }), + ), + ]); + + expect(verdicts.every((verdict) => verdict.status !== "failed")).toBe(true); + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { measuredAt: "asc" }, + }); + expect(rows).toHaveLength(2); + expect(rows).toEqual([ + expect.objectContaining({ + externalId: "sleep:race-b", + measuredAt: firstTime, + value: 43, + }), + expect.objectContaining({ + externalId: "sleep:race-a", + measuredAt: secondTime, + value: 41, + }), + ]); + }); +}); diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index 2daded94f..cd2c24d40 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -326,27 +326,17 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stored.every((r) => r.unit === "minutes")).toBe(true); }); - // v1.4.25 W10 reconcile (senior-dev H-1): under contention the - // per-entry envelope MUST stay in sync with the aggregate - // `inserted` / `duplicate` counts. The previous reconciliation - // block was an effective no-op (it looked for rows that were - // "marked inserted but missing from the DB" — an impossibility - // because the raced row IS in the DB, written by the other - // batch). The corrected block downgrades enough "inserted" - // statuses to "duplicate" so the per-entry envelope and the - // aggregate counts agree, which the iOS sync cursor depends on - // to checkpoint correctly. + // Under contention every per-entry verdict must stay in sync with its + // aggregate counter. Inserted, updated, duplicate, skipped, and failed are + // all explicit because only the hard failure verdict must block checkpoint + // advancement. it("keeps per-entry status in sync with aggregate counts under a concurrent-write race", async () => { const { POST } = await import("@/app/api/measurements/batch/route"); - // Two batches with overlapping externalIds posted in parallel. - // The composite unique index ensures only one row per - // (user, type, source, externalId) lands; `skipDuplicates` - // absorbs duplicate-key conflicts. The exact split between - // "won the race" and "got absorbed" depends on Postgres's - // commit order, so we assert only the invariant the iOS sync - // cursor relies on: per-entry envelope sums equal the aggregate - // counts for each response. + // Two batches with overlapping external ids and the same natural identity + // are posted in parallel. The reconciler serializes both identity indexes; + // the exact inserted/updated split depends on commit order, but every + // response must remain internally consistent. const sharedEntries = () => Array.from({ length: 6 }, (_, i) => ({ hkIdentifier: "HKQuantityTypeIdentifierBodyMass", @@ -368,6 +358,8 @@ describe("POST /api/measurements/batch (real Postgres)", () => { data: { processed: number; inserted: number; + updated: number; + failed: number; duplicates: number; entries: Array<{ status: string }>; }; @@ -379,19 +371,31 @@ describe("POST /api/measurements/batch (real Postgres)", () => { // no-op reconciliation left the envelope out of sync. for (const json of [firstJson, secondJson]) { const insertedEntries = json.data.entries.filter( - (e) => e.status === "inserted", + (entry) => entry.status === "inserted", + ).length; + const updatedEntries = json.data.entries.filter( + (entry) => entry.status === "updated", ).length; const duplicateEntries = json.data.entries.filter( - (e) => e.status === "duplicate", + (entry) => entry.status === "duplicate", ).length; const skippedEntries = json.data.entries.filter( - (e) => e.status === "skipped", + (entry) => entry.status === "skipped", + ).length; + const failedEntries = json.data.entries.filter( + (entry) => entry.status === "failed", ).length; expect(insertedEntries).toBe(json.data.inserted); + expect(updatedEntries).toBe(json.data.updated); expect(duplicateEntries).toBe(json.data.duplicates); - expect(insertedEntries + duplicateEntries + skippedEntries).toBe( - json.data.processed, - ); + expect(failedEntries).toBe(json.data.failed); + expect( + insertedEntries + + updatedEntries + + duplicateEntries + + skippedEntries + + failedEntries, + ).toBe(json.data.processed); } // Invariant 2 — aggregate counts are non-negative. The previous @@ -400,14 +404,12 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(firstJson.data.inserted).toBeGreaterThanOrEqual(0); expect(secondJson.data.inserted).toBeGreaterThanOrEqual(0); - // Invariant 3 — the DB ends up with at most 6 rows because the - // composite unique index enforces single-copy, and the - // `inserted` counts across both requests cannot exceed the - // number of rows actually present. + // Invariant 3 — all samples share one natural identity, so exactly one + // canonical row survives and only one request can report its insertion. const stored = await getPrismaClient().measurement.findMany({ where: { userId: TEST_USER_ID }, }); - expect(stored.length).toBeLessThanOrEqual(6); + expect(stored).toHaveLength(1); expect(firstJson.data.inserted + secondJson.data.inserted).toBe( stored.length, ); From 5d784a639cb5ebb76e4a8a64bbc84f4f68f63300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 12:09:32 +0200 Subject: [PATCH 11/35] fix(integrations): serialize refresh and cursor writes --- src/lib/integrations/oauth-refresh.ts | 31 +++ src/lib/whoop/__tests__/sync.test.ts | 143 ++++++++++-- src/lib/whoop/sync.ts | 215 ++++++++--------- .../__tests__/sync-measure-watermark.test.ts | 49 +++- src/lib/withings/sync.ts | 99 ++++---- ...ovider-token-refresh-serialization.test.ts | 220 ++++++++++++++++++ 6 files changed, 590 insertions(+), 167 deletions(-) create mode 100644 tests/integration/provider-token-refresh-serialization.test.ts diff --git a/src/lib/integrations/oauth-refresh.ts b/src/lib/integrations/oauth-refresh.ts index 87276e76f..89c424051 100644 --- a/src/lib/integrations/oauth-refresh.ts +++ b/src/lib/integrations/oauth-refresh.ts @@ -1,3 +1,5 @@ +import type { Prisma } from "@/generated/prisma/client"; + /** * Shared OAuth rotating-refresh-token persistence helper. * @@ -58,3 +60,32 @@ export async function persistRotatedToken( // rather than parking the connection at reauth. return opts.readPeerAccessToken(); } + +export type SerializedRefreshProvider = "whoop" | "withings"; + +/** + * Provider refreshes hold a database transaction open around one bounded + * external request. `maxWait` bounds pool acquisition and `timeout` bounds the + * advisory-lock wait, provider call, and durable token replacement together. + */ +export const PROVIDER_REFRESH_TRANSACTION_OPTIONS = { + maxWait: 10_000, + timeout: 60_000, +} as const; + +/** + * Serialize one provider/user refresh attempt for the transaction lifetime. + * Callers must re-read token state after this returns: a prior contender may + * have committed a fresh token while this transaction waited for the lock. + */ +export async function acquireProviderTokenRefreshLock( + tx: Pick, + provider: SerializedRefreshProvider, + userId: string, +): Promise { + const key = `provider-token-refresh:${provider}:${userId}`; + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${key}, 0)) + `; +} diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 91dff2c21..c8e56648d 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -31,6 +31,7 @@ const { findMany: vi.fn(), }, $executeRaw: vi.fn(), + $queryRaw: vi.fn(), $transaction: vi.fn(), }, refreshAccessTokenMock: vi.fn(), @@ -113,8 +114,8 @@ import { syncUserRecovery } from "../sync-recovery"; beforeEach(() => { vi.clearAllMocks(); - prismaMock.$transaction.mockImplementation(async (run: (tx: unknown) => unknown) => - run({}), + prismaMock.$transaction.mockImplementation( + async (run: (tx: unknown) => unknown) => run(prismaMock), ); reconcileMock.mockImplementation( async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ @@ -150,7 +151,7 @@ describe("getValidToken — rotating refresh", () => { refresh_token: "new-refresh", expires_in: 3600, }); - prismaMock.whoopConnection.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.whoopConnection.update.mockResolvedValue({}); const result = await getValidToken("user1"); @@ -159,15 +160,15 @@ describe("getValidToken — rotating refresh", () => { clientId: "cid", clientSecret: "csecret", }); - const updateArg = prismaMock.whoopConnection.updateMany.mock.calls[0]![0]; - // CAS guard: scoped to the connection AND the exact stored ciphertext spent. + const updateArg = prismaMock.whoopConnection.update.mock.calls[0]![0]; expect(updateArg.where.id).toBe("conn1"); - expect(updateArg.where.refreshToken).toBe("enc(old-refresh)"); expect(updateArg.data.accessToken).toBe("enc(new-access)"); expect(updateArg.data.refreshToken).toBe("enc(new-refresh)"); + expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); }); - it("reuses the peer's rotated token on a lost CAS race (no spurious reauth)", async () => { + + it("re-reads token state under an advisory lock and reuses a peer winner", async () => { prismaMock.whoopConnection.findUnique .mockResolvedValueOnce({ id: "conn1", @@ -176,20 +177,26 @@ describe("getValidToken — rotating refresh", () => { refreshToken: "enc(old-refresh)", tokenExpiresAt: new Date(Date.now() - 1000), }) - // Re-read after the lost race surfaces the peer's freshly rotated access. - .mockResolvedValueOnce({ accessToken: "enc(peer-access)" }); - refreshAccessTokenMock.mockResolvedValue({ - access_token: "new-access", - refresh_token: "new-refresh", - expires_in: 3600, - }); - // A concurrent sync already rotated the token → zero rows match the guard. - prismaMock.whoopConnection.updateMany.mockResolvedValue({ count: 0 }); + .mockResolvedValueOnce({ + id: "conn1", + whoopUserId: "42", + accessToken: "enc(winner-access)", + refreshToken: "enc(winner-refresh)", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); const result = await getValidToken("user1"); - expect(result?.accessToken).toBe("peer-access"); - expect(recordSyncFailure).not.toHaveBeenCalled(); + expect(result?.accessToken).toBe("winner-access"); + expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenMock).not.toHaveBeenCalled(); + expect(prismaMock.$transaction).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + maxWait: expect.any(Number), + timeout: expect.any(Number), + }), + ); }); it("returns the stored token without refresh when not near expiry", async () => { @@ -418,6 +425,13 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { source: "WHOOP", externalId: readings[1].externalId, }); + expect(prismaMock.$transaction).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + maxWait: expect.any(Number), + timeout: expect.any(Number), + }), + ); }); it("does not pre-probe and still emits a genuine insert", async () => { @@ -459,6 +473,99 @@ describe("upsertWhoopMeasurements — exact insertion results", () => { }); }); +describe("syncUserRecovery — durable cursor ordering", () => { + const recovery = { + cycle_id: 1, + sleep_id: "sleep-uuid", + user_id: 42, + created_at: "2026-06-01T06:00:00.000Z", + updated_at: "2026-06-01T07:00:00.000Z", + score_state: "SCORED", + score: { + user_calibrating: false, + recovery_score: 66, + resting_heart_rate: 52, + hrv_rmssd_milli: 48.7, + spo2_percentage: 97, + skin_temp_celsius: 33.4, + }, + }; + + beforeEach(() => { + prismaMock.whoopConnection.findUnique.mockResolvedValue({ + id: "conn1", + whoopUserId: "42", + accessToken: "enc(live-access)", + refreshToken: "enc(live-refresh)", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + lastSyncedAt: new Date("2026-06-01T00:00:00Z"), + resourceCursors: { + recovery: "2026-06-01T00:00:00.000Z", + }, + }); + fetchRecoveriesMock.mockResolvedValue([recovery]); + }); + + it("keeps the cursor unchanged when any represented measurement write fails", async () => { + reconcileMock + .mockResolvedValueOnce({ + status: "inserted", + row: { + id: "inserted:first", + type: "RECOVERY_SCORE", + measuredAt: new Date(recovery.updated_at), + externalId: "sleep-uuid:recovery_score", + }, + }) + .mockResolvedValueOnce({ + status: "failed", + reason: "db_error", + error: new Error("injected write failure"), + }); + + await expect(syncUserRecovery("user1")).rejects.toThrow(); + + expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); + }); + + it("advances after success and replay updates identities without new arrivals", async () => { + expect(await syncUserRecovery("user1")).toBe(5); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + prismaMock.$transaction.mockImplementation( + async (run: (tx: unknown) => unknown) => run(prismaMock), + ); + prismaMock.whoopConnection.findUnique.mockResolvedValue({ + id: "conn1", + whoopUserId: "42", + accessToken: "enc(live-access)", + refreshToken: "enc(live-refresh)", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + lastSyncedAt: new Date("2026-06-01T00:00:00Z"), + resourceCursors: { + recovery: "2026-06-01T00:00:00.000Z", + }, + }); + fetchRecoveriesMock.mockResolvedValue([recovery]); + reconcileMock.mockImplementation( + async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + status: "updated", + row: { + id: `existing:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); + + expect(await syncUserRecovery("user1")).toBe(5); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + expect(emitArrivalMock).toHaveBeenCalledWith("user1", [], "whoop"); + }); +}); + describe("isCollectionForbidden — tier degradation gate", () => { it("is true only for a WhoopApiError carrying HTTP 403", () => { const forbidden = new WhoopApiError({ diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index c3802ba14..c275a49da 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -6,9 +6,9 @@ * - `getValidToken` decrypts the stored pair, refreshes at * `tokenExpiresAt - 5 min`, and persists BOTH rotated tokens (WHOOP * invalidates the prior access AND refresh token on every refresh — the - * same discipline Withings uses for its rotating refresh token). The persist - * is a compare-and-swap on the stored ciphertext so two overlapping syncs - * cannot park the connection at reauth (see `persistRotatedToken`). + * same discipline Withings uses for its rotating refresh token). A + * provider/user advisory lock serializes refresh, and token state is + * re-read inside the transaction before the one-time token is spent. * - Each per-resource sync (`sync-recovery` / `sync-sleep` / `sync-cycle` / * `sync-workout`) upserts into `Measurement` / `Workout` keyed on * `(userId, type, source = WHOOP, externalId)` so a re-post (WHOOP @@ -44,7 +44,10 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; -import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; +import { + acquireProviderTokenRefreshLock, + PROVIDER_REFRESH_TRANSACTION_OPTIONS, +} from "@/lib/integrations/oauth-refresh"; import { MeasurementReconciliationError, reconcileExternalMeasurement, @@ -202,91 +205,86 @@ export async function getValidToken( }); if (!connection) return null; - const accessToken = decrypt(connection.accessToken); - const refreshToken = decrypt(connection.refreshToken); - if ( - connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < + connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >= Date.now() ) { - try { - const creds = await getUserWhoopCredentials(userId); - if (!creds) { - getEvent()?.addWarning( - `No WHOOP credentials found for user ${userId} during token refresh`, - ); - await recordSyncFailure({ - userId, - integration: "whoop", - kind: "reauth_required", - message: "WHOOP credentials missing — token refresh skipped", - errorCode: "credentials_missing", - }); - return null; - } - - const newTokens = await refreshAccessToken(refreshToken, creds); - const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - - // WHOOP rotates the refresh token on every refresh. Persist BOTH the new - // access token AND the new refresh token with a compare-and-swap on the - // stored ciphertext: a concurrent sync that already rotated the token is - // not clobbered, and THIS caller — if it lost the race — reuses the peer's - // rotated access token instead of dropping the connection to reauth. - const persistedAccessToken = await persistRotatedToken( - newTokens.access_token, - { - conditionalUpdate: async () => { - const { count } = await prisma.whoopConnection.updateMany({ - // `connection.refreshToken` is the exact ciphertext we read + - // spent; matching it is the CAS guard (a re-encrypt would differ). - where: { - id: connection.id, - refreshToken: connection.refreshToken, - }, - data: { - accessToken: encrypt(newTokens.access_token), - refreshToken: encrypt(newTokens.refresh_token), - tokenExpiresAt: expiresAt, - }, - }); - return count; - }, - readPeerAccessToken: async () => { - const fresh = await prisma.whoopConnection.findUnique({ - where: { id: connection.id }, - select: { accessToken: true }, - }); - return fresh ? decrypt(fresh.accessToken) : null; - }, - }, - ); - - if (!persistedAccessToken) return null; - - return { - accessToken: persistedAccessToken, - connection: { - id: connection.id, - whoopUserId: connection.whoopUserId, - }, - }; - } catch (err) { - getEvent()?.addWarning( - `WHOOP token refresh failed for user ${userId}: ${err}`, - ); - await recordWhoopSyncFailure(userId, err); - return null; - } + return { + accessToken: decrypt(connection.accessToken), + connection: { + id: connection.id, + whoopUserId: connection.whoopUserId, + }, + }; } - return { - accessToken, - connection: { - id: connection.id, - whoopUserId: connection.whoopUserId, - }, - }; + const creds = await getUserWhoopCredentials(userId); + if (!creds) { + getEvent()?.addWarning( + `No WHOOP credentials found for user ${userId} during token refresh`, + ); + await recordSyncFailure({ + userId, + integration: "whoop", + kind: "reauth_required", + message: "WHOOP credentials missing — token refresh skipped", + errorCode: "credentials_missing", + }); + return null; + } + + try { + return await prisma.$transaction(async (tx) => { + await acquireProviderTokenRefreshLock(tx, "whoop", userId); + + const current = await tx.whoopConnection.findUnique({ + where: { userId }, + }); + if (!current) return null; + + if ( + current.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS >= + Date.now() + ) { + return { + accessToken: decrypt(current.accessToken), + connection: { + id: current.id, + whoopUserId: current.whoopUserId, + }, + }; + } + + const newTokens = await refreshAccessToken( + decrypt(current.refreshToken), + creds, + ); + const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); + + await tx.whoopConnection.update({ + where: { id: current.id }, + data: { + accessToken: encrypt(newTokens.access_token), + refreshToken: encrypt(newTokens.refresh_token), + tokenExpiresAt: expiresAt, + }, + }); + + return { + accessToken: newTokens.access_token, + connection: { + id: current.id, + whoopUserId: current.whoopUserId, + }, + }; + }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); + } catch (err) { + getEvent()?.addWarning( + `WHOOP token refresh failed for user ${userId}: ${err}`, + ); + await recordWhoopSyncFailure(userId, err); + return null; + } } /** @@ -350,30 +348,33 @@ export async function upsertWhoopMeasurements( InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - const verdicts = await prisma.$transaction(async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "WHOOP", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "WHOOP", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, { timeout: 60_000 }); + return outcomes; + }, + { maxWait: 10_000, timeout: 60_000 }, + ); for (let index = 0; index < readings.length; index++) { const reading = readings[index]!; diff --git a/src/lib/withings/__tests__/sync-measure-watermark.test.ts b/src/lib/withings/__tests__/sync-measure-watermark.test.ts index 57a542171..c81a9d4a6 100644 --- a/src/lib/withings/__tests__/sync-measure-watermark.test.ts +++ b/src/lib/withings/__tests__/sync-measure-watermark.test.ts @@ -24,6 +24,8 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), update: vi.fn(), }, + $queryRaw: vi.fn(), + $transaction: vi.fn(), }, })); @@ -38,6 +40,13 @@ vi.mock("../client", () => ({ subscribeWebhook: vi.fn(), })); +vi.mock("../credentials", () => ({ + getUserWithingsCredentials: vi.fn(async () => ({ + clientId: "cid", + clientSecret: "secret", + })), +})); + vi.mock("@/lib/integrations/status", async () => { const actual = await vi.importActual< typeof import("@/lib/integrations/status") @@ -77,13 +86,13 @@ vi.mock("@/lib/logging/context", () => ({ })); import { prisma } from "@/lib/db"; -import { fetchMeasurements } from "../client"; +import { fetchMeasurements, refreshAccessToken } from "../client"; import { recordSyncFailure, recordSyncSuccess, } from "@/lib/integrations/status"; -import { syncUserMeasurements } from "../sync"; +import { getValidToken, syncUserMeasurements } from "../sync"; const P2002 = Object.assign(new Error("Unique constraint failed"), { code: "P2002", @@ -91,6 +100,9 @@ const P2002 = Object.assign(new Error("Unique constraint failed"), { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation(async (run) => + run(prisma as never), + ); // A live, non-expired connection so `getValidToken` returns the token // without touching the refresh path. vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ @@ -107,6 +119,39 @@ beforeEach(() => { vi.mocked(prisma.measurement.update).mockResolvedValue({} as never); }); +describe("getValidToken — serialized rotating refresh", () => { + it("re-reads token state under an advisory lock and reuses a peer winner", async () => { + vi.mocked(prisma.withingsConnection.findUnique) + .mockResolvedValueOnce({ + id: "conn-1", + withingsUserId: "wu-1", + accessToken: "old-access", + refreshToken: "old-refresh", + tokenExpiresAt: new Date(Date.now() - 1000), + } as never) + .mockResolvedValueOnce({ + id: "conn-1", + withingsUserId: "wu-1", + accessToken: "winner-access", + refreshToken: "winner-refresh", + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + } as never); + + const result = await getValidToken("user-1"); + + expect(result?.accessToken).toBe("winner-access"); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(refreshAccessToken).not.toHaveBeenCalled(); + expect(prisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + maxWait: expect.any(Number), + timeout: expect.any(Number), + }), + ); + }); +}); + describe("syncUserMeasurements — watermark hold on hard row failure (F4)", () => { it("holds lastSyncedAt and records a failure when a row hard-fails", async () => { vi.mocked(fetchMeasurements).mockResolvedValue([ diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 50f660f7f..ac0e12561 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -32,6 +32,10 @@ import { } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { + acquireProviderTokenRefreshLock, + PROVIDER_REFRESH_TRANSACTION_OPTIONS, +} from "@/lib/integrations/oauth-refresh"; /** * Build the callback URL handed to Withings at `Notify.subscribe` time. @@ -74,35 +78,58 @@ export async function getValidToken(userId: string): Promise<{ if (!connection) return null; - const accessToken = decrypt(connection.accessToken); - const refreshToken = decrypt(connection.refreshToken); + if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) { + return { + accessToken: decrypt(connection.accessToken), + connection: { + id: connection.id, + withingsUserId: connection.withingsUserId, + }, + }; + } - // Check if token is expired (with 5 min buffer) - if (connection.tokenExpiresAt.getTime() - 5 * 60 * 1000 < Date.now()) { - try { - const creds = await getUserWithingsCredentials(userId); - if (!creds) { - getEvent()?.addWarning( - `No credentials found for user ${userId} during token refresh`, - ); - // Without credentials we cannot refresh OR re-authenticate — the - // user has to re-enter the client_id/secret. Mark as reauth so - // scheduled syncs back off. - await recordSyncFailure({ - userId, - integration: "withings", - kind: "reauth_required", - message: "Withings credentials missing — token refresh skipped", - errorCode: "credentials_missing", - }); - return null; + const creds = await getUserWithingsCredentials(userId); + if (!creds) { + getEvent()?.addWarning( + `No credentials found for user ${userId} during token refresh`, + ); + await recordSyncFailure({ + userId, + integration: "withings", + kind: "reauth_required", + message: "Withings credentials missing — token refresh skipped", + errorCode: "credentials_missing", + }); + return null; + } + + try { + return await prisma.$transaction(async (tx) => { + await acquireProviderTokenRefreshLock(tx, "withings", userId); + + const current = await tx.withingsConnection.findUnique({ + where: { userId }, + }); + if (!current) return null; + + if (current.tokenExpiresAt.getTime() - 5 * 60 * 1000 >= Date.now()) { + return { + accessToken: decrypt(current.accessToken), + connection: { + id: current.id, + withingsUserId: current.withingsUserId, + }, + }; } - const newTokens = await refreshAccessToken(refreshToken, creds); - + const newTokens = await refreshAccessToken( + decrypt(current.refreshToken), + creds, + ); const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - await prisma.withingsConnection.update({ - where: { id: connection.id }, + + await tx.withingsConnection.update({ + where: { id: current.id }, data: { accessToken: encrypt(newTokens.access_token), refreshToken: encrypt(newTokens.refresh_token), @@ -113,24 +140,16 @@ export async function getValidToken(userId: string): Promise<{ return { accessToken: newTokens.access_token, connection: { - id: connection.id, - withingsUserId: connection.withingsUserId, + id: current.id, + withingsUserId: current.withingsUserId, }, }; - } catch (err) { - getEvent()?.addWarning(`Token refresh failed for user ${userId}: ${err}`); - await recordWithingsSyncFailure(userId, err); - return null; - } + }, PROVIDER_REFRESH_TRANSACTION_OPTIONS); + } catch (err) { + getEvent()?.addWarning(`Token refresh failed for user ${userId}: ${err}`); + await recordWithingsSyncFailure(userId, err); + return null; } - - return { - accessToken, - connection: { - id: connection.id, - withingsUserId: connection.withingsUserId, - }, - }; } /** diff --git a/tests/integration/provider-token-refresh-serialization.test.ts b/tests/integration/provider-token-refresh-serialization.test.ts new file mode 100644 index 000000000..cc8afa424 --- /dev/null +++ b/tests/integration/provider-token-refresh-serialization.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as WhoopClientModule from "@/lib/whoop/client"; +import type * as WithingsClientModule from "@/lib/withings/client"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +delete process.env.ENCRYPTION_KEYS; +delete process.env.ENCRYPTION_ACTIVE_KEY_ID; + +const { whoopRefresh, withingsRefresh, whoopRecoveries } = vi.hoisted(() => ({ + whoopRefresh: vi.fn(), + withingsRefresh: vi.fn(), + whoopRecoveries: vi.fn(), +})); + +vi.mock("@/lib/whoop/client", async (importOriginal) => ({ + ...(await importOriginal()), + refreshAccessToken: whoopRefresh, + fetchRecoveries: whoopRecoveries, +})); + +vi.mock("@/lib/withings/client", async (importOriginal) => ({ + ...(await importOriginal()), + refreshAccessToken: withingsRefresh, +})); + +import { encrypt, decrypt } from "@/lib/crypto"; +import { getValidToken as getValidWhoopToken } from "@/lib/whoop/sync"; +import { syncUserRecovery } from "@/lib/whoop/sync-recovery"; +import { getValidToken as getValidWithingsToken } from "@/lib/withings/sync"; + +const CONTENDERS = 75; +const EXPIRED_AT = new Date("2026-01-01T00:00:00.000Z"); + +async function seedProviderUser(provider: "whoop" | "withings") { + const prisma = getPrismaClient(); + const userId = `refresh-${provider}`; + await prisma.user.create({ + data: { + id: userId, + username: `refresh-${provider}`, + email: `refresh-${provider}@example.test`, + ...(provider === "whoop" + ? { + whoopClientIdEncrypted: encrypt("whoop-client"), + whoopClientSecretEncrypted: encrypt("whoop-secret"), + } + : { + withingsClientIdEncrypted: encrypt("withings-client"), + withingsClientSecretEncrypted: encrypt("withings-secret"), + }), + }, + }); + + if (provider === "whoop") { + await prisma.whoopConnection.create({ + data: { + userId, + whoopUserId: "whoop-user", + accessToken: encrypt("stale-access"), + refreshToken: encrypt("stale-refresh"), + tokenExpiresAt: EXPIRED_AT, + }, + }); + } else { + await prisma.withingsConnection.create({ + data: { + userId, + withingsUserId: "withings-user", + accessToken: encrypt("stale-access"), + refreshToken: encrypt("stale-refresh"), + tokenExpiresAt: EXPIRED_AT, + }, + }); + } + + return userId; +} + +async function runContenders( + provider: "whoop" | "withings", + userId: string, +) { + const getToken = + provider === "whoop" ? getValidWhoopToken : getValidWithingsToken; + return Promise.all( + Array.from({ length: CONTENDERS }, () => getToken(userId)), + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + vi.clearAllMocks(); +}); + +describe.each([ + ["whoop", whoopRefresh], + ["withings", withingsRefresh], +] as const)("%s rotating token refresh serialization (real Postgres)", (provider, refresh) => { + it("lets exactly one contender refresh a stale token and shares the winner", async () => { + const userId = await seedProviderUser(provider); + refresh.mockImplementation(async (refreshToken: string) => { + expect(refreshToken).toBe("stale-refresh"); + return { + access_token: "winner-access", + refresh_token: "winner-refresh", + expires_in: 3600, + }; + }); + + const results = await runContenders(provider, userId); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(CONTENDERS); + expect(results.every((result) => result?.accessToken === "winner-access")).toBe( + true, + ); + + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) + : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); + expect(decrypt(row.accessToken)).toBe("winner-access"); + expect(decrypt(row.refreshToken)).toBe("winner-refresh"); + }); + + it("rolls back a failed winner so a later contender can retry", async () => { + const userId = await seedProviderUser(provider); + refresh + .mockRejectedValueOnce(new Error("injected refresh failure")) + .mockResolvedValue({ + access_token: "retry-access", + refresh_token: "retry-refresh", + expires_in: 3600, + }); + + const results = await runContenders(provider, userId); + + expect(refresh).toHaveBeenCalledTimes(2); + expect(results.filter((result) => result === null)).toHaveLength(1); + expect( + results.filter((result) => result?.accessToken === "retry-access"), + ).toHaveLength(CONTENDERS - 1); + + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) + : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); + expect(decrypt(row.accessToken)).toBe("retry-access"); + expect(decrypt(row.refreshToken)).toBe("retry-refresh"); + }); +}); + +describe("WHOOP recovery cursor durability (real Postgres)", () => { + const oldCursor = "2026-06-01T00:00:00.000Z"; + const recovery = { + cycle_id: 1, + sleep_id: "sleep-real", + user_id: 42, + created_at: "2026-06-01T06:00:00.000Z", + updated_at: "2026-06-01T07:00:00.000Z", + score_state: "SCORED", + score: { + user_calibrating: false, + recovery_score: 66, + resting_heart_rate: 52, + hrv_rmssd_milli: 48.7, + spo2_percentage: 97, + skin_temp_celsius: 33.4, + }, + }; + + it("holds the cursor on a durable write failure, then advances idempotently", async () => { + const userId = await seedProviderUser("whoop"); + const prisma = getPrismaClient(); + await prisma.whoopConnection.update({ + where: { userId }, + data: { + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + lastSyncedAt: new Date(oldCursor), + resourceCursors: { recovery: oldCursor }, + }, + }); + whoopRecoveries.mockResolvedValue([recovery]); + + await prisma.$executeRaw` + ALTER TABLE "measurements" + ADD CONSTRAINT "test_whoop_recovery_write_failure" + CHECK ("external_id" <> 'sleep-real:hrv_rmssd') + `; + + try { + await expect(syncUserRecovery(userId)).rejects.toThrow(); + const failed = await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(failed.resourceCursors).toEqual({ recovery: oldCursor }); + expect(await prisma.measurement.count({ where: { userId } })).toBe(0); + } finally { + await prisma.$executeRaw` + ALTER TABLE "measurements" + DROP CONSTRAINT IF EXISTS "test_whoop_recovery_write_failure" + `; + } + + expect(await syncUserRecovery(userId)).toBe(5); + const succeeded = await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(succeeded.resourceCursors).not.toEqual({ recovery: oldCursor }); + expect(await prisma.measurement.count({ where: { userId } })).toBe(5); + + expect(await syncUserRecovery(userId)).toBe(5); + expect(await prisma.measurement.count({ where: { userId } })).toBe(5); + }); +}); From 2716f302c763ba62488d730be5945866097fe1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 12:24:15 +0200 Subject: [PATCH 12/35] chore: align hardening tests and formatting --- .../restore/__tests__/owner-mismatch.test.ts | 1 + .../api/admin/backups/[id]/restore/route.ts | 1059 +++++++++-------- src/app/api/devices/__tests__/route.test.ts | 4 +- src/app/api/devices/route.ts | 6 +- .../__tests__/aggregated-hr-wire.test.ts | 4 +- .../insert-returning-arrivals.test.ts | 4 +- .../batch/__tests__/pr-detection-hook.test.ts | 4 +- src/app/api/measurements/batch/route.ts | 187 ++- .../mood-entries/[id]/__tests__/route.test.ts | 35 +- .../__tests__/full-backup-payload.test.ts | 16 +- src/lib/export/records-backup.ts | 7 +- .../__tests__/withings-ecg-worker.test.ts | 18 +- .../__tests__/backup-handlers.test.ts | 6 +- src/lib/jobs/reminder/backup-handlers.ts | 8 +- src/lib/jobs/reminder/withings-sync.ts | 1 - src/lib/jobs/withings-ecg-queue.ts | 4 +- .../reconcile-external-measurement.test.ts | 4 +- .../reconcile-external-measurement.ts | 10 +- src/lib/mood/__tests__/tag-links.test.ts | 1 - src/lib/oura/__tests__/sync.test.ts | 27 +- src/lib/oura/sync.ts | 49 +- src/lib/polar/__tests__/sync.test.ts | 27 +- src/lib/polar/sync.ts | 49 +- src/lib/strava/sync.ts | 5 +- src/lib/validations/backup.ts | 4 +- src/lib/whoop/__tests__/sync.test.ts | 11 +- src/lib/withings/__tests__/sync-sleep.test.ts | 14 +- src/lib/withings/sync-ecg.ts | 1 - src/lib/withings/sync-sleep.ts | 138 ++- .../admin-backups-canonical-roundtrip.test.ts | 138 ++- .../integration/admin-backups-upload.test.ts | 61 +- tests/integration/device-register.test.ts | 10 +- .../measurement-identity-reconcile.test.ts | 6 +- .../partial-unique-live-rows.test.ts | 4 +- ...ovider-token-refresh-serialization.test.ts | 120 +- 35 files changed, 1044 insertions(+), 999 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts index 48ee3186d..e26b605ab 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts @@ -39,6 +39,7 @@ vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") })); vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() })); vi.mock("@/lib/validations/backup", () => ({ parseBackupPayload: vi.fn(), + isCompatibleSchemaVersion: vi.fn(() => true), summarizeBackup: vi.fn(() => ({})), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 86876f8e5..d92116869 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -73,7 +73,6 @@ function decodeEncryptedBytes(encoded: string): Uint8Array { return bytes; } - const handler = apiHandler( async ( request: NextRequest, @@ -231,7 +230,6 @@ const handler = apiHandler( }, }); - // Portable exports intentionally omit document ciphertext. They remain // valid upload/download artifacts, but cannot be used to manufacture an // InboundDocument row. A DR restore must fail before any delete rather @@ -270,553 +268,560 @@ const handler = apiHandler( let cleared: RestoreResponse["cleared"]; try { - cleared = await prisma.$transaction(async (tx) => { - // Delete every serialized owner-scoped partition before rebuilding it - // in the same transaction. Child rows either go first for counts or - // cascade from their serialized parent. - const intake = await tx.medicationIntakeEvent.deleteMany({ - where: { userId: ownerId }, - }); - const meds = await tx.medication.deleteMany({ - where: { userId: ownerId }, - }); - const measurements = await tx.measurement.deleteMany({ - where: { userId: ownerId }, - }); - const moods = await tx.moodEntry.deleteMany({ - where: { userId: ownerId }, - }); - // v1.4.39 W-MOOD — wipe the persisted mood rollup partition - // for this owner so the next analytics read doesn't surface - // pre-restore daily means. The fold below mints fresh rows - // from the restored mood entries. - await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); - // v1.4.39.1 — wipe the persisted measurement rollup partition - // for the same reason. Pre-fix the partition kept the previous - // owner's daily means even after the underlying measurements - // were replaced — the chart's `source=rollup` fast-path could - // surface a stale 30-day mean built from rows that no longer - // existed. The fold below mints fresh rows from the restored - // measurement set. - await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); - const channels = await tx.notificationChannel.deleteMany({ - where: { userId: ownerId }, - }); - const subs = await tx.pushSubscription.deleteMany({ - where: { userId: ownerId }, - }); - const tgDel = await tx.telegramScheduledDeletion.deleteMany({ - where: { userId: ownerId }, - }); - const documents = await tx.inboundDocument.deleteMany({ - where: { userId: ownerId }, - }); - const workouts = await tx.workout.deleteMany({ - where: { userId: ownerId }, - }); - const familyHistory = await tx.familyHistoryEntry.deleteMany({ - where: { userId: ownerId }, - }); - const allergies = await tx.allergy.deleteMany({ - where: { userId: ownerId }, - }); - const illnessEpisodes = await tx.illnessEpisode.deleteMany({ - where: { userId: ownerId }, - }); - const labResults = await tx.labResult.deleteMany({ - where: { userId: ownerId }, - }); - const biomarkers = await tx.biomarker.deleteMany({ - where: { userId: ownerId }, - }); + cleared = await prisma.$transaction( + async (tx) => { + // Delete every serialized owner-scoped partition before rebuilding it + // in the same transaction. Child rows either go first for counts or + // cascade from their serialized parent. + const intake = await tx.medicationIntakeEvent.deleteMany({ + where: { userId: ownerId }, + }); + const meds = await tx.medication.deleteMany({ + where: { userId: ownerId }, + }); + const measurements = await tx.measurement.deleteMany({ + where: { userId: ownerId }, + }); + const moods = await tx.moodEntry.deleteMany({ + where: { userId: ownerId }, + }); + // v1.4.39 W-MOOD — wipe the persisted mood rollup partition + // for this owner so the next analytics read doesn't surface + // pre-restore daily means. The fold below mints fresh rows + // from the restored mood entries. + await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); + // v1.4.39.1 — wipe the persisted measurement rollup partition + // for the same reason. Pre-fix the partition kept the previous + // owner's daily means even after the underlying measurements + // were replaced — the chart's `source=rollup` fast-path could + // surface a stale 30-day mean built from rows that no longer + // existed. The fold below mints fresh rows from the restored + // measurement set. + await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); + const channels = await tx.notificationChannel.deleteMany({ + where: { userId: ownerId }, + }); + const subs = await tx.pushSubscription.deleteMany({ + where: { userId: ownerId }, + }); + const tgDel = await tx.telegramScheduledDeletion.deleteMany({ + where: { userId: ownerId }, + }); + const documents = await tx.inboundDocument.deleteMany({ + where: { userId: ownerId }, + }); + const workouts = await tx.workout.deleteMany({ + where: { userId: ownerId }, + }); + const familyHistory = await tx.familyHistoryEntry.deleteMany({ + where: { userId: ownerId }, + }); + const allergies = await tx.allergy.deleteMany({ + where: { userId: ownerId }, + }); + const illnessEpisodes = await tx.illnessEpisode.deleteMany({ + where: { userId: ownerId }, + }); + const labResults = await tx.labResult.deleteMany({ + where: { userId: ownerId }, + }); + const biomarkers = await tx.biomarker.deleteMany({ + where: { userId: ownerId }, + }); - // Stable ids are authoritative for canonical snapshots. Legacy payloads - // without ids fall back to the historic natural measurement key. - for (const measurement of payload.measurements) { - const measuredAt = new Date(measurement.measuredAt); - const source = measurement.source ?? "MANUAL"; - const restoredData = { - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - source, - measuredAt, - notes: null, - notesEncrypted: encryptNote(measurement.notes ?? null), - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) - : null, - }; - - if (measurement.id) { - await tx.measurement.upsert({ - where: { id: measurement.id, userId: ownerId }, - create: { - id: measurement.id, - userId: ownerId, - ...restoredData, - }, - update: restoredData, - }); - continue; - } - - const existing = await tx.measurement.findFirst({ - where: { - userId: ownerId, + // Stable ids are authoritative for canonical snapshots. Legacy payloads + // without ids fall back to the historic natural measurement key. + for (const measurement of payload.measurements) { + const measuredAt = new Date(measurement.measuredAt); + const source = measurement.source ?? "MANUAL"; + const restoredData = { type: measurement.type, + value: measurement.value, + unit: measurement.unit, source, measuredAt, - }, - select: { id: true }, - }); - if (existing) { - await tx.measurement.update({ - where: { id: existing.id, userId: ownerId }, - data: restoredData, - }); - } else { - await tx.measurement.create({ - data: { userId: ownerId, ...restoredData }, - }); - } - } - - // Medications + schedules — restore one at a time so we can - // wire schedules to their parent's freshly-generated id, AND - // build a name → id map for the intake events that follow. - const medByName = new Map(); - for (const m of payload.medications) { - const created = await tx.medication.create({ - data: { - userId: ownerId, - name: m.name, - dose: m.dose, - active: m.active ?? true, - schedules: { - create: m.schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - })), - }, - }, - }); - // First write wins on duplicate names — keeps the round-trip - // deterministic against the restore-from-our-own-download case. - if (!medByName.has(m.name)) medByName.set(m.name, created.id); - } - - // Intake events — only restore the ones whose `medication` name - // resolved against a row we just created. Orphans (medications - // that were deleted before the snapshot but still referenced by - // intake events) are dropped silently — the alternative would - // be an FK-violation crash, which is worse than a slightly - // smaller history. - if (payload.intakeEvents.length > 0) { - const rows = payload.intakeEvents - .map((e) => { - const medId = medByName.get(e.medication); - if (!medId) return null; - return { - userId: ownerId, - medicationId: medId, - scheduledFor: new Date(e.scheduledFor), - takenAt: e.takenAt ? new Date(e.takenAt) : null, - skipped: e.skipped ?? false, - source: (e.source ?? "WEB") as never, - }; - }) - .filter((r): r is NonNullable => r !== null); - if (rows.length > 0) { - await tx.medicationIntakeEvent.createMany({ data: rows }); - } - } - - if (payload.moodEntries.length > 0) { - const factorKeys = [ - ...new Set( - payload.moodEntries.flatMap((entry) => - entry.factors.map((factor) => factor.key), - ), - ), - ]; - const factorRows = - factorKeys.length === 0 - ? [] - : await tx.moodTag.findMany({ - where: { - key: { in: factorKeys }, - kind: "RATED", - userId: null, - }, - select: { id: true, key: true }, - }); - const factorByKey = new Map( - factorRows.map((factor) => [factor.key, factor.id]), - ); - if (factorByKey.size !== factorKeys.length) { - const missing = factorKeys.filter((key) => !factorByKey.has(key)); - throw new Error(`Unknown mood factor keys: ${missing.join(", ")}`); - } - - for (const entry of payload.moodEntries) { - const moodLoggedAt = new Date(entry.loggedAt); - const restoredData = { - date: entry.date, - mood: entry.mood, - score: entry.score, - tags: entry.tags ?? null, - source: entry.source ?? "MOODLOG", - externalId: entry.externalId ?? null, - moodLoggedAt, - deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + notes: null, + notesEncrypted: encryptNote(measurement.notes ?? null), + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, }; - const createData = { userId: ownerId, ...restoredData }; - const restored = entry.id - ? await tx.moodEntry.upsert({ - where: { id: entry.id, userId: ownerId }, - create: { id: entry.id, ...createData }, - update: restoredData, - }) - : entry.externalId - ? await tx.moodEntry.upsert({ - where: { - userId_source_externalId: { - userId: ownerId, - source: restoredData.source, - externalId: entry.externalId, - }, - }, - create: createData, - update: restoredData, - }) - : await tx.moodEntry.upsert({ - where: { - userId_date_moodLoggedAt: { - userId: ownerId, - date: entry.date, - moodLoggedAt, - }, - }, - create: createData, - update: restoredData, - }); - await tx.moodEntryTagLink.deleteMany({ - where: { moodEntryId: restored.id }, + if (measurement.id) { + await tx.measurement.upsert({ + where: { id: measurement.id, userId: ownerId }, + create: { + id: measurement.id, + userId: ownerId, + ...restoredData, + }, + update: restoredData, + }); + continue; + } + + const existing = await tx.measurement.findFirst({ + where: { + userId: ownerId, + type: measurement.type, + source, + measuredAt, + }, + select: { id: true }, }); - if (entry.factors.length > 0) { - await tx.moodEntryTagLink.createMany({ - data: entry.factors.map((factor) => ({ - moodEntryId: restored.id, - moodTagId: factorByKey.get(factor.key)!, - rating: factor.rating, - })), + if (existing) { + await tx.measurement.update({ + where: { id: existing.id, userId: ownerId }, + data: restoredData, + }); + } else { + await tx.measurement.create({ + data: { userId: ownerId, ...restoredData }, }); } } - } - // v1.15.0 — cycle tables (profile + observed spans + day-logs + - // symptom links). Delete-then-recreate, mirroring the contract - // above. `notesEncrypted` is restored as ciphertext verbatim. - const cycleCleared = await restoreCycleData(tx, ownerId, payload); - - const biomarkerByName = new Map(); - for (const biomarker of payload.biomarkers) { - const created = await tx.biomarker.create({ - data: { - ...(biomarker.id ? { id: biomarker.id } : {}), - userId: ownerId, - name: biomarker.name, - unit: biomarker.unit, - lowerBound: biomarker.lowerBound ?? null, - upperBound: biomarker.upperBound ?? null, - panel: biomarker.panel ?? null, - hidden: biomarker.hidden ?? false, - contextEncrypted: - biomarker.context == null - ? null - : encryptContextToBytes(biomarker.context), - ...(biomarker.createdAt - ? { createdAt: new Date(biomarker.createdAt) } - : {}), - ...(biomarker.updatedAt - ? { updatedAt: new Date(biomarker.updatedAt) } - : {}), - }, - }); - biomarkerByName.set(biomarker.name, created.id); - } - - for (const lab of payload.labResults) { - const biomarkerId = lab.biomarkerName - ? biomarkerByName.get(lab.biomarkerName) - : undefined; - if (lab.biomarkerName && !biomarkerId) { - throw new Error( - `Unknown biomarker reference: ${lab.biomarkerName}`, - ); - } - await tx.labResult.create({ - data: { - ...(lab.id ? { id: lab.id } : {}), - userId: ownerId, - biomarkerId: biomarkerId ?? null, - panel: lab.panel ?? null, - analyte: lab.analyte, - value: lab.value ?? null, - valueText: lab.valueText ?? null, - unit: lab.unit, - referenceLow: lab.referenceLow ?? null, - referenceHigh: lab.referenceHigh ?? null, - takenAt: new Date(lab.takenAt), - source: lab.source, - noteEncrypted: - lab.note == null ? null : encryptNoteToBytes(lab.note), - ...(lab.createdAt - ? { createdAt: new Date(lab.createdAt) } - : {}), - ...(lab.updatedAt - ? { updatedAt: new Date(lab.updatedAt) } - : {}), - }, - }); - } - - const episodeIds = new Set( - payload.illnessEpisodes.map((episode) => episode.id), - ); - for (const episode of payload.illnessEpisodes) { - if ( - episode.parentConditionId && - !episodeIds.has(episode.parentConditionId) - ) { - throw new Error( - `Unknown illness parent: ${episode.parentConditionId}`, - ); - } - await tx.illnessEpisode.create({ - data: { - id: episode.id, - userId: ownerId, - label: episode.label, - type: episode.type as never, - lifecycle: episode.lifecycle as never, - onsetAt: new Date(episode.onsetAt), - resolvedAt: episode.resolvedAt - ? new Date(episode.resolvedAt) - : null, - parentConditionId: null, - noteEncrypted: - episode.note == null ? null : encryptToBytes(episode.note), - ...(episode.createdAt - ? { createdAt: new Date(episode.createdAt) } - : {}), - ...(episode.updatedAt - ? { updatedAt: new Date(episode.updatedAt) } - : {}), - }, - }); - } - for (const episode of payload.illnessEpisodes) { - if (episode.parentConditionId) { - await tx.illnessEpisode.update({ - where: { id: episode.id }, - data: { parentConditionId: episode.parentConditionId }, - }); - } - } - - const symptomKeys = [ - ...new Set( - payload.illnessEpisodes.flatMap((episode) => - episode.dayLogs.flatMap((dayLog) => - dayLog.symptoms.map((symptom) => symptom.key), - ), - ), - ), - ]; - const symptomRows = - symptomKeys.length === 0 - ? [] - : await tx.illnessSymptom.findMany({ - where: { key: { in: symptomKeys } }, - select: { id: true, key: true }, - }); - const symptomByKey = new Map( - symptomRows.map((symptom) => [symptom.key, symptom.id]), - ); - if (symptomByKey.size !== symptomKeys.length) { - const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); - throw new Error(`Unknown illness symptom keys: ${missing.join(", ")}`); - } - for (const episode of payload.illnessEpisodes) { - for (const dayLog of episode.dayLogs) { - await tx.illnessDayLog.create({ + // Medications + schedules — restore one at a time so we can + // wire schedules to their parent's freshly-generated id, AND + // build a name → id map for the intake events that follow. + const medByName = new Map(); + for (const m of payload.medications) { + const created = await tx.medication.create({ data: { - ...(dayLog.id ? { id: dayLog.id } : {}), userId: ownerId, - episodeId: episode.id, - date: dayLog.date, - functionalImpact: dayLog.functionalImpact ?? null, - feverC: dayLog.feverC ?? null, - noteEncrypted: - dayLog.note == null ? null : encryptToBytes(dayLog.note), - ...(dayLog.updatedAt - ? { updatedAt: new Date(dayLog.updatedAt) } - : {}), - symptomLinks: { - create: dayLog.symptoms.map((symptom) => ({ - symptomId: symptomByKey.get(symptom.key)!, - severity: symptom.severity ?? null, + name: m.name, + dose: m.dose, + active: m.active ?? true, + schedules: { + create: m.schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, })), }, }, }); + // First write wins on duplicate names — keeps the round-trip + // deterministic against the restore-from-our-own-download case. + if (!medByName.has(m.name)) medByName.set(m.name, created.id); } - } - for (const allergy of payload.allergies) { - await tx.allergy.create({ - data: { - id: allergy.id, - userId: ownerId, - substance: allergy.substance, - category: allergy.category as never, - type: allergy.type as never, - severity: (allergy.severity ?? null) as never, - status: allergy.status as never, - onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, - reactionEncrypted: - allergy.reaction == null - ? null - : encryptToBytes(allergy.reaction), - notesEncrypted: - allergy.note == null ? null : encryptToBytes(allergy.note), - ...(allergy.createdAt - ? { createdAt: new Date(allergy.createdAt) } - : {}), - ...(allergy.updatedAt - ? { updatedAt: new Date(allergy.updatedAt) } - : {}), - }, - }); - } + // Intake events — only restore the ones whose `medication` name + // resolved against a row we just created. Orphans (medications + // that were deleted before the snapshot but still referenced by + // intake events) are dropped silently — the alternative would + // be an FK-violation crash, which is worse than a slightly + // smaller history. + if (payload.intakeEvents.length > 0) { + const rows = payload.intakeEvents + .map((e) => { + const medId = medByName.get(e.medication); + if (!medId) return null; + return { + userId: ownerId, + medicationId: medId, + scheduledFor: new Date(e.scheduledFor), + takenAt: e.takenAt ? new Date(e.takenAt) : null, + skipped: e.skipped ?? false, + source: (e.source ?? "WEB") as never, + }; + }) + .filter((r): r is NonNullable => r !== null); + if (rows.length > 0) { + await tx.medicationIntakeEvent.createMany({ data: rows }); + } + } - for (const familyEntry of payload.familyHistory) { - await tx.familyHistoryEntry.create({ - data: { - id: familyEntry.id, - userId: ownerId, - relationship: familyEntry.relationship as never, - condition: familyEntry.condition, - ageAtOnset: familyEntry.ageAtOnset ?? null, - notesEncrypted: - familyEntry.note == null - ? null - : encryptToBytes(familyEntry.note), - ...(familyEntry.createdAt - ? { createdAt: new Date(familyEntry.createdAt) } - : {}), - ...(familyEntry.updatedAt - ? { updatedAt: new Date(familyEntry.updatedAt) } - : {}), - }, - }); - } - - if (payload.workouts.length > 0) { - await tx.workout.createMany({ - data: payload.workouts.map((workout) => ({ - ...(workout.id ? { id: workout.id } : {}), - userId: ownerId, - sportType: workout.sportType, - startedAt: new Date(workout.startedAt), - endedAt: new Date(workout.endedAt), - durationSec: workout.durationSec, - totalEnergyKcal: workout.totalEnergyKcal ?? null, - totalDistanceM: workout.totalDistanceM ?? null, - avgHeartRate: workout.avgHeartRate ?? null, - maxHeartRate: workout.maxHeartRate ?? null, - minHeartRate: workout.minHeartRate ?? null, - stepCount: workout.stepCount ?? null, - elevationM: workout.elevationM ?? null, - pauseDurationSec: workout.pauseDurationSec ?? null, - source: workout.source as never, - externalId: workout.externalId ?? null, - ...(workout.createdAt - ? { createdAt: new Date(workout.createdAt) } - : {}), - ...(workout.updatedAt - ? { updatedAt: new Date(workout.updatedAt) } - : {}), - })), - }); - } - - if (payload.documents.length > 0) { - await tx.inboundDocument.createMany({ - data: payload.documents.map((document) => ({ - id: document.id, - userId: ownerId, - kind: document.kind as never, - title: document.title ?? null, - filename: document.filename ?? null, - mimeType: document.mimeType, - byteSize: document.byteSize, - contentEncrypted: decodeEncryptedBytes( - document.contentEncrypted!, + if (payload.moodEntries.length > 0) { + const factorKeys = [ + ...new Set( + payload.moodEntries.flatMap((entry) => + entry.factors.map((factor) => factor.key), + ), ), - contentSha256: document.contentSha256 ?? null, - contentCodec: document.contentCodec!, - status: document.status as never, - providerType: document.providerType ?? null, - reportDate: document.reportDate - ? new Date(document.reportDate) - : null, - documentDate: document.documentDate - ? new Date(document.documentDate) - : null, - errorReason: document.errorReason ?? null, - summaryEncrypted: document.summaryEncrypted - ? decodeEncryptedBytes(document.summaryEncrypted) - : null, - summaryGeneratedAt: document.summaryGeneratedAt - ? new Date(document.summaryGeneratedAt) - : null, - summaryState: document.summaryState as never, - createdAt: new Date(document.createdAt!), - updatedAt: new Date(document.updatedAt!), - })), - }); - } + ]; + const factorRows = + factorKeys.length === 0 + ? [] + : await tx.moodTag.findMany({ + where: { + key: { in: factorKeys }, + kind: "RATED", + userId: null, + }, + select: { id: true, key: true }, + }); + const factorByKey = new Map( + factorRows.map((factor) => [factor.key, factor.id]), + ); + if (factorByKey.size !== factorKeys.length) { + const missing = factorKeys.filter((key) => !factorByKey.has(key)); + throw new Error( + `Unknown mood factor keys: ${missing.join(", ")}`, + ); + } - return { - measurements: measurements.count, - medications: meds.count, - intakeEvents: intake.count, - moodEntries: moods.count, - notificationChannels: channels.count, - pushSubscriptions: subs.count, - telegramScheduledDeletions: tgDel.count, - cycles: cycleCleared.cycles, - cycleDayLogs: cycleCleared.cycleDayLogs, - cycleProfile: cycleCleared.cycleProfile, - labResults: labResults.count, - biomarkers: biomarkers.count, - illnessEpisodes: illnessEpisodes.count, - allergies: allergies.count, - familyHistory: familyHistory.count, - workouts: workouts.count, - documents: documents.count, - }; - }, { - maxWait: 10_000, - timeout: 120_000, - }); + for (const entry of payload.moodEntries) { + const moodLoggedAt = new Date(entry.loggedAt); + const restoredData = { + date: entry.date, + mood: entry.mood, + score: entry.score, + tags: entry.tags ?? null, + source: entry.source ?? "MOODLOG", + externalId: entry.externalId ?? null, + moodLoggedAt, + deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null, + }; + const createData = { userId: ownerId, ...restoredData }; + const restored = entry.id + ? await tx.moodEntry.upsert({ + where: { id: entry.id, userId: ownerId }, + create: { id: entry.id, ...createData }, + update: restoredData, + }) + : entry.externalId + ? await tx.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId: ownerId, + source: restoredData.source, + externalId: entry.externalId, + }, + }, + create: createData, + update: restoredData, + }) + : await tx.moodEntry.upsert({ + where: { + userId_date_moodLoggedAt: { + userId: ownerId, + date: entry.date, + moodLoggedAt, + }, + }, + create: createData, + update: restoredData, + }); + + await tx.moodEntryTagLink.deleteMany({ + where: { moodEntryId: restored.id }, + }); + if (entry.factors.length > 0) { + await tx.moodEntryTagLink.createMany({ + data: entry.factors.map((factor) => ({ + moodEntryId: restored.id, + moodTagId: factorByKey.get(factor.key)!, + rating: factor.rating, + })), + }); + } + } + } + + // v1.15.0 — cycle tables (profile + observed spans + day-logs + + // symptom links). Delete-then-recreate, mirroring the contract + // above. `notesEncrypted` is restored as ciphertext verbatim. + const cycleCleared = await restoreCycleData(tx, ownerId, payload); + + const biomarkerByName = new Map(); + for (const biomarker of payload.biomarkers) { + const created = await tx.biomarker.create({ + data: { + ...(biomarker.id ? { id: biomarker.id } : {}), + userId: ownerId, + name: biomarker.name, + unit: biomarker.unit, + lowerBound: biomarker.lowerBound ?? null, + upperBound: biomarker.upperBound ?? null, + panel: biomarker.panel ?? null, + hidden: biomarker.hidden ?? false, + contextEncrypted: + biomarker.context == null + ? null + : encryptContextToBytes(biomarker.context), + ...(biomarker.createdAt + ? { createdAt: new Date(biomarker.createdAt) } + : {}), + ...(biomarker.updatedAt + ? { updatedAt: new Date(biomarker.updatedAt) } + : {}), + }, + }); + biomarkerByName.set(biomarker.name, created.id); + } + + for (const lab of payload.labResults) { + const biomarkerId = lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : undefined; + if (lab.biomarkerName && !biomarkerId) { + throw new Error( + `Unknown biomarker reference: ${lab.biomarkerName}`, + ); + } + await tx.labResult.create({ + data: { + ...(lab.id ? { id: lab.id } : {}), + userId: ownerId, + biomarkerId: biomarkerId ?? null, + panel: lab.panel ?? null, + analyte: lab.analyte, + value: lab.value ?? null, + valueText: lab.valueText ?? null, + unit: lab.unit, + referenceLow: lab.referenceLow ?? null, + referenceHigh: lab.referenceHigh ?? null, + takenAt: new Date(lab.takenAt), + source: lab.source, + noteEncrypted: + lab.note == null ? null : encryptNoteToBytes(lab.note), + ...(lab.createdAt + ? { createdAt: new Date(lab.createdAt) } + : {}), + ...(lab.updatedAt + ? { updatedAt: new Date(lab.updatedAt) } + : {}), + }, + }); + } + + const episodeIds = new Set( + payload.illnessEpisodes.map((episode) => episode.id), + ); + for (const episode of payload.illnessEpisodes) { + if ( + episode.parentConditionId && + !episodeIds.has(episode.parentConditionId) + ) { + throw new Error( + `Unknown illness parent: ${episode.parentConditionId}`, + ); + } + await tx.illnessEpisode.create({ + data: { + id: episode.id, + userId: ownerId, + label: episode.label, + type: episode.type as never, + lifecycle: episode.lifecycle as never, + onsetAt: new Date(episode.onsetAt), + resolvedAt: episode.resolvedAt + ? new Date(episode.resolvedAt) + : null, + parentConditionId: null, + noteEncrypted: + episode.note == null ? null : encryptToBytes(episode.note), + ...(episode.createdAt + ? { createdAt: new Date(episode.createdAt) } + : {}), + ...(episode.updatedAt + ? { updatedAt: new Date(episode.updatedAt) } + : {}), + }, + }); + } + for (const episode of payload.illnessEpisodes) { + if (episode.parentConditionId) { + await tx.illnessEpisode.update({ + where: { id: episode.id }, + data: { parentConditionId: episode.parentConditionId }, + }); + } + } + + const symptomKeys = [ + ...new Set( + payload.illnessEpisodes.flatMap((episode) => + episode.dayLogs.flatMap((dayLog) => + dayLog.symptoms.map((symptom) => symptom.key), + ), + ), + ), + ]; + const symptomRows = + symptomKeys.length === 0 + ? [] + : await tx.illnessSymptom.findMany({ + where: { key: { in: symptomKeys } }, + select: { id: true, key: true }, + }); + const symptomByKey = new Map( + symptomRows.map((symptom) => [symptom.key, symptom.id]), + ); + if (symptomByKey.size !== symptomKeys.length) { + const missing = symptomKeys.filter((key) => !symptomByKey.has(key)); + throw new Error( + `Unknown illness symptom keys: ${missing.join(", ")}`, + ); + } + for (const episode of payload.illnessEpisodes) { + for (const dayLog of episode.dayLogs) { + await tx.illnessDayLog.create({ + data: { + ...(dayLog.id ? { id: dayLog.id } : {}), + userId: ownerId, + episodeId: episode.id, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact ?? null, + feverC: dayLog.feverC ?? null, + noteEncrypted: + dayLog.note == null ? null : encryptToBytes(dayLog.note), + ...(dayLog.updatedAt + ? { updatedAt: new Date(dayLog.updatedAt) } + : {}), + symptomLinks: { + create: dayLog.symptoms.map((symptom) => ({ + symptomId: symptomByKey.get(symptom.key)!, + severity: symptom.severity ?? null, + })), + }, + }, + }); + } + } + + for (const allergy of payload.allergies) { + await tx.allergy.create({ + data: { + id: allergy.id, + userId: ownerId, + substance: allergy.substance, + category: allergy.category as never, + type: allergy.type as never, + severity: (allergy.severity ?? null) as never, + status: allergy.status as never, + onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, + reactionEncrypted: + allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), + notesEncrypted: + allergy.note == null ? null : encryptToBytes(allergy.note), + ...(allergy.createdAt + ? { createdAt: new Date(allergy.createdAt) } + : {}), + ...(allergy.updatedAt + ? { updatedAt: new Date(allergy.updatedAt) } + : {}), + }, + }); + } + + for (const familyEntry of payload.familyHistory) { + await tx.familyHistoryEntry.create({ + data: { + id: familyEntry.id, + userId: ownerId, + relationship: familyEntry.relationship as never, + condition: familyEntry.condition, + ageAtOnset: familyEntry.ageAtOnset ?? null, + notesEncrypted: + familyEntry.note == null + ? null + : encryptToBytes(familyEntry.note), + ...(familyEntry.createdAt + ? { createdAt: new Date(familyEntry.createdAt) } + : {}), + ...(familyEntry.updatedAt + ? { updatedAt: new Date(familyEntry.updatedAt) } + : {}), + }, + }); + } + + if (payload.workouts.length > 0) { + await tx.workout.createMany({ + data: payload.workouts.map((workout) => ({ + ...(workout.id ? { id: workout.id } : {}), + userId: ownerId, + sportType: workout.sportType, + startedAt: new Date(workout.startedAt), + endedAt: new Date(workout.endedAt), + durationSec: workout.durationSec, + totalEnergyKcal: workout.totalEnergyKcal ?? null, + totalDistanceM: workout.totalDistanceM ?? null, + avgHeartRate: workout.avgHeartRate ?? null, + maxHeartRate: workout.maxHeartRate ?? null, + minHeartRate: workout.minHeartRate ?? null, + stepCount: workout.stepCount ?? null, + elevationM: workout.elevationM ?? null, + pauseDurationSec: workout.pauseDurationSec ?? null, + source: workout.source as never, + externalId: workout.externalId ?? null, + ...(workout.createdAt + ? { createdAt: new Date(workout.createdAt) } + : {}), + ...(workout.updatedAt + ? { updatedAt: new Date(workout.updatedAt) } + : {}), + })), + }); + } + + if (payload.documents.length > 0) { + await tx.inboundDocument.createMany({ + data: payload.documents.map((document) => ({ + id: document.id, + userId: ownerId, + kind: document.kind as never, + title: document.title ?? null, + filename: document.filename ?? null, + mimeType: document.mimeType, + byteSize: document.byteSize, + contentEncrypted: decodeEncryptedBytes( + document.contentEncrypted!, + ), + contentSha256: document.contentSha256 ?? null, + contentCodec: document.contentCodec!, + status: document.status as never, + providerType: document.providerType ?? null, + reportDate: document.reportDate + ? new Date(document.reportDate) + : null, + documentDate: document.documentDate + ? new Date(document.documentDate) + : null, + errorReason: document.errorReason ?? null, + summaryEncrypted: document.summaryEncrypted + ? decodeEncryptedBytes(document.summaryEncrypted) + : null, + summaryGeneratedAt: document.summaryGeneratedAt + ? new Date(document.summaryGeneratedAt) + : null, + summaryState: document.summaryState as never, + createdAt: new Date(document.createdAt!), + updatedAt: new Date(document.updatedAt!), + })), + }); + } + + return { + measurements: measurements.count, + medications: meds.count, + intakeEvents: intake.count, + moodEntries: moods.count, + notificationChannels: channels.count, + pushSubscriptions: subs.count, + telegramScheduledDeletions: tgDel.count, + cycles: cycleCleared.cycles, + cycleDayLogs: cycleCleared.cycleDayLogs, + cycleProfile: cycleCleared.cycleProfile, + labResults: labResults.count, + biomarkers: biomarkers.count, + illnessEpisodes: illnessEpisodes.count, + allergies: allergies.count, + familyHistory: familyHistory.count, + workouts: workouts.count, + documents: documents.count, + }; + }, + { + maxWait: 10_000, + timeout: 120_000, + }, + ); } catch (err) { // Scrub the raw Prisma / driver error from the wire response — // even on an admin endpoint, leaking column names, constraint diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index c16b20a0d..88c52f32d 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -85,8 +85,8 @@ beforeEach(() => { vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); - vi.mocked(prisma.$transaction).mockImplementation( - async (callback) => callback(prisma as never), + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback(prisma as never), ); vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ id: "ch-1", diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index cbbabdaef..f9fb33118 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -234,11 +234,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // the APNs row with the newly presented metadata. let canonicalDevice = apnsDevice ?? legacyDevice; let duplicateDevice: typeof legacyDevice = null; - if ( - apnsDevice && - legacyDevice && - apnsDevice.id !== legacyDevice.id - ) { + if (apnsDevice && legacyDevice && apnsDevice.id !== legacyDevice.id) { duplicateDevice = legacyDevice; await tx.device.deleteMany({ where: { id: legacyDevice.id, userId: user.id }, diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 51de39107..352a6aff6 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -77,7 +77,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ }); return { status: "updated", row: { ...exact, ...input } }; } - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); if (inserted) return { status: "inserted", row: inserted }; if (options.exactExternalMatch === "update") { await tx.measurement.updateMany({ where: {}, data: input }); diff --git a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts index d376658b3..504bf1926 100644 --- a/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts +++ b/src/app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts @@ -58,7 +58,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ await tx.measurement.updateMany({ where: { id: exact.id }, data: input }); return { status: "updated", row: { ...exact, ...input } }; } - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); if (inserted) return { status: "inserted", row: inserted }; if (options.exactExternalMatch === "update") { await tx.measurement.updateMany({ where: {}, data: input }); diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index ab9ff6113..ab8f5b478 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -38,7 +38,9 @@ vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ ) => { const override = await reconcileOverrideMock(tx, input); if (override !== undefined) return override; - const [inserted] = await tx.measurement.createManyAndReturn({ data: input }); + const [inserted] = await tx.measurement.createManyAndReturn({ + data: input, + }); return inserted ? { status: "inserted", row: inserted } : { status: "duplicate" }; diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 25013650d..1001392ce 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -177,12 +177,7 @@ type BatchEntry = z.infer; * sample is a canonical, immutable reading. `skipped` is a validation no-op; * `failed` is a hard database verdict and must not be checkpointed as landed. */ -type EntryStatus = - | "inserted" - | "updated" - | "duplicate" - | "skipped" - | "failed"; +type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped" | "failed"; // v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative // totals that the iOS HealthKit observer re-posts as the day progresses. @@ -475,103 +470,101 @@ async function postBatch(request: NextRequest): Promise { } for (const p of prepared) { - const externalId = p.row.externalId as string; - const statsRow = isStatsExternalId(externalId); - const key = `${p.row.type}::${p.row.source}::${externalId}`; - if (statsRow && lastStatsIndexByKey.get(key) !== p.index) { - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "superseded_in_batch", - }; - duplicateCount++; - continue; - } + const externalId = p.row.externalId as string; + const statsRow = isStatsExternalId(externalId); + const key = `${p.row.type}::${p.row.source}::${externalId}`; + if (statsRow && lastStatsIndexByKey.get(key) !== p.index) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "superseded_in_batch", + }; + duplicateCount++; + continue; + } - if (!statsRow && hasSameReadingSibling(p)) { - results[p.index] = { - index: p.index, - status: "duplicate", - reason: "cross_source_merge", - }; - duplicateCount++; - crossSourceMergedCount++; - continue; - } + if (!statsRow && hasSameReadingSibling(p)) { + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "cross_source_merge", + }; + duplicateCount++; + crossSourceMergedCount++; + continue; + } - const verdict = await prisma.$transaction((tx) => - reconcileExternalMeasurement( - tx, - { - userId: user.id, - type: p.row.type as MeasurementType, - value: p.row.value as number, - valueMin: p.row.valueMin as number | null, - valueMax: p.row.valueMax as number | null, - unit: p.row.unit as string, - source: - p.row.source as Prisma.MeasurementUncheckedCreateInput["source"], - measuredAt: p.row.measuredAt as Date, - externalId, - externalSourceVersion: p.row - .externalSourceVersion as string | null, - glucoseContext: - p.row.glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], - sleepStage: - p.row.sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], - rhythmClassification: - p.row - .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], - deviceType: p.row.deviceType as string | null, - }, - { exactExternalMatch: statsRow ? "update" : "duplicate" }, - ), - ); - - switch (verdict.status) { - case "inserted": - results[p.index] = { index: p.index, status: "inserted" }; - insertedCount++; - insertedPrepared.push(p); - writtenIdentities.push({ - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }); - break; - case "updated": - case "resurrected": - results[p.index] = { index: p.index, status: "updated" }; - updatedCount++; - writtenIdentities.push( - { - type: p.row.type as MeasurementType, - measuredAt: p.row.measuredAt as Date, - }, - ...(verdict.dirtyIdentities ?? []), - ); - break; - case "duplicate": - results[p.index] = { index: p.index, status: "duplicate" }; - duplicateCount++; - break; - case "failed": - results[p.index] = { - index: p.index, - status: "failed", - reason: verdict.error.code ?? "write_failed", - }; - failedCount++; - continue; - } - - if (isMergeableSource(p.row.source as string)) { - inBatchCandidates.push({ + const verdict = await prisma.$transaction((tx) => + reconcileExternalMeasurement( + tx, + { + userId: user.id, type: p.row.type as MeasurementType, - source: p.row.source as string, value: p.row.value as number, + valueMin: p.row.valueMin as number | null, + valueMax: p.row.valueMax as number | null, + unit: p.row.unit as string, + source: p.row + .source as Prisma.MeasurementUncheckedCreateInput["source"], + measuredAt: p.row.measuredAt as Date, + externalId, + externalSourceVersion: p.row.externalSourceVersion as string | null, + glucoseContext: p.row + .glucoseContext as Prisma.MeasurementUncheckedCreateInput["glucoseContext"], + sleepStage: p.row + .sleepStage as Prisma.MeasurementUncheckedCreateInput["sleepStage"], + rhythmClassification: p.row + .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], + deviceType: p.row.deviceType as string | null, + }, + { exactExternalMatch: statsRow ? "update" : "duplicate" }, + ), + ); + + switch (verdict.status) { + case "inserted": + results[p.index] = { index: p.index, status: "inserted" }; + insertedCount++; + insertedPrepared.push(p); + writtenIdentities.push({ + type: p.row.type as MeasurementType, measuredAt: p.row.measuredAt as Date, }); - } + break; + case "updated": + case "resurrected": + results[p.index] = { index: p.index, status: "updated" }; + updatedCount++; + writtenIdentities.push( + { + type: p.row.type as MeasurementType, + measuredAt: p.row.measuredAt as Date, + }, + ...(verdict.dirtyIdentities ?? []), + ); + break; + case "duplicate": + results[p.index] = { index: p.index, status: "duplicate" }; + duplicateCount++; + break; + case "failed": + results[p.index] = { + index: p.index, + status: "failed", + reason: verdict.error.code ?? "write_failed", + }; + failedCount++; + continue; + } + + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } } } diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index c036c0268..308b67d60 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -13,7 +13,6 @@ const txClient = vi.hoisted(() => ({ }, })); - vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { @@ -222,9 +221,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { error: null; }; expect(body.data.tagKeys).toEqual(["happy"]); - expect(body.data.ratedFactors).toEqual([ - { key: "factor_work", rating: 4 }, - ]); + expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 4 }]); expect(body.error).toBeNull(); }); @@ -254,9 +251,7 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }; }; expect(body.data.tagKeys).toEqual(["happy"]); - expect(body.data.ratedFactors).toEqual([ - { key: "factor_work", rating: 5 }, - ]); + expect(body.data.ratedFactors).toEqual([{ key: "factor_work", rating: 5 }]); }); it("returns 422 when a factor score is outside its catalog scale", async () => { @@ -333,18 +328,18 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => { }); }); - it.each([ - { ratedFactors: null }, - { ratedFactors: [] }, - ])("clears RATED links for an explicit $ratedFactors", async (payload) => { - const res = await PUT(putReq(payload), ROUTE_CTX); + it.each([{ ratedFactors: null }, { ratedFactors: [] }])( + "clears RATED links for an explicit $ratedFactors", + async (payload) => { + const res = await PUT(putReq(payload), ROUTE_CTX); - expect(res.status).toBe(200); - expect(replaceRatedFactorLinks).toHaveBeenCalledWith( - "me1", - "user-1", - [], - txClient, - ); - }); + expect(res.status).toBe(200); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [], + txClient, + ); + }, + ); }); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 29b327804..3586b1a6d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -46,9 +46,7 @@ function makePrisma() { externalId: "moodlog-42", moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"), deletedAt, - tagLinks: [ - { rating: 4, moodTag: { key: "sleep_quality" } }, - ], + tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }], }, ]), }, @@ -94,10 +92,14 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { installSectionMocks(); const prisma = makePrisma(); - const { payload } = await buildFullBackupPayload(prisma as never, "user-1", { - purpose: "disaster-recovery", - exportedAt: new Date("2026-07-20T00:00:00.000Z"), - }); + const { payload } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); expect(mocks.findMeasurementsPaged).toHaveBeenCalledWith( prisma, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index 1637560a7..a534de18a 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -368,9 +368,7 @@ export async function buildRecordsBackupSection( mimeType: d.mimeType, byteSize: d.byteSize, status: d.status, - reportDate: d.reportDate - ? d.reportDate.toISOString().slice(0, 10) - : null, + reportDate: d.reportDate ? d.reportDate.toISOString().slice(0, 10) : null, documentDate: d.documentDate ? d.documentDate.toISOString().slice(0, 10) : null, @@ -415,8 +413,7 @@ export async function buildRecordsBackupSection( documents: disasterRecovery ? { included: "encrypted-content", - note: - "Document metadata and encrypted stored bytes are included for disaster recovery.", + note: "Document metadata and encrypted stored bytes are included for disaster recovery.", } : { included: "metadata-only", note: DOCUMENTS_MANIFEST_NOTE }, workouts: { included: "summary-only", note: WORKOUTS_MANIFEST_NOTE }, diff --git a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts index c873d070d..05de54a0a 100644 --- a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts +++ b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts @@ -3,12 +3,13 @@ import type { Job } from "pg-boss"; import { readFileSync } from "node:fs"; import { join } from "node:path"; const { syncUserEcg } = vi.hoisted(() => ({ - syncUserEcg: vi.fn< - ( - userId: string, - options?: { startdate?: number; enddate?: number }, - ) => Promise - >(), + syncUserEcg: + vi.fn< + ( + userId: string, + options?: { startdate?: number; enddate?: number }, + ) => Promise + >(), })); vi.mock("@/lib/withings/sync-ecg", () => ({ @@ -70,10 +71,7 @@ beforeEach(() => { vi.clearAllMocks(); }); const registrarSource = readFileSync( - join( - process.cwd(), - "src/lib/jobs/reminder/register-integration-sync.ts", - ), + join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"), "utf8", ); diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index 5ee8ff29d..30cdc3b14 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -38,9 +38,9 @@ const documentCiphertext = Buffer.from([1, 2, 3, 4]).toString("base64"); function buildPrismaMock() { return { user: { - findMany: vi.fn().mockResolvedValue([ - { id: "user-dr", username: "backup-owner" }, - ]), + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-dr", username: "backup-owner" }]), }, dataBackup: { upsert: mocks.upsert }, }; diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 8b419ae2e..1106159ed 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -58,11 +58,9 @@ export async function handleDataBackup(jobs: Job[]) { let backed = 0; for (const user of users) { try { - const { payload } = await buildFullBackupPayload( - prisma, - user.id, - { purpose: "disaster-recovery" }, - ); + const { payload } = await buildFullBackupPayload(prisma, user.id, { + purpose: "disaster-recovery", + }); const backupJson = JSON.stringify(payload); // Encrypt the backup data (contains sensitive health information) diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index f42305b0b..6b3909996 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -17,7 +17,6 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { getWorkerPrisma } from "./shared"; export type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; - export interface WithingsSyncPayload { triggeredAt: string; } diff --git a/src/lib/jobs/withings-ecg-queue.ts b/src/lib/jobs/withings-ecg-queue.ts index 9026d1b20..34a0e0ad6 100644 --- a/src/lib/jobs/withings-ecg-queue.ts +++ b/src/lib/jobs/withings-ecg-queue.ts @@ -49,9 +49,7 @@ export async function enqueueWithingsEcgSync( userId: identity.userId, eventId, triggeredAt: new Date().toISOString(), - ...(parsedStartdate?.success - ? { startdate: parsedStartdate.data } - : {}), + ...(parsedStartdate?.success ? { startdate: parsedStartdate.data } : {}), ...(parsedEnddate?.success ? { enddate: parsedEnddate.data } : {}), } satisfies WithingsEcgSyncPayload, { diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts index 41ca58bb2..4034767ec 100644 --- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts +++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts @@ -24,9 +24,7 @@ function desired( }; } -function row( - overrides: Record = {}, -): Record { +function row(overrides: Record = {}): Record { return { id: "row-1", userId: "user-1", diff --git a/src/lib/measurements/reconcile-external-measurement.ts b/src/lib/measurements/reconcile-external-measurement.ts index 95ed27a51..d45d998c9 100644 --- a/src/lib/measurements/reconcile-external-measurement.ts +++ b/src/lib/measurements/reconcile-external-measurement.ts @@ -36,10 +36,7 @@ export type ReconciledMeasurementRow = Pick< "id" | "type" | "measuredAt" | "externalId" >; -export type DirtyMeasurementIdentity = Pick< - Measurement, - "type" | "measuredAt" ->; +export type DirtyMeasurementIdentity = Pick; export type MeasurementReconciliationVerdict = | { @@ -126,7 +123,9 @@ async function lockUser( async function lockAndReloadCandidates( tx: PrismaTypes.TransactionClient, desired: ExternalMeasurementWrite, -): Promise>> { +): Promise< + Array> +> { const where = { userId: desired.userId, OR: [ @@ -262,6 +261,7 @@ export async function reconcileExternalMeasurement( } const { userId: _ownerId, ...mutable } = desired; + void _ownerId; const updated = await tx.measurement.update({ where: { id: canonical.id }, data: { diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index e1abeb0ab..c14e7c7e6 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -272,7 +272,6 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { skipDuplicates: true, }); }); - }); describe("entry-ownership guard", () => { diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index af6578fee..d59602a77 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -142,17 +142,22 @@ beforeEach(() => { transactionMock .mockReset() .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); - reconcileMock.mockReset().mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ - status: "inserted", - row: { - id: `inserted:${input.externalId}`, - type: input.type, - measuredAt: input.measuredAt, - externalId: input.externalId, - }, - }), - ); + reconcileMock + .mockReset() + .mockImplementation( + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index c534fabc1..779cf1bdf 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -461,30 +461,33 @@ export async function upsertOuraMeasurements( InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - const verdicts = await prisma.$transaction(async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "OURA", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "OURA", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, { timeout: 60_000 }); + return outcomes; + }, + { timeout: 60_000 }, + ); for (let index = 0; index < readings.length; index++) { const reading = readings[index]!; diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index da00e02cd..b80055b53 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -106,17 +106,22 @@ beforeEach(() => { transactionMock .mockReset() .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); - reconcileMock.mockReset().mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ - status: "inserted", - row: { - id: `inserted:${input.externalId}`, - type: input.type, - measuredAt: input.measuredAt, - externalId: input.externalId, - }, - }), - ); + reconcileMock + .mockReset() + .mockImplementation( + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ + status: "inserted", + row: { + id: `inserted:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }, + }), + ); createManyAndReturnMock.mockReset().mockImplementation( async ({ data, diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index f321239a5..e3a4c5283 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -210,30 +210,33 @@ export async function upsertPolarMeasurements( InsertedMeasurementArrivalRow & { externalId: string | null } > = []; - const verdicts = await prisma.$transaction(async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "POLAR", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (const r of readings) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: r.type as MeasurementType, + source: "POLAR", + value: r.value, + unit: r.unit, + measuredAt: r.measuredAt, + externalId: r.externalId, + sleepStage: r.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, { timeout: 60_000 }); + return outcomes; + }, + { timeout: 60_000 }, + ); for (let index = 0; index < readings.length; index++) { const reading = readings[index]!; diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 800316eaf..e416407c4 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -68,7 +68,10 @@ export function classifyStravaFailure(err: unknown): FailureKind { return toFailureKind(classifyStravaError(err)); } -async function recordStravaFailure(userId: string, err: unknown): Promise { +async function recordStravaFailure( + userId: string, + err: unknown, +): Promise { await recordSyncFailure({ userId, integration: "strava", diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index fb50d852b..ca68e8f64 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -46,7 +46,6 @@ import { SecondarySymptom, } from "@/generated/prisma/enums"; - export const BACKUP_SCHEMA_VERSION = "1" as const; const isoDateTime = z @@ -351,8 +350,7 @@ const base64BytesSchema = z .string() .min(1) .refine( - (value) => - value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), { message: "Expected base64-encoded encrypted bytes" }, ); diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index c8e56648d..6416e109c 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -118,7 +118,10 @@ beforeEach(() => { async (run: (tx: unknown) => unknown) => run(prismaMock), ); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "inserted", row: { id: `inserted:${input.externalId}`, @@ -167,7 +170,6 @@ describe("getValidToken — rotating refresh", () => { expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1); }); - it("re-reads token state under an advisory lock and reuses a peer winner", async () => { prismaMock.whoopConnection.findUnique .mockResolvedValueOnce({ @@ -549,7 +551,10 @@ describe("syncUserRecovery — durable cursor ordering", () => { }); fetchRecoveriesMock.mockResolvedValue([recovery]); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "updated", row: { id: `existing:${input.externalId}`, diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index dd5271afd..565455c02 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -107,11 +107,14 @@ function installFetchMock(segments: FakeSegment[]) { beforeEach(() => { vi.resetAllMocks(); - transactionMock.mockImplementation( - async (run: (tx: unknown) => unknown) => run({}), + transactionMock.mockImplementation(async (run: (tx: unknown) => unknown) => + run({}), ); reconcileMock.mockImplementation( - async (_tx: unknown, input: { type: string; measuredAt: Date; externalId: string }) => ({ + async ( + _tx: unknown, + input: { type: string; measuredAt: Date; externalId: string }, + ) => ({ status: "inserted", row: { id: `inserted:${input.externalId}`, @@ -502,10 +505,7 @@ describe("syncUserSleep — segment writes + idempotency", () => { externalId: string; value: number; }; - return [ - row.type, - { externalId: row.externalId, value: row.value }, - ]; + return [row.type, { externalId: row.externalId, value: row.value }]; }), ); expect(byType.get("RESTING_HEART_RATE")).toEqual({ diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts index 61cfb92ec..04c99bb1a 100644 --- a/src/lib/withings/sync-ecg.ts +++ b/src/lib/withings/sync-ecg.ts @@ -496,7 +496,6 @@ export async function syncUserEcg( } } - annotate({ action: { name: "withings.ecg.sync" }, meta: { diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 6ba1cb710..f878d39a1 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -412,48 +412,51 @@ export async function syncUserSleep( // and are excluded (their `no-id` prefix would span every id-less night in // history, not just this fetch). const freshBySession = new Map(); - await prisma.$transaction(async (tx) => { - for (const segment of segments) { - const stage = mapWithingsSleepState(segment.state); - if (!stage) continue; + await prisma.$transaction( + async (tx) => { + for (const segment of segments) { + const stage = mapWithingsSleepState(segment.state); + if (!stage) continue; - const measuredAt = new Date(segment.enddate * 1000); - const durationSec = Math.max(0, segment.enddate - segment.startdate); - const minutes = Math.round(durationSec / 60); - const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; - if (typeof segment.id === "number") { - const fresh = freshBySession.get(segment.id) ?? []; - fresh.push(externalId); - freshBySession.set(segment.id, fresh); - } + const measuredAt = new Date(segment.enddate * 1000); + const durationSec = Math.max(0, segment.enddate - segment.startdate); + const minutes = Math.round(durationSec / 60); + const externalId = `withings:sleep:${userId}:${segment.id ?? "no-id"}:${segment.startdate}`; + if (typeof segment.id === "number") { + const fresh = freshBySession.get(segment.id) ?? []; + fresh.push(externalId); + freshBySession.set(segment.id, fresh); + } - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: SLEEP_TYPE, - value: minutes, - unit: getUnitForType(SLEEP_TYPE), - measuredAt, - source: "WITHINGS", - sleepStage: stage, - externalId, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: SLEEP_TYPE, + value: minutes, + unit: getUnitForType(SLEEP_TYPE), + measuredAt, + source: "WITHINGS", + sleepStage: stage, + externalId, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + if (verdict.status === "inserted") { + insertedSleepMeasuredAts.push(measuredAt); + } + touched.push({ type: SLEEP_TYPE, measuredAt }); + imported++; } - for (const dirty of verdict.dirtyIdentities ?? []) { - touched.push(dirty); - } - if (verdict.status === "inserted") { - insertedSleepMeasuredAts.push(measuredAt); - } - touched.push({ type: SLEEP_TYPE, measuredAt }); - imported++; - } - }, { timeout: 60_000 }); + }, + { timeout: 60_000 }, + ); // Session-scoped sweep (mirrors Google Health's replace-by-window): for each // session this fetch re-produced, soft-delete any live WITHINGS @@ -486,35 +489,38 @@ export async function syncUserSleep( ); } - await prisma.$transaction(async (tx) => { - for (const summary of summaries) { - const measuredAt = new Date(summary.enddate * 1000); - const sessionId = summary.id ?? "no-id"; - for (const vital of mapWithingsSleepSummary(summary.data)) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: vital.type, - value: vital.value, - unit: getUnitForType(vital.type), - measuredAt, - source: "WITHINGS", - externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + await prisma.$transaction( + async (tx) => { + for (const summary of summaries) { + const measuredAt = new Date(summary.enddate * 1000); + const sessionId = summary.id ?? "no-id"; + for (const vital of mapWithingsSleepSummary(summary.data)) { + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: vital.type, + value: vital.value, + unit: getUnitForType(vital.type), + measuredAt, + source: "WITHINGS", + externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); + } + touched.push({ type: vital.type, measuredAt }); + imported++; } - for (const dirty of verdict.dirtyIdentities ?? []) { - touched.push(dirty); - } - touched.push({ type: vital.type, measuredAt }); - imported++; } - } - }, { timeout: 60_000 }); + }, + { timeout: 60_000 }, + ); // v1.4.39.1 — refresh the persistent rollup table for every distinct // (type, day) the sync touched. Sleep segments collapse heavily — diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index ee2b12f1a..e61f05ff9 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -12,7 +12,6 @@ process.env.BACKUP_S3_ACCESS_KEY = "test-access"; process.env.BACKUP_S3_SECRET_KEY = "test-secret"; process.env.BACKUP_ENCRYPTION_KEY = OFFHOST_ENCRYPTION_KEY; - import { encrypt, encryptBytes } from "@/lib/crypto"; import { encryptToBytes, decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -337,15 +336,10 @@ describe("canonical disaster-recovery backup round-trip", () => { failed: 0, totalUsers: 1, }); - const offhostObject = s3.objects.get( - `2026-07-02/user-${ownerId}.json.enc`, - ); + const offhostObject = s3.objects.get(`2026-07-02/user-${ownerId}.json.enc`); expect(offhostObject).toBeDefined(); const payload = parseBackupPayload( - decryptBackup( - offhostObject!, - Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex"), - ), + decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")), ); expect(payload).toMatchObject({ schemaVersion: "1", @@ -379,14 +373,26 @@ describe("canonical disaster-recovery backup round-trip", () => { expect((await restore()).status).toBe(200); - expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.illnessEpisode.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.illnessDayLog.count({ where: { userId: ownerId } })).toBe(1); + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe( + 1, + ); + expect(await prisma.biomarker.count({ where: { userId: ownerId } })).toBe( + 1, + ); + expect( + await prisma.illnessEpisode.count({ where: { userId: ownerId } }), + ).toBe(1); + expect( + await prisma.illnessDayLog.count({ where: { userId: ownerId } }), + ).toBe(1); expect(await prisma.allergy.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.familyHistoryEntry.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.familyHistoryEntry.count({ where: { userId: ownerId } }), + ).toBe(1); expect(await prisma.workout.count({ where: { userId: ownerId } })).toBe(1); - expect(await prisma.inboundDocument.count({ where: { userId: ownerId } })).toBe(1); + expect( + await prisma.inboundDocument.count({ where: { userId: ownerId } }), + ).toBe(1); expect( await prisma.measurement.findUniqueOrThrow({ where: { id: measurement.id }, @@ -466,7 +472,9 @@ describe("canonical disaster-recovery backup round-trip", () => { where: { id: illnessDay.id }, include: { symptomLinks: true }, }); - expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe("Rest day"); + expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe( + "Rest day", + ); expect(restoredIllnessDay.symptomLinks).toEqual([ expect.objectContaining({ symptomId: symptom.id, severity: 2 }), ]); @@ -625,55 +633,57 @@ describe("canonical disaster-recovery backup round-trip", () => { payload.schemaVersion = "999"; }, }, - ])("rejects $label with 422 before writes or invalidation", async ({ mutate }) => { - const prisma = getPrismaClient(); - const admin = await seedAdminSession(); - const existing = await prisma.measurement.create({ - data: { - id: "pre-validation-measurement", + ])( + "rejects $label with 422 before writes or invalidation", + async ({ mutate }) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const existing = await prisma.measurement.create({ + data: { + id: "pre-validation-measurement", + userId: admin.id, + type: "WEIGHT", + value: 91, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-03T06:00:00.000Z"), + }, + }); + const payload: Record = { + schemaVersion: "1", + exportedAt: "2026-07-03T08:00:00.000Z", userId: admin.id, - type: "WEIGHT", - value: 91, - unit: "kg", - source: "MANUAL", - measuredAt: new Date("2026-07-03T06:00:00.000Z"), - }, - }); - const payload: Record = { - schemaVersion: "1", - exportedAt: "2026-07-03T08:00:00.000Z", - userId: admin.id, - measurements: [], - moodEntries: [], - }; - mutate(payload); - const backup = await prisma.dataBackup.create({ - data: { - userId: admin.id, - type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`, - data: encrypt(JSON.stringify(payload)), - }, - }); + measurements: [], + moodEntries: [], + }; + mutate(payload); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: `INVALID_BOUNDARY_${String(payload.schemaVersion)}`, + data: encrypt(JSON.stringify(payload)), + }, + }); - const response = await POST( - makeRequest(backup.id) as unknown as Parameters[0], - { params: Promise.resolve({ id: backup.id }) }, - ); - - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - data: null, - error: expect.any(String), - }); - expect( - await prisma.measurement.findMany({ - where: { userId: admin.id }, - select: { id: true }, - }), - ).toEqual([{ id: existing.id }]); - expect(invalidateUserData).not.toHaveBeenCalled(); - }); + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ + data: null, + error: expect.any(String), + }); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + select: { id: true }, + }), + ).toEqual([{ id: existing.id }]); + expect(invalidateUserData).not.toHaveBeenCalled(); + }, + ); it("rejects metadata-only documents before mutation or invalidation", async () => { const prisma = getPrismaClient(); @@ -784,9 +794,9 @@ describe("canonical disaster-recovery backup round-trip", () => { select: { id: true }, }), ).toEqual([{ id: measurement.id }]); - expect( - await prisma.moodEntry.count({ where: { userId: admin.id } }), - ).toBe(0); + expect(await prisma.moodEntry.count({ where: { userId: admin.id } })).toBe( + 0, + ); expect(invalidateUserData).not.toHaveBeenCalled(); }); }); diff --git a/tests/integration/admin-backups-upload.test.ts b/tests/integration/admin-backups-upload.test.ts index 39c822251..d6866f0ff 100644 --- a/tests/integration/admin-backups-upload.test.ts +++ b/tests/integration/admin-backups-upload.test.ts @@ -239,34 +239,43 @@ describe("POST /api/admin/backups/upload", () => { }); it.each([ - ["measurement type", (payload: UploadPayloadFixture) => { - payload.measurements[0]!.type = "NOT_A_MEASUREMENT"; - }], - ["measurement source", (payload: UploadPayloadFixture) => { - payload.measurements[0]!.source = "NOT_A_SOURCE"; - }], - ])("rejects an invalid %s with 422 and zero backup writes", async (_label, mutate) => { - const prisma = getPrismaClient(); - const admin = await seedAdminSession(); - const payload = buildPayload(admin.id); - mutate(payload); - const file = new File([JSON.stringify(payload)], "invalid-enum.json", { - type: "application/json", - }); + [ + "measurement type", + (payload: UploadPayloadFixture) => { + payload.measurements[0]!.type = "NOT_A_MEASUREMENT"; + }, + ], + [ + "measurement source", + (payload: UploadPayloadFixture) => { + payload.measurements[0]!.source = "NOT_A_SOURCE"; + }, + ], + ])( + "rejects an invalid %s with 422 and zero backup writes", + async (_label, mutate) => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const payload = buildPayload(admin.id); + mutate(payload); + const file = new File([JSON.stringify(payload)], "invalid-enum.json", { + type: "application/json", + }); - const { POST } = await import("@/app/api/admin/backups/upload/route"); - const res = await POST( - buildMultipart(file) as unknown as Parameters[0], - ); + const { POST } = await import("@/app/api/admin/backups/upload/route"); + const res = await POST( + buildMultipart(file) as unknown as Parameters[0], + ); - expect(res.status).toBe(422); - expect(await res.json()).toMatchObject({ - data: null, - error: "Backup payload failed schema validation", - meta: { issues: expect.any(Array) }, - }); - expect(await prisma.dataBackup.count()).toBe(0); - }); + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ + data: null, + error: "Backup payload failed schema validation", + meta: { issues: expect.any(Array) }, + }); + expect(await prisma.dataBackup.count()).toBe(0); + }, + ); it("rejects an incompatible schemaVersion with 422", async () => { const prisma = getPrismaClient(); diff --git a/tests/integration/device-register.test.ts b/tests/integration/device-register.test.ts index 94381cf5a..c4ffd314d 100644 --- a/tests/integration/device-register.test.ts +++ b/tests/integration/device-register.test.ts @@ -175,7 +175,9 @@ describe("POST /api/devices", () => { const secondBody = (await second.json()) as { data: { id: string } }; expect(secondBody.data.id).toBe(firstBody.data.id); - const devices = await prisma.device.findMany({ where: { userId: user.id } }); + const devices = await prisma.device.findMany({ + where: { userId: user.id }, + }); expect(devices).toHaveLength(1); expect(devices[0]).toMatchObject({ id: firstBody.data.id, @@ -214,7 +216,7 @@ describe("POST /api/devices", () => { liveActivityPushToken: "feedface".repeat(8), }, }); - const legacyDevice = await prisma.device.create({ + await prisma.device.create({ data: { userId: user.id, platform: "ios", @@ -252,7 +254,9 @@ describe("POST /api/devices", () => { const body = (await response.json()) as { data: { id: string } }; expect(body.data.id).toBe(apnsDevice.id); - const devices = await prisma.device.findMany({ where: { userId: user.id } }); + const devices = await prisma.device.findMany({ + where: { userId: user.id }, + }); expect(devices).toHaveLength(1); expect(devices[0]).toMatchObject({ id: apnsDevice.id, diff --git a/tests/integration/measurement-identity-reconcile.test.ts b/tests/integration/measurement-identity-reconcile.test.ts index 451c4b0f6..4835692cf 100644 --- a/tests/integration/measurement-identity-reconcile.test.ts +++ b/tests/integration/measurement-identity-reconcile.test.ts @@ -110,9 +110,9 @@ describe("external measurement identity reconciliation (real Postgres)", () => { status: "updated", row: { id: occupied.id, externalId: "sleep:new" }, }); - expect( - await prisma.measurement.count({ where: { userId: user.id } }), - ).toBe(1); + expect(await prisma.measurement.count({ where: { userId: user.id } })).toBe( + 1, + ); }); it("resurrects and re-keys a tombstone occupying the natural key", async () => { diff --git a/tests/integration/partial-unique-live-rows.test.ts b/tests/integration/partial-unique-live-rows.test.ts index 145c95052..c73ae9870 100644 --- a/tests/integration/partial-unique-live-rows.test.ts +++ b/tests/integration/partial-unique-live-rows.test.ts @@ -347,7 +347,9 @@ describe("MoodEntry — external-id re-import idempotency (v1.12.1 / 0122)", () mood: "OKAY", score: 3, }); - expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe(1); + expect(await prisma.moodEntry.count({ where: { userId: user.id } })).toBe( + 1, + ); }); it("entries without an id still dedup on the legacy wall-clock key", async () => { diff --git a/tests/integration/provider-token-refresh-serialization.test.ts b/tests/integration/provider-token-refresh-serialization.test.ts index cc8afa424..88a38b2f1 100644 --- a/tests/integration/provider-token-refresh-serialization.test.ts +++ b/tests/integration/provider-token-refresh-serialization.test.ts @@ -79,10 +79,7 @@ async function seedProviderUser(provider: "whoop" | "withings") { return userId; } -async function runContenders( - provider: "whoop" | "withings", - userId: string, -) { +async function runContenders(provider: "whoop" | "withings", userId: string) { const getToken = provider === "whoop" ? getValidWhoopToken : getValidWithingsToken; return Promise.all( @@ -98,62 +95,73 @@ beforeEach(async () => { describe.each([ ["whoop", whoopRefresh], ["withings", withingsRefresh], -] as const)("%s rotating token refresh serialization (real Postgres)", (provider, refresh) => { - it("lets exactly one contender refresh a stale token and shares the winner", async () => { - const userId = await seedProviderUser(provider); - refresh.mockImplementation(async (refreshToken: string) => { - expect(refreshToken).toBe("stale-refresh"); - return { - access_token: "winner-access", - refresh_token: "winner-refresh", - expires_in: 3600, - }; - }); - - const results = await runContenders(provider, userId); - - expect(refresh).toHaveBeenCalledTimes(1); - expect(results).toHaveLength(CONTENDERS); - expect(results.every((result) => result?.accessToken === "winner-access")).toBe( - true, - ); - - const prisma = getPrismaClient(); - const row = - provider === "whoop" - ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) - : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); - expect(decrypt(row.accessToken)).toBe("winner-access"); - expect(decrypt(row.refreshToken)).toBe("winner-refresh"); - }); - - it("rolls back a failed winner so a later contender can retry", async () => { - const userId = await seedProviderUser(provider); - refresh - .mockRejectedValueOnce(new Error("injected refresh failure")) - .mockResolvedValue({ - access_token: "retry-access", - refresh_token: "retry-refresh", - expires_in: 3600, +] as const)( + "%s rotating token refresh serialization (real Postgres)", + (provider, refresh) => { + it("lets exactly one contender refresh a stale token and shares the winner", async () => { + const userId = await seedProviderUser(provider); + refresh.mockImplementation(async (refreshToken: string) => { + expect(refreshToken).toBe("stale-refresh"); + return { + access_token: "winner-access", + refresh_token: "winner-refresh", + expires_in: 3600, + }; }); - const results = await runContenders(provider, userId); + const results = await runContenders(provider, userId); - expect(refresh).toHaveBeenCalledTimes(2); - expect(results.filter((result) => result === null)).toHaveLength(1); - expect( - results.filter((result) => result?.accessToken === "retry-access"), - ).toHaveLength(CONTENDERS - 1); + expect(refresh).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(CONTENDERS); + expect( + results.every((result) => result?.accessToken === "winner-access"), + ).toBe(true); - const prisma = getPrismaClient(); - const row = - provider === "whoop" - ? await prisma.whoopConnection.findUniqueOrThrow({ where: { userId } }) - : await prisma.withingsConnection.findUniqueOrThrow({ where: { userId } }); - expect(decrypt(row.accessToken)).toBe("retry-access"); - expect(decrypt(row.refreshToken)).toBe("retry-refresh"); - }); -}); + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }) + : await prisma.withingsConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(decrypt(row.accessToken)).toBe("winner-access"); + expect(decrypt(row.refreshToken)).toBe("winner-refresh"); + }); + + it("rolls back a failed winner so a later contender can retry", async () => { + const userId = await seedProviderUser(provider); + refresh + .mockRejectedValueOnce(new Error("injected refresh failure")) + .mockResolvedValue({ + access_token: "retry-access", + refresh_token: "retry-refresh", + expires_in: 3600, + }); + + const results = await runContenders(provider, userId); + + expect(refresh).toHaveBeenCalledTimes(2); + expect(results.filter((result) => result === null)).toHaveLength(1); + expect( + results.filter((result) => result?.accessToken === "retry-access"), + ).toHaveLength(CONTENDERS - 1); + + const prisma = getPrismaClient(); + const row = + provider === "whoop" + ? await prisma.whoopConnection.findUniqueOrThrow({ + where: { userId }, + }) + : await prisma.withingsConnection.findUniqueOrThrow({ + where: { userId }, + }); + expect(decrypt(row.accessToken)).toBe("retry-access"); + expect(decrypt(row.refreshToken)).toBe("retry-refresh"); + }); + }, +); describe("WHOOP recovery cursor durability (real Postgres)", () => { const oldCursor = "2026-06-01T00:00:00.000Z"; From a87a144b1e6a109cbee09ed4acc78bb4bc70dbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 12:29:21 +0200 Subject: [PATCH 13/35] chore(release): prepare v1.31.2 --- CHANGELOG.md | 23 +++++++++++++++++++++++ package.json | 2 +- public/sw.js | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8bc8703..43f82a440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [1.31.2] — 2026-07-20 + +- **Provider imports now commit identity and progress atomically.** Withings, + WHOOP, Oura, Polar, Strava, and Apple Health reconcile external and natural + measurement identities under database locks, preserve tombstones, emit + arrivals only for genuine inserts, and advance cursors only after durable + writes. OAuth refresh-token rotation is serialized per user and provider. +- **Webhook success now means the source data is durable.** moodLog writes + before acknowledging delivery, while Withings ECG callbacks enter a durable + queue with bounded retry and replay-safe processing. Operational JSON import + failures are reported as failures instead of misleading duplicate skips. +- **Recovery is complete and owner-safe.** Canonical backups round-trip every + supported record class, including encrypted document content and soft-deleted + measurements. Restore rejects account mismatches before writing, validates + backup enums at the boundary, and preserves recovery integrity across both + uploaded and off-host payloads. +- **Device and mood edits no longer lose related state.** Same-user APNs + registrations merge transactionally without crossing account boundaries, + refresh-token bindings follow the surviving device, and mood edits preserve + rated factors unless the caller explicitly replaces them. + +No migrations. No breaking changes. + ## [1.31.1] — 2026-07-19 - **Arrival events now follow committed inserts across every writer.** Provider diff --git a/package.json b/package.json index 5a5fccee2..f1b40080c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.31.1", + "version": "1.31.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 743bf73d0..1b480b10f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.31.1"; + /* @sw-version-fallback */ "v1.31.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 5fc0b1eead993faa14d4071eebe2988cafc615b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 12:32:03 +0200 Subject: [PATCH 14/35] docs(api): sync spec for v1.31.2 --- docs/api/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f91e1dd7b..f1b8f627d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.31.1 + version: 1.31.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. From e47d02702d48ddbcf3cbcb5cbdd83fb299d2d84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:03:36 +0200 Subject: [PATCH 15/35] fix(import): stabilize legacy mood retries --- src/app/api/import/__tests__/route.test.ts | 55 ++++++++++++++++++++++ src/app/api/import/route.ts | 7 ++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 5e0647b8f..3450bcce8 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -777,6 +777,61 @@ describe("POST /api/import — write failure classification", () => { }); }); + it("resumes a partially committed legacy mood import without duplicates", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); + + const committedKeys = new Set(); + let writeAttempt = 0; + vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { + writeAttempt++; + if (writeAttempt === 2) { + throw new Error("transient write failure"); + } + + const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; + if (committedKeys.has(key)) { + throw new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ); + } + committedKeys.add(key); + return {} as never; + }); + + const payload = { + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4 }, + { date: "2026-05-02", mood: "OKAY", score: 3 }, + ], + }; + + const failedResponse = await POST(request(payload)); + expect(failedResponse.status).toBe(503); + expectRetryableFailure(await failedResponse.json(), { + measurements: 0, + moodEntries: 1, + skipped: 0, + }); + + vi.advanceTimersByTime(1000); + const retryResponse = await POST(request(payload)); + + expect(retryResponse.status).toBe(200); + expect(await retryResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 1, skipped: 1 }, + error: null, + }); + expect(committedKeys.size).toBe(2); + + vi.useRealTimers(); + }); + it("returns a retryable failure for a generic mood write error", async () => { vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( new Error("write failed"), diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 6e633e756..e11675c34 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -240,9 +240,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // Import mood entries if (!writeFailed && data.moodEntries?.length) { - for (const e of data.moodEntries) { + for (const [index, e] of data.moodEntries.entries()) { try { - const loggedAt = e.loggedAt ?? new Date(); + // Keep legacy rows retry-stable while preserving distinct rows per payload. + const loggedAt = + e.loggedAt ?? + new Date(Date.parse(`${e.date}T12:00:00.000Z`) + index); if (e.externalId) { // v1.12.1 — idempotent re-import keyed on the source-stable id. // A second import of the same export updates the row in place From 197f7bf5a11614026a6fbbfe7df4b1af3a8a9d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:08:48 +0200 Subject: [PATCH 16/35] fix(whoop): persist backfill chunks durably --- src/lib/whoop/__tests__/sync.test.ts | 86 +++++++++++++++++++++ src/lib/whoop/sync-sleep.ts | 10 ++- src/lib/whoop/sync.ts | 109 +++++++++++++++------------ 3 files changed, 153 insertions(+), 52 deletions(-) diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index 6416e109c..bbdf84e29 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -530,6 +530,92 @@ describe("syncUserRecovery — durable cursor ordering", () => { expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); }); + it("keeps earlier chunks durable and resumes without duplicate rows after a later chunk fails", async () => { + const records = Array.from({ length: 21 }, (_, index) => ({ + ...recovery, + cycle_id: index + 1, + sleep_id: `sleep-${index.toString().padStart(2, "0")}`, + })); + fetchRecoveriesMock.mockResolvedValue(records); + + type StoredRow = { + id: string; + type: string; + measuredAt: Date; + externalId: string; + }; + type TransactionState = { rows: Map }; + + let committedRows = new Map(); + let committedInsertCount = 0; + let failLaterChunk = true; + + prismaMock.$transaction.mockImplementation( + async (run: (tx: TransactionState) => unknown) => { + const stagedRows = new Map(committedRows); + const result = await run({ rows: stagedRows }); + committedInsertCount += [...stagedRows.keys()].filter( + (key) => !committedRows.has(key), + ).length; + committedRows = stagedRows; + return result; + }, + ); + reconcileMock.mockImplementation( + async ( + tx: TransactionState, + input: { type: string; measuredAt: Date; externalId: string }, + ) => { + if (failLaterChunk && input.externalId === "sleep-20:recovery") { + return { + status: "failed", + reason: "db_error", + error: new Error("injected later-chunk failure"), + }; + } + + const existing = tx.rows.get(input.externalId); + const row = { + id: existing?.id ?? `row:${input.externalId}`, + type: input.type, + measuredAt: input.measuredAt, + externalId: input.externalId, + }; + tx.rows.set(input.externalId, row); + return { status: existing ? "updated" : "inserted", row }; + }, + ); + + await expect(syncUserRecovery("user1")).rejects.toThrow(); + + expect(committedRows.size).toBeGreaterThan(0); + expect(committedRows.size).toBeLessThan(105); + expect(prismaMock.$transaction.mock.calls.length).toBeGreaterThan(1); + expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); + + failLaterChunk = false; + await expect(syncUserRecovery("user1")).resolves.toBe(105); + + const expectedExternalIds = records.flatMap(({ sleep_id }) => + ["recovery", "hrv_rmssd", "rhr", "spo2", "skin_temp"].map( + (fieldTag) => `${sleep_id}:${fieldTag}`, + ), + ); + const emittedExternalIds = emitArrivalMock.mock.calls.flatMap((call) => + ( + call[1] as Array<{ + externalId: string; + }> + ).map((row) => row.externalId), + ); + + expect(committedRows).toHaveLength(105); + expect(committedInsertCount).toBe(105); + expect([...committedRows.keys()]).toEqual(expectedExternalIds); + expect(emittedExternalIds).toEqual(expectedExternalIds); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + }); + it("advances after success and replay updates identities without new arrivals", async () => { expect(await syncUserRecovery("user1")).toBe(5); expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 89fdbe36d..18711f9c3 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -94,12 +94,14 @@ export async function syncUserSleep( // the fresh set upserts (mirrors Google Health's replace-by-window order). await sweepStaleSleepSegments(userId, "WHOOP", sweeps); - let insertedSleepMeasuredAts: Date[] = []; + const insertedSleepMeasuredAts: Date[] = []; const imported = await upsertWhoopMeasurements(userId, readings, { onInserted: (rows) => { - insertedSleepMeasuredAts = rows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt); + insertedSleepMeasuredAts.push( + ...rows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt), + ); }, }); await markResourceSynced(userId, "sleep"); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index c275a49da..820fd15aa 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -323,10 +323,12 @@ export interface WhoopMeasurementUpsert { sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED" | null; } +const WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE = 100; + /** - * Upsert a batch of mapped WHOOP readings for one user and fold the rollup - * tier + invalidate status-insight caches once at the end (mirrors the - * Withings sync tail). The shared reconciler protects both external and + * Upsert mapped WHOOP readings for one user in bounded transactions, then fold + * the rollup tier + invalidate status-insight caches once at the end (mirrors + * the Withings sync tail). The shared reconciler protects both external and * natural identity; an exact WHOOP re-score overwrites in place. Returns the * count of rows written. * @@ -344,56 +346,67 @@ export async function upsertWhoopMeasurements( let imported = 0; const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; - const insertedRows: Array< - InsertedMeasurementArrivalRow & { externalId: string | null } - > = []; - const verdicts = await prisma.$transaction( - async (tx) => { - const outcomes = []; - for (const r of readings) { - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: r.type as MeasurementType, - source: "WHOOP", - value: r.value, - unit: r.unit, - measuredAt: r.measuredAt, - externalId: r.externalId, - sleepStage: r.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + for ( + let chunkStart = 0; + chunkStart < readings.length; + chunkStart += WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE + ) { + const chunkEnd = Math.min( + chunkStart + WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE, + readings.length, + ); + const verdicts = await prisma.$transaction( + async (tx) => { + const outcomes = []; + for (let index = chunkStart; index < chunkEnd; index++) { + const reading = readings[index]!; + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: reading.type as MeasurementType, + source: "WHOOP", + value: reading.value, + unit: reading.unit, + measuredAt: reading.measuredAt, + externalId: reading.externalId, + sleepStage: reading.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); + return outcomes; + }, + { maxWait: 10_000, timeout: 60_000 }, + ); + + const insertedRows: Array< + InsertedMeasurementArrivalRow & { externalId: string | null } + > = []; + for (let index = chunkStart; index < chunkEnd; index++) { + const reading = readings[index]!; + const verdict = verdicts[index - chunkStart]!; + for (const dirty of verdict.dirtyIdentities ?? []) { + touched.push(dirty); } - return outcomes; - }, - { maxWait: 10_000, timeout: 60_000 }, - ); + const type = reading.type as MeasurementType; + imported++; + touched.push({ type, measuredAt: reading.measuredAt }); + if (verdict.status === "inserted") { + insertedRows.push(verdict.row); + } + } - for (let index = 0; index < readings.length; index++) { - const reading = readings[index]!; - const verdict = verdicts[index]!; - for (const dirty of verdict.dirtyIdentities ?? []) { - touched.push(dirty); - } - const type = reading.type as MeasurementType; - imported++; - touched.push({ type, measuredAt: reading.measuredAt }); - if (verdict.status === "inserted") { - insertedRows.push(verdict.row); - } + opts.onInserted?.(insertedRows); + void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch( + () => {}, + ); } - - opts.onInserted?.(insertedRows); - void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch( - () => {}, - ); try { const keys = collapseToTypeDayKeys(touched); for (const k of keys) { From 007ea76327e8fea99bcea6145e3fe252a17396da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:12:55 +0200 Subject: [PATCH 17/35] fix(import): stabilize legacy mood retries --- src/app/api/import/__tests__/route.test.ts | 109 +++++++++++++++++++-- src/app/api/import/route.ts | 42 ++++++-- 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 3450bcce8..5281f0cd4 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({ upsert: vi.fn(), }, moodEntry: { create: vi.fn(), upsert: vi.fn() }, + $transaction: vi.fn(), }, })); @@ -77,6 +78,9 @@ import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); + vi.mocked(prisma.$transaction).mockImplementation( + (operation) => operation(prisma as never) as never, + ); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); encryptNoteMock.mockImplementation((note: string | null) => @@ -777,20 +781,22 @@ describe("POST /api/import — write failure classification", () => { }); }); - it("resumes a partially committed legacy mood import without duplicates", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); + it("rolls back a partial legacy mood import before a retry", async () => { + type LegacyMoodWrite = { date: string; moodLoggedAt: Date }; const committedKeys = new Set(); let writeAttempt = 0; - vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { + const writeMood = async ( + data: LegacyMoodWrite, + target: Set, + ): Promise => { writeAttempt++; if (writeAttempt === 2) { throw new Error("transient write failure"); } const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; - if (committedKeys.has(key)) { + if (target.has(key)) { throw new Prisma.PrismaClientKnownRequestError( "Unique constraint failed", { @@ -800,8 +806,26 @@ describe("POST /api/import — write failure classification", () => { }, ); } - committedKeys.add(key); + target.add(key); return {} as never; + }; + + vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) => + writeMood(data as LegacyMoodWrite, committedKeys), + ); + vi.mocked(prisma.$transaction).mockImplementation(async (operation) => { + const pendingKeys = new Set(committedKeys); + const result = await operation({ + moodEntry: { + create: vi.fn(({ data }: { data: LegacyMoodWrite }) => + writeMood(data, pendingKeys), + ), + upsert: prisma.moodEntry.upsert, + }, + } as never); + committedKeys.clear(); + for (const key of pendingKeys) committedKeys.add(key); + return result as never; }); const payload = { @@ -815,21 +839,86 @@ describe("POST /api/import — write failure classification", () => { expect(failedResponse.status).toBe(503); expectRetryableFailure(await failedResponse.json(), { measurements: 0, - moodEntries: 1, + moodEntries: 0, skipped: 0, }); + expect(committedKeys.size).toBe(0); - vi.advanceTimersByTime(1000); const retryResponse = await POST(request(payload)); expect(retryResponse.status).toBe(200); expect(await retryResponse.json()).toEqual({ - data: { measurements: 0, moodEntries: 1, skipped: 1 }, + data: { measurements: 0, moodEntries: 2, skipped: 0 }, error: null, }); expect(committedKeys.size).toBe(2); + }); - vi.useRealTimers(); + it("does not alias unrelated date-only mood imports", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); + const committedKeys = new Set(); + vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { + const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; + if (committedKeys.has(key)) { + throw new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ); + } + committedKeys.add(key); + return {} as never; + }); + + const firstResponse = await POST( + request({ + moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }], + }), + ); + vi.advanceTimersByTime(1000); + const secondResponse = await POST( + request({ + moodEntries: [{ date: "2026-05-10", mood: "SCHLECHT", score: 2 }], + }), + ); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(await secondResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 1, skipped: 0 }, + error: null, + }); + expect(committedKeys.size).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the current instant for a date-only mood row", async () => { + vi.useFakeTimers(); + try { + const now = new Date("2026-05-10T08:00:00.000Z"); + vi.setSystemTime(now); + vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never); + + const response = await POST( + request({ + moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }], + }), + ); + + expect(response.status).toBe(200); + expect(prisma.moodEntry.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ moodLoggedAt: now }), + }); + } finally { + vi.useRealTimers(); + } }); it("returns a retryable failure for a generic mood write error", async () => { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e11675c34..59301acd9 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -238,14 +238,18 @@ export const POST = apiHandler(async (request: NextRequest) => { .map((row) => row.measuredAt), ).catch(() => {}); - // Import mood entries + // Import mood entries with stable identities first. Rows without either + // identity are committed together so a retry cannot replay a partial batch. if (!writeFailed && data.moodEntries?.length) { - for (const [index, e] of data.moodEntries.entries()) { + const retryUnsafeEntries = []; + for (const e of data.moodEntries) { + if (!e.loggedAt && !e.externalId) { + retryUnsafeEntries.push(e); + continue; + } + try { - // Keep legacy rows retry-stable while preserving distinct rows per payload. - const loggedAt = - e.loggedAt ?? - new Date(Date.parse(`${e.date}T12:00:00.000Z`) + index); + const loggedAt = e.loggedAt ?? new Date(); if (e.externalId) { // v1.12.1 — idempotent re-import keyed on the source-stable id. // A second import of the same export updates the row in place @@ -300,6 +304,32 @@ export const POST = apiHandler(async (request: NextRequest) => { break; } } + + if (!writeFailed && retryUnsafeEntries.length > 0) { + const loggedAtBase = Date.now(); + let importedMoodEntries = 0; + try { + await prisma.$transaction(async (tx) => { + for (const [index, e] of retryUnsafeEntries.entries()) { + await tx.moodEntry.create({ + data: { + userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + source: "IMPORT", + moodLoggedAt: new Date(loggedAtBase + index), + }, + }); + importedMoodEntries++; + } + }); + stats.moodEntries += importedMoodEntries; + } catch { + writeFailed = true; + } + } } // v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than From 1b717b4ca02685d682bfa1f04449aa1277b44065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:16:34 +0200 Subject: [PATCH 18/35] fix(whoop): fold committed backfill chunks --- .../whoop/__tests__/sync-sleep-sweep.test.ts | 69 ++++++++++++++----- src/lib/whoop/__tests__/sync.test.ts | 16 ++++- src/lib/whoop/sync-sleep.ts | 40 +++++------ src/lib/whoop/sync.ts | 34 ++++----- 4 files changed, 101 insertions(+), 58 deletions(-) diff --git a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts index d824579a0..2582fc6b2 100644 --- a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts +++ b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts @@ -9,24 +9,31 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { updateManyMock, upsertMeasurementsMock } = vi.hoisted(() => ({ - updateManyMock: vi.fn(async () => ({ count: 0 })), - upsertMeasurementsMock: vi.fn( - async ( - _userId: string, - readings: Array<{ type: string; measuredAt: Date }>, - opts?: { - onInserted?: ( - rows: Array<{ id: string; type: string; measuredAt: Date }>, - ) => void; +const { updateManyMock, upsertMeasurementsMock, morningRefreshMock } = + vi.hoisted(() => ({ + updateManyMock: vi.fn(async () => ({ count: 0 })), + upsertMeasurementsMock: vi.fn( + async ( + _userId: string, + readings: Array<{ type: string; measuredAt: Date }>, + opts?: { + onInserted?: ( + rows: Array<{ id: string; type: string; measuredAt: Date }>, + ) => void; + }, + ) => { + opts?.onInserted?.( + readings.map((row, index) => ({ ...row, id: `inserted-${index}` })), + ); + return 1; }, - ) => { - opts?.onInserted?.( - readings.map((row, index) => ({ ...row, id: `inserted-${index}` })), - ); - return 1; - }, - ), + ), + morningRefreshMock: vi.fn(async () => {}), + })); + +vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ + maybeEnqueueMorningRefresh: (...args: unknown[]) => + morningRefreshMock(...args), })); vi.mock("@/lib/db", () => ({ @@ -92,6 +99,7 @@ beforeEach(() => { updateManyMock.mockClear().mockResolvedValue({ count: 0 }); upsertMeasurementsMock.mockClear(); fetchSleepsMock.mockReset(); + morningRefreshMock.mockClear(); }); describe("syncUserSleep — record-scoped stale-segment sweep", () => { @@ -155,4 +163,31 @@ describe("syncUserSleep — record-scoped stale-segment sweep", () => { await expect(syncUserSleep("user-1")).resolves.toBe(1); expect(upsertMeasurementsMock).toHaveBeenCalledTimes(1); }); + + it("enqueues refreshes for a committed chunk before a later chunk fails", async () => { + fetchSleepsMock.mockResolvedValue([NIGHT]); + const measuredAt = new Date("2026-06-01T07:00:00.000Z"); + upsertMeasurementsMock.mockImplementationOnce( + async ( + _userId: string, + _readings: Array<{ type: string; measuredAt: Date }>, + opts?: { + onInserted?: ( + rows: Array<{ id: string; type: string; measuredAt: Date }>, + ) => void; + }, + ) => { + opts?.onInserted?.([ + { id: "committed-segment", type: "SLEEP_DURATION", measuredAt }, + ]); + throw new Error("injected later-chunk failure"); + }, + ); + + await expect(syncUserSleep("user-1")).rejects.toThrow( + "injected later-chunk failure", + ); + + expect(morningRefreshMock).toHaveBeenCalledWith("user-1", [measuredAt]); + }); }); diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index bbdf84e29..c9ab76082 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -17,6 +17,8 @@ const { isReauthRequired, emitArrivalMock, reconcileMock, + recomputeBucketsMock, + invalidateStatusMock, } = vi.hoisted(() => ({ prismaMock: { whoopConnection: { @@ -43,6 +45,12 @@ const { ), reconcileMock: vi.fn(), emitArrivalMock: vi.fn(), + recomputeBucketsMock: vi.fn<(...a: unknown[]) => Promise>( + async () => {}, + ), + invalidateStatusMock: vi.fn<(...a: unknown[]) => Promise>( + async () => {}, + ), })); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); @@ -82,11 +90,13 @@ vi.mock("@/lib/integrations/status", () => ({ vi.mock("@/lib/rollups/measurement-rollups", () => ({ collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), - recomputeBucketsForMeasurement: vi.fn(async () => {}), + recomputeBucketsForMeasurement: (...a: unknown[]) => + recomputeBucketsMock(...a), })); vi.mock("@/lib/insights/comprehensive-generate", () => ({ - invalidateStatusInsightsForTypes: vi.fn(async () => {}), + invalidateStatusInsightsForTypes: (...a: unknown[]) => + invalidateStatusMock(...a), })); vi.mock("@/lib/arrivals/measurement-emit", () => ({ @@ -592,6 +602,8 @@ describe("syncUserRecovery — durable cursor ordering", () => { expect(committedRows.size).toBeLessThan(105); expect(prismaMock.$transaction.mock.calls.length).toBeGreaterThan(1); expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); + expect(recomputeBucketsMock).toHaveBeenCalled(); + expect(invalidateStatusMock).toHaveBeenCalled(); failLaterChunk = false; await expect(syncUserRecovery("user1")).resolves.toBe(105); diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 18711f9c3..2a263dc79 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -40,6 +40,18 @@ import { } from "./sync"; import { prisma } from "@/lib/db"; +function enqueueMorningRefreshForInsertedSleepRows( + userId: string, + rows: ReadonlyArray<{ type: string; measuredAt: Date }>, +): void { + const measuredAts = rows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt); + if (measuredAts.length === 0) return; + + void maybeEnqueueMorningRefresh(userId, measuredAts).catch(() => {}); +} + export async function syncUserSleep( userId: string, opts: { fullSync?: boolean } = {}, @@ -94,23 +106,15 @@ export async function syncUserSleep( // the fresh set upserts (mirrors Google Health's replace-by-window order). await sweepStaleSleepSegments(userId, "WHOOP", sweeps); - const insertedSleepMeasuredAts: Date[] = []; + // Each committed chunk triggers independently; a later chunk failure cannot + // strand inserted sleep rows without their debounced morning refresh. const imported = await upsertWhoopMeasurements(userId, readings, { onInserted: (rows) => { - insertedSleepMeasuredAts.push( - ...rows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt), - ); + enqueueMorningRefreshForInsertedSleepRows(userId, rows); }, }); await markResourceSynced(userId, "sleep"); - // S4 — trigger the debounced morning refresh on a last-night segment landing. - void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch( - () => {}, - ); - return imported; } @@ -158,20 +162,12 @@ export async function syncWhoopSleepById( { prefix: `${record.id}:`, keepIds }, ]); - let insertedSleepMeasuredAts: Date[] = []; + // Dispatch the targeted refresh as soon as its insert commits, matching the + // chunked collection path above. const imported = await upsertWhoopMeasurements(userId, readings, { onInserted: (rows) => { - insertedSleepMeasuredAts = rows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt); + enqueueMorningRefreshForInsertedSleepRows(userId, rows); }, }); - - // S4 — a webhook-driven single-record refresh is the freshest possible - // last-night signal; kick the debounced morning refresh on its segments. - void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch( - () => {}, - ); - return imported; } diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 820fd15aa..cfe5403fa 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -345,7 +345,6 @@ export async function upsertWhoopMeasurements( if (readings.length === 0) return 0; let imported = 0; - const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; for ( let chunkStart = 0; @@ -385,6 +384,7 @@ export async function upsertWhoopMeasurements( { maxWait: 10_000, timeout: 60_000 }, ); + const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; const insertedRows: Array< InsertedMeasurementArrivalRow & { externalId: string | null } > = []; @@ -406,24 +406,24 @@ export async function upsertWhoopMeasurements( void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch( () => {}, ); - } - try { - const keys = collapseToTypeDayKeys(touched); - for (const k of keys) { - await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); - } - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - ).catch((err) => { + try { + const keys = collapseToTypeDayKeys(touched); + for (const k of keys) { + await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); + } + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `whoop: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, + `whoop: rollup recompute failed for ${userId}: ${err}`, ); - }); - } catch (err) { - getEvent()?.addWarning( - `whoop: rollup recompute failed for ${userId}: ${err}`, - ); + } } return imported; From 94842406889bb9505850691f7306d02dfcca4f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:24:49 +0200 Subject: [PATCH 19/35] fix(whoop): settle backfill side effects --- .../whoop/__tests__/sync-sleep-sweep.test.ts | 41 ++++++++++++++++++ src/lib/whoop/__tests__/sync.test.ts | 3 +- src/lib/whoop/sync-sleep.ts | 43 ++++++++++++------- src/lib/whoop/sync.ts | 29 +++++++++---- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts index 2582fc6b2..8e7cf6dab 100644 --- a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts +++ b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts @@ -164,6 +164,47 @@ describe("syncUserSleep — record-scoped stale-segment sweep", () => { expect(upsertMeasurementsMock).toHaveBeenCalledTimes(1); }); + it("enqueues one refresh after every committed chunk settles", async () => { + fetchSleepsMock.mockResolvedValue([NIGHT]); + const firstMeasuredAt = new Date("2026-06-01T06:30:00.000Z"); + const secondMeasuredAt = new Date("2026-06-01T07:00:00.000Z"); + upsertMeasurementsMock.mockImplementationOnce( + async ( + _userId: string, + _readings: Array<{ type: string; measuredAt: Date }>, + opts?: { + onInserted?: ( + rows: Array<{ id: string; type: string; measuredAt: Date }>, + ) => void; + }, + ) => { + opts?.onInserted?.([ + { + id: "first-committed-segment", + type: "SLEEP_DURATION", + measuredAt: firstMeasuredAt, + }, + ]); + opts?.onInserted?.([ + { + id: "second-committed-segment", + type: "SLEEP_DURATION", + measuredAt: secondMeasuredAt, + }, + ]); + return 2; + }, + ); + + await expect(syncUserSleep("user-1")).resolves.toBe(2); + + expect(morningRefreshMock).toHaveBeenCalledTimes(1); + expect(morningRefreshMock).toHaveBeenCalledWith("user-1", [ + firstMeasuredAt, + secondMeasuredAt, + ]); + }); + it("enqueues refreshes for a committed chunk before a later chunk fails", async () => { fetchSleepsMock.mockResolvedValue([NIGHT]); const measuredAt = new Date("2026-06-01T07:00:00.000Z"); diff --git a/src/lib/whoop/__tests__/sync.test.ts b/src/lib/whoop/__tests__/sync.test.ts index c9ab76082..8d09b9239 100644 --- a/src/lib/whoop/__tests__/sync.test.ts +++ b/src/lib/whoop/__tests__/sync.test.ts @@ -603,7 +603,7 @@ describe("syncUserRecovery — durable cursor ordering", () => { expect(prismaMock.$transaction.mock.calls.length).toBeGreaterThan(1); expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); expect(recomputeBucketsMock).toHaveBeenCalled(); - expect(invalidateStatusMock).toHaveBeenCalled(); + expect(invalidateStatusMock).toHaveBeenCalledTimes(1); failLaterChunk = false; await expect(syncUserRecovery("user1")).resolves.toBe(105); @@ -625,6 +625,7 @@ describe("syncUserRecovery — durable cursor ordering", () => { expect(committedInsertCount).toBe(105); expect([...committedRows.keys()]).toEqual(expectedExternalIds); expect(emittedExternalIds).toEqual(expectedExternalIds); + expect(invalidateStatusMock).toHaveBeenCalledTimes(2); expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/whoop/sync-sleep.ts b/src/lib/whoop/sync-sleep.ts index 2a263dc79..251ecc96a 100644 --- a/src/lib/whoop/sync-sleep.ts +++ b/src/lib/whoop/sync-sleep.ts @@ -40,15 +40,11 @@ import { } from "./sync"; import { prisma } from "@/lib/db"; -function enqueueMorningRefreshForInsertedSleepRows( +function enqueueMorningRefreshForMeasuredAts( userId: string, - rows: ReadonlyArray<{ type: string; measuredAt: Date }>, + measuredAts: Date[], ): void { - const measuredAts = rows - .filter((row) => row.type === "SLEEP_DURATION") - .map((row) => row.measuredAt); if (measuredAts.length === 0) return; - void maybeEnqueueMorningRefresh(userId, measuredAts).catch(() => {}); } @@ -106,13 +102,24 @@ export async function syncUserSleep( // the fresh set upserts (mirrors Google Health's replace-by-window order). await sweepStaleSleepSegments(userId, "WHOOP", sweeps); - // Each committed chunk triggers independently; a later chunk failure cannot - // strand inserted sleep rows without their debounced morning refresh. - const imported = await upsertWhoopMeasurements(userId, readings, { - onInserted: (rows) => { - enqueueMorningRefreshForInsertedSleepRows(userId, rows); - }, - }); + // Accumulate committed chunks, then trigger once after the write pass settles. + // The finally path also covers a later chunk failure without generating from + // an intermediate prefix while more chunks are still being written. + const insertedSleepMeasuredAts: Date[] = []; + let imported: number; + try { + imported = await upsertWhoopMeasurements(userId, readings, { + onInserted: (rows) => { + insertedSleepMeasuredAts.push( + ...rows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt), + ); + }, + }); + } finally { + enqueueMorningRefreshForMeasuredAts(userId, insertedSleepMeasuredAts); + } await markResourceSynced(userId, "sleep"); return imported; @@ -162,12 +169,16 @@ export async function syncWhoopSleepById( { prefix: `${record.id}:`, keepIds }, ]); - // Dispatch the targeted refresh as soon as its insert commits, matching the - // chunked collection path above. + const insertedSleepMeasuredAts: Date[] = []; const imported = await upsertWhoopMeasurements(userId, readings, { onInserted: (rows) => { - enqueueMorningRefreshForInsertedSleepRows(userId, rows); + insertedSleepMeasuredAts.push( + ...rows + .filter((row) => row.type === "SLEEP_DURATION") + .map((row) => row.measuredAt), + ); }, }); + enqueueMorningRefreshForMeasuredAts(userId, insertedSleepMeasuredAts); return imported; } diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index cfe5403fa..a058d6cb7 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -345,6 +345,18 @@ export async function upsertWhoopMeasurements( if (readings.length === 0) return 0; let imported = 0; + const touchedTypes = new Set(); + const invalidateTouchedTypes = (): void => { + if (touchedTypes.size === 0) return; + + void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch( + (err) => { + getEvent()?.addWarning( + `whoop: status-insight invalidate failed for ${userId}: ${err}`, + ); + }, + ); + }; for ( let chunkStart = 0; @@ -382,7 +394,10 @@ export async function upsertWhoopMeasurements( return outcomes; }, { maxWait: 10_000, timeout: 60_000 }, - ); + ).catch((err) => { + invalidateTouchedTypes(); + throw err; + }); const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; const insertedRows: Array< @@ -393,8 +408,10 @@ export async function upsertWhoopMeasurements( const verdict = verdicts[index - chunkStart]!; for (const dirty of verdict.dirtyIdentities ?? []) { touched.push(dirty); + touchedTypes.add(dirty.type); } const type = reading.type as MeasurementType; + touchedTypes.add(type); imported++; touched.push({ type, measuredAt: reading.measuredAt }); if (verdict.status === "inserted") { @@ -411,14 +428,6 @@ export async function upsertWhoopMeasurements( for (const k of keys) { await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); } - invalidateStatusInsightsForTypes( - userId, - keys.map((k) => k.type), - ).catch((err) => { - getEvent()?.addWarning( - `whoop: status-insight invalidate failed for ${userId}: ${err}`, - ); - }); } catch (err) { getEvent()?.addWarning( `whoop: rollup recompute failed for ${userId}: ${err}`, @@ -426,6 +435,8 @@ export async function upsertWhoopMeasurements( } } + invalidateTouchedTypes(); + return imported; } From 8b7cbddd72f55a7117dd1472beb5e9a6f00e2e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:25:13 +0200 Subject: [PATCH 20/35] fix(import): derive stable legacy mood timestamps --- src/app/api/import/__tests__/route.test.ts | 93 +++++++++----- src/app/api/import/route.ts | 138 +++++++++++++-------- 2 files changed, 150 insertions(+), 81 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 5281f0cd4..6f464c049 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -18,7 +18,6 @@ vi.mock("@/lib/db", () => ({ upsert: vi.fn(), }, moodEntry: { create: vi.fn(), upsert: vi.fn() }, - $transaction: vi.fn(), }, })); @@ -78,9 +77,6 @@ import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); - vi.mocked(prisma.$transaction).mockImplementation( - (operation) => operation(prisma as never) as never, - ); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); encryptNoteMock.mockImplementation((note: string | null) => @@ -781,7 +777,7 @@ describe("POST /api/import — write failure classification", () => { }); }); - it("rolls back a partial legacy mood import before a retry", async () => { + it("resumes a partially committed legacy mood import without duplicates", async () => { type LegacyMoodWrite = { date: string; moodLoggedAt: Date }; const committedKeys = new Set(); @@ -813,20 +809,6 @@ describe("POST /api/import — write failure classification", () => { vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) => writeMood(data as LegacyMoodWrite, committedKeys), ); - vi.mocked(prisma.$transaction).mockImplementation(async (operation) => { - const pendingKeys = new Set(committedKeys); - const result = await operation({ - moodEntry: { - create: vi.fn(({ data }: { data: LegacyMoodWrite }) => - writeMood(data, pendingKeys), - ), - upsert: prisma.moodEntry.upsert, - }, - } as never); - committedKeys.clear(); - for (const key of pendingKeys) committedKeys.add(key); - return result as never; - }); const payload = { moodEntries: [ @@ -839,16 +821,16 @@ describe("POST /api/import — write failure classification", () => { expect(failedResponse.status).toBe(503); expectRetryableFailure(await failedResponse.json(), { measurements: 0, - moodEntries: 0, + moodEntries: 1, skipped: 0, }); - expect(committedKeys.size).toBe(0); + expect(committedKeys.size).toBe(1); const retryResponse = await POST(request(payload)); expect(retryResponse.status).toBe(200); expect(await retryResponse.json()).toEqual({ - data: { measurements: 0, moodEntries: 2, skipped: 0 }, + data: { measurements: 0, moodEntries: 1, skipped: 1 }, error: null, }); expect(committedKeys.size).toBe(2); @@ -899,28 +881,81 @@ describe("POST /api/import — write failure classification", () => { } }); - it("uses the current instant for a date-only mood row", async () => { + it("anchors a date-only mood row to its recorded day", async () => { vi.useFakeTimers(); try { - const now = new Date("2026-05-10T08:00:00.000Z"); - vi.setSystemTime(now); + vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never); const response = await POST( request({ - moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }], + moodEntries: [{ date: "2026-05-01", mood: "GUT", score: 4 }], }), ); expect(response.status).toBe(200); - expect(prisma.moodEntry.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ moodLoggedAt: now }), - }); + const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] + .data.moodLoggedAt; + expect(moodLoggedAt.toISOString().slice(0, 10)).toBe("2026-05-01"); + expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( + Date.parse("2026-05-01T00:00:00.000Z"), + ); + expect(moodLoggedAt.getTime()).toBeLessThan( + Date.parse("2026-05-01T00:05:00.000Z"), + ); } finally { vi.useRealTimers(); } }); + it("keeps a generated timestamp stable when upserting an external ID", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never); + const payload = { + moodEntries: [ + { + date: "2026-05-01", + mood: "GUT", + score: 4, + externalId: "legacy-row-1", + }, + ], + }; + + await POST(request(payload)); + vi.advanceTimersByTime(1000); + await POST(request(payload)); + + const first = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0]; + const second = vi.mocked(prisma.moodEntry.upsert).mock.calls[1][0]; + expect(first.create.moodLoggedAt).toEqual(second.create.moodLoggedAt); + expect(first.update.moodLoggedAt).toEqual(second.update.moodLoggedAt); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["1899-12-31", "2026-05-11"])( + "rejects an implausible date-only mood row dated %s", + async (date) => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); + + const response = await POST( + request({ moodEntries: [{ date, mood: "GUT", score: 4 }] }), + ); + + expect(response.status).toBe(422); + expect(prisma.moodEntry.create).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, + ); + it("returns a retryable failure for a generic mood write error", async () => { vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce( new Error("write failed"), diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 59301acd9..29915dd52 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -11,13 +11,18 @@ import { import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; +import { createHash } from "node:crypto"; import { validateMeasurementRange, measurementTypeEnum, glucoseContextEnum, } from "@/lib/validations/measurement"; -import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { + ENTRY_INSTANT_CLOCK_SKEW_MS, + isPlausibleEntryInstant, + validateEntryInstant, +} from "@/lib/validations/entry-instant"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; import { @@ -60,22 +65,71 @@ const measurementSchema = z "Wert ausserhalb des plausiblen Bereichs", ); -const moodEntrySchema = z.object({ - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]), - score: z.number().int().min(1).max(5), - tags: z.string().optional(), - // v1.17.1 — same entry-instant bound as measurements; an imported mood - // log cannot be forward-dated. Optional + transforms to a `Date` when set. - loggedAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - ).optional(), - // v1.12.1 — optional source-stable id (e.g. a Daylio row id). When - // present, the import upserts on `(userId, source, externalId)` so a - // re-import of the same export is idempotent rather than minting a - // duplicate; absent → the legacy first-write-wins create path. - externalId: z.string().min(1).max(120).optional(), -}); +const moodEntrySchema = z + .object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]), + score: z.number().int().min(1).max(5), + tags: z.string().optional(), + // v1.17.1 — same entry-instant bound as measurements; an imported mood + // log cannot be forward-dated. Optional + transforms to a `Date` when set. + loggedAt: validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ).optional(), + // v1.12.1 — optional source-stable id (e.g. a Daylio row id). When + // present, the import upserts on `(userId, source, externalId)` so a + // re-import of the same export is idempotent rather than minting a + // duplicate; absent → the legacy first-write-wins create path. + externalId: z.string().min(1).max(120).optional(), + }) + .superRefine((entry, ctx) => { + if (entry.loggedAt) return; + const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`); + const isCalendarDate = + !Number.isNaN(dateStart) && + new Date(dateStart).toISOString().slice(0, 10) === entry.date; + if ( + !isCalendarDate || + !isPlausibleEntryInstant(new Date(dateStart)) + ) { + ctx.addIssue({ + code: "custom", + path: ["date"], + message: "Mood date must be between 1900 and today", + }); + } + }); + +type MoodEntryInput = z.infer; + +function fallbackMoodIdentity(entry: MoodEntryInput): string { + return JSON.stringify([ + entry.date, + entry.mood, + entry.score, + entry.tags ?? null, + entry.externalId ?? null, + ]); +} + +function stableFallbackMoodInstant( + entry: MoodEntryInput, + occurrence: number, + reservedInstants: Set, +): Date { + const digest = createHash("sha256") + .update(`${fallbackMoodIdentity(entry)}:${occurrence}`) + .digest(); + let offset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`); + let instant = dateStart + offset; + while (reservedInstants.has(instant)) { + offset = (offset + 1) % ENTRY_INSTANT_CLOCK_SKEW_MS; + instant = dateStart + offset; + } + reservedInstants.add(instant); + return new Date(instant); +} const importSchema = z.object({ measurements: z.array(measurementSchema).max(10000).optional(), @@ -238,18 +292,24 @@ export const POST = apiHandler(async (request: NextRequest) => { .map((row) => row.measuredAt), ).catch(() => {}); - // Import mood entries with stable identities first. Rows without either - // identity are committed together so a retry cannot replay a partial batch. + // Import mood entries if (!writeFailed && data.moodEntries?.length) { - const retryUnsafeEntries = []; + const fallbackOccurrences = new Map(); + const reservedFallbackInstants = new Set(); for (const e of data.moodEntries) { - if (!e.loggedAt && !e.externalId) { - retryUnsafeEntries.push(e); - continue; - } - try { - const loggedAt = e.loggedAt ?? new Date(); + let loggedAt = e.loggedAt; + if (!loggedAt) { + const identity = fallbackMoodIdentity(e); + const occurrence = fallbackOccurrences.get(identity) ?? 0; + fallbackOccurrences.set(identity, occurrence + 1); + loggedAt = stableFallbackMoodInstant( + e, + occurrence, + reservedFallbackInstants, + ); + } + if (e.externalId) { // v1.12.1 — idempotent re-import keyed on the source-stable id. // A second import of the same export updates the row in place @@ -304,32 +364,6 @@ export const POST = apiHandler(async (request: NextRequest) => { break; } } - - if (!writeFailed && retryUnsafeEntries.length > 0) { - const loggedAtBase = Date.now(); - let importedMoodEntries = 0; - try { - await prisma.$transaction(async (tx) => { - for (const [index, e] of retryUnsafeEntries.entries()) { - await tx.moodEntry.create({ - data: { - userId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - source: "IMPORT", - moodLoggedAt: new Date(loggedAtBase + index), - }, - }); - importedMoodEntries++; - } - }); - stats.moodEntries += importedMoodEntries; - } catch { - writeFailed = true; - } - } } // v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than From cd6b5f0cc6cbee7785fd012a9f6c102f11c716cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:25:37 +0200 Subject: [PATCH 21/35] fix(backups): preserve canonical restore identity --- .../api/admin/backups/[id]/restore/route.ts | 304 +++++++++--- src/lib/cycle/__tests__/backup.test.ts | 155 ++++++ src/lib/cycle/backup.ts | 84 +++- .../__tests__/full-backup-payload.test.ts | 201 +++++++- .../export/__tests__/records-backup.test.ts | 160 +++++++ src/lib/export/full-backup-payload.ts | 246 +++++++--- src/lib/export/records-backup.ts | 112 ++++- src/lib/validations/__tests__/backup.test.ts | 53 ++- src/lib/validations/backup.ts | 181 ++++++- .../admin-backups-canonical-roundtrip.test.ts | 441 ++++++++++++++++-- 10 files changed, 1722 insertions(+), 215 deletions(-) create mode 100644 src/lib/cycle/__tests__/backup.test.ts diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index d92116869..7dd1c6afd 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -329,43 +329,141 @@ const handler = apiHandler( where: { userId: ownerId }, }); - // Stable ids are authoritative for canonical snapshots. Legacy payloads - // without ids fall back to the historic natural measurement key. - for (const measurement of payload.measurements) { - const measuredAt = new Date(measurement.measuredAt); - const source = measurement.source ?? "MANUAL"; - const restoredData = { - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - source, - measuredAt, - notes: null, - notesEncrypted: encryptNote(measurement.notes ?? null), - deletedAt: measurement.deletedAt - ? new Date(measurement.deletedAt) + if (payload.appSettings) { + const settings = payload.appSettings; + const settingsData = { + registrationEnabled: settings.registrationEnabled, + mfaRequired: settings.mfaRequired, + defaultLocale: settings.defaultLocale, + telegramGlobal: settings.telegramGlobal, + ntfyGlobal: settings.ntfyGlobal, + webPushGlobal: settings.webPushGlobal, + webPushVapidPublicKey: settings.webPushVapidPublicKey, + webPushVapidPrivateKeyEncrypted: + settings.webPushVapidPrivateKeyEncrypted, + webPushVapidSubject: settings.webPushVapidSubject, + apiGlobal: settings.apiGlobal, + moodLogGlobal: settings.moodLogGlobal, + umamiEnabled: settings.umamiEnabled, + umamiScriptUrl: settings.umamiScriptUrl, + umamiWebsiteId: settings.umamiWebsiteId, + glitchtipEnabled: settings.glitchtipEnabled, + glitchtipDsn: settings.glitchtipDsn, + glitchtipEnvironment: settings.glitchtipEnvironment, + reminderLateMinutes: settings.reminderLateMinutes, + reminderMissedMinutes: settings.reminderMissedMinutes, + adminAiKeyEncrypted: settings.adminAiKeyEncrypted, + adminAiModel: settings.adminAiModel, + adminAiBaseUrl: settings.adminAiBaseUrl, + adminCodexAccessTokenEncrypted: + settings.adminCodexAccessTokenEncrypted, + adminCodexRefreshTokenEncrypted: + settings.adminCodexRefreshTokenEncrypted, + adminCodexAccountIdEncrypted: + settings.adminCodexAccountIdEncrypted, + adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt + ? new Date(settings.adminCodexTokenExpiresAt) : null, + adminCodexConnectedAt: settings.adminCodexConnectedAt + ? new Date(settings.adminCodexConnectedAt) + : null, + adminCodexConnectionStatus: + settings.adminCodexConnectionStatus, + adminAiInsightsFeedbackSummary: + settings.adminAiInsightsFeedbackSummary as never, + defaultUserTimezone: settings.defaultUserTimezone, + assistantEnabled: settings.assistantEnabled, + assistantCoachEnabled: settings.assistantCoachEnabled, + assistantBriefingEnabled: settings.assistantBriefingEnabled, + assistantInsightStatusEnabled: + settings.assistantInsightStatusEnabled, + assistantCorrelationsEnabled: + settings.assistantCorrelationsEnabled, + assistantHealthScoreExplainerEnabled: + settings.assistantHealthScoreExplainerEnabled, + moduleAvailabilityJson: + settings.moduleAvailabilityJson as never, + documentMaxFileBytes: settings.documentMaxFileBytes, + documentQuotaBytes: BigInt(settings.documentQuotaBytes), }; + await tx.appSettings.upsert({ + where: { id: settings.id }, + create: { id: settings.id, ...settingsData }, + update: settingsData, + }); + } - if (measurement.id) { - await tx.measurement.upsert({ - where: { id: measurement.id, userId: ownerId }, - create: { - id: measurement.id, - userId: ownerId, - ...restoredData, - }, - update: restoredData, - }); - continue; - } + const toRestoredMeasurementData = ( + measurement: (typeof payload.measurements)[number], + ) => ({ + type: measurement.type, + value: measurement.value, + valueMin: measurement.valueMin ?? null, + valueMax: measurement.valueMax ?? null, + unit: measurement.unit, + source: measurement.source ?? "MANUAL", + measuredAt: new Date(measurement.measuredAt), + notes: measurement.notes ?? null, + notesEncrypted: + measurement.notesEncrypted !== undefined + ? measurement.notesEncrypted === null + ? null + : decodeEncryptedBytes(measurement.notesEncrypted) + : encryptNote(measurement.notes ?? null), + externalId: measurement.externalId ?? null, + externalSourceVersion: measurement.externalSourceVersion ?? null, + glucoseContext: (measurement.glucoseContext ?? null) as never, + sleepStage: (measurement.sleepStage ?? null) as never, + rhythmClassification: (measurement.rhythmClassification ?? + null) as never, + deviceType: measurement.deviceType ?? null, + syncVersion: measurement.syncVersion ?? 1, + deletedAt: measurement.deletedAt + ? new Date(measurement.deletedAt) + : null, + ...(measurement.createdAt + ? { createdAt: new Date(measurement.createdAt) } + : {}), + ...(measurement.updatedAt + ? { updatedAt: new Date(measurement.updatedAt) } + : {}), + }); + const stableRows = payload.measurements.flatMap((measurement) => + measurement.id + ? [ + { + id: measurement.id, + userId: ownerId, + ...toRestoredMeasurementData(measurement), + }, + ] + : [], + ); + const measurementBatchSize = 1_000; + for ( + let offset = 0; + offset < stableRows.length; + offset += measurementBatchSize + ) { + await tx.measurement.createMany({ + data: stableRows.slice(offset, offset + measurementBatchSize), + }); + } + + // v1 payloads did not require stable ids. Preserve their historical + // natural-key reconciliation without routing canonical v2 rows + // through it. + for (const measurement of payload.measurements) { + if (measurement.id) continue; + const restoredData = toRestoredMeasurementData(measurement); const existing = await tx.measurement.findFirst({ where: { userId: ownerId, type: measurement.type, - source, - measuredAt, + source: restoredData.source, + measuredAt: restoredData.measuredAt, + sleepStage: restoredData.sleepStage, }, select: { id: true }, }); @@ -381,50 +479,97 @@ const handler = apiHandler( } } - // Medications + schedules — restore one at a time so we can - // wire schedules to their parent's freshly-generated id, AND - // build a name → id map for the intake events that follow. const medByName = new Map(); + const restoredMedicationIds = new Set(); for (const m of payload.medications) { const created = await tx.medication.create({ data: { + ...(m.id ? { id: m.id } : {}), userId: ownerId, name: m.name, dose: m.dose, + treatmentClass: m.treatmentClass ?? "GENERIC", + dosesPerUnit: m.dosesPerUnit ?? null, + unitsPerDose: m.unitsPerDose ?? "1", active: m.active ?? true, + notificationsEnabled: m.notificationsEnabled ?? true, + pausedAt: m.pausedAt ? new Date(m.pausedAt) : null, + snoozedUntil: m.snoozedUntil + ? new Date(m.snoozedUntil) + : null, + startsOn: m.startsOn ? new Date(m.startsOn) : null, + endsOn: m.endsOn ? new Date(m.endsOn) : null, + oneShot: m.oneShot ?? false, + asNeeded: m.asNeeded ?? false, + deliveryForm: m.deliveryForm ?? "ORAL", + trackInjectionSites: m.trackInjectionSites ?? false, + allowedInjectionSites: m.allowedInjectionSites ?? [], + liveActivityEnabled: m.liveActivityEnabled ?? false, + criticalAlarmEnabled: m.criticalAlarmEnabled ?? false, + atcCode: m.atcCode ?? null, + rxNormCode: m.rxNormCode ?? null, + lowStockNotifiedAt: m.lowStockNotifiedAt + ? new Date(m.lowStockNotifiedAt) + : null, + lowStockNotifiedThresholdDays: + m.lowStockNotifiedThresholdDays ?? null, + reorderLeadDays: m.reorderLeadDays ?? null, + externalSource: m.externalSource ?? null, + externalId: m.externalId ?? null, + ...(m.createdAt ? { createdAt: new Date(m.createdAt) } : {}), + ...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}), schedules: { create: m.schedules.map((s) => ({ + ...(s.id ? { id: s.id } : {}), windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label ?? null, dose: s.dose ?? null, + daysOfWeek: s.daysOfWeek ?? null, + timesOfDay: s.timesOfDay ?? [], + reminderGraceMinutes: s.reminderGraceMinutes ?? null, + rrule: s.rrule ?? null, + rollingIntervalDays: s.rollingIntervalDays ?? null, + scheduleType: s.scheduleType ?? "SCHEDULED", + cyclicOnWeeks: s.cyclicOnWeeks ?? null, + cyclicOffWeeks: s.cyclicOffWeeks ?? null, + doseWindows: (s.doseWindows ?? null) as never, })), }, }, }); - // First write wins on duplicate names — keeps the round-trip - // deterministic against the restore-from-our-own-download case. + restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); } - // Intake events — only restore the ones whose `medication` name - // resolved against a row we just created. Orphans (medications - // that were deleted before the snapshot but still referenced by - // intake events) are dropped silently — the alternative would - // be an FK-violation crash, which is worse than a slightly - // smaller history. if (payload.intakeEvents.length > 0) { const rows = payload.intakeEvents .map((e) => { - const medId = medByName.get(e.medication); + const medId = + e.medicationId && restoredMedicationIds.has(e.medicationId) + ? e.medicationId + : medByName.get(e.medication); if (!medId) return null; return { + ...(e.id ? { id: e.id } : {}), userId: ownerId, medicationId: medId, scheduledFor: new Date(e.scheduledFor), takenAt: e.takenAt ? new Date(e.takenAt) : null, skipped: e.skipped ?? false, - source: (e.source ?? "WEB") as never, + autoMissed: e.autoMissed ?? false, + attributionSource: e.attributionSource ?? "AUTO", + source: e.source ?? "WEB", + idempotencyKey: e.idempotencyKey ?? null, + ...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}), + injectionSite: e.injectionSite ?? null, + doseTaken: e.doseTaken ?? null, + inventoryConsumption: (e.inventoryConsumption ?? + null) as never, + externalId: e.externalId ?? null, + ...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}), + syncVersion: e.syncVersion ?? 0, + deletedAt: e.deletedAt ? new Date(e.deletedAt) : null, }; }) .filter((r): r is NonNullable => r !== null); @@ -526,6 +671,7 @@ const handler = apiHandler( const cycleCleared = await restoreCycleData(tx, ownerId, payload); const biomarkerByName = new Map(); + const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { const created = await tx.biomarker.create({ data: { @@ -550,15 +696,23 @@ const handler = apiHandler( }, }); biomarkerByName.set(biomarker.name, created.id); + restoredBiomarkerIds.add(created.id); } for (const lab of payload.labResults) { - const biomarkerId = lab.biomarkerName - ? biomarkerByName.get(lab.biomarkerName) - : undefined; - if (lab.biomarkerName && !biomarkerId) { + const biomarkerId = + lab.biomarkerId !== undefined + ? lab.biomarkerId === null + ? null + : restoredBiomarkerIds.has(lab.biomarkerId) + ? lab.biomarkerId + : undefined + : lab.biomarkerName + ? biomarkerByName.get(lab.biomarkerName) + : null; + if (biomarkerId === undefined) { throw new Error( - `Unknown biomarker reference: ${lab.biomarkerName}`, + `Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`, ); } await tx.labResult.create({ @@ -576,7 +730,14 @@ const handler = apiHandler( takenAt: new Date(lab.takenAt), source: lab.source, noteEncrypted: - lab.note == null ? null : encryptNoteToBytes(lab.note), + lab.noteEncrypted !== undefined + ? lab.noteEncrypted === null + ? null + : decodeEncryptedBytes(lab.noteEncrypted) + : lab.note == null + ? null + : encryptNoteToBytes(lab.note), + deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null, ...(lab.createdAt ? { createdAt: new Date(lab.createdAt) } : {}), @@ -612,7 +773,16 @@ const handler = apiHandler( : null, parentConditionId: null, noteEncrypted: - episode.note == null ? null : encryptToBytes(episode.note), + episode.noteEncrypted !== undefined + ? episode.noteEncrypted === null + ? null + : decodeEncryptedBytes(episode.noteEncrypted) + : episode.note == null + ? null + : encryptToBytes(episode.note), + deletedAt: episode.deletedAt + ? new Date(episode.deletedAt) + : null, ...(episode.createdAt ? { createdAt: new Date(episode.createdAt) } : {}), @@ -667,7 +837,20 @@ const handler = apiHandler( functionalImpact: dayLog.functionalImpact ?? null, feverC: dayLog.feverC ?? null, noteEncrypted: - dayLog.note == null ? null : encryptToBytes(dayLog.note), + dayLog.noteEncrypted !== undefined + ? dayLog.noteEncrypted === null + ? null + : decodeEncryptedBytes(dayLog.noteEncrypted) + : dayLog.note == null + ? null + : encryptToBytes(dayLog.note), + tz: dayLog.tz ?? null, + deletedAt: dayLog.deletedAt + ? new Date(dayLog.deletedAt) + : null, + ...(dayLog.createdAt + ? { createdAt: new Date(dayLog.createdAt) } + : {}), ...(dayLog.updatedAt ? { updatedAt: new Date(dayLog.updatedAt) } : {}), @@ -694,11 +877,24 @@ const handler = apiHandler( status: allergy.status as never, onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null, reactionEncrypted: - allergy.reaction == null - ? null - : encryptToBytes(allergy.reaction), + allergy.reactionEncrypted !== undefined + ? allergy.reactionEncrypted === null + ? null + : decodeEncryptedBytes(allergy.reactionEncrypted) + : allergy.reaction == null + ? null + : encryptToBytes(allergy.reaction), notesEncrypted: - allergy.note == null ? null : encryptToBytes(allergy.note), + allergy.notesEncrypted !== undefined + ? allergy.notesEncrypted === null + ? null + : decodeEncryptedBytes(allergy.notesEncrypted) + : allergy.note == null + ? null + : encryptToBytes(allergy.note), + deletedAt: allergy.deletedAt + ? new Date(allergy.deletedAt) + : null, ...(allergy.createdAt ? { createdAt: new Date(allergy.createdAt) } : {}), diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts new file mode 100644 index 000000000..ee7998e65 --- /dev/null +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildCycleBackupSection } from "../backup"; + +const deletedAt = new Date("2026-07-19T12:00:00.000Z"); +const createdAt = new Date("2026-07-01T08:00:00.000Z"); +const updatedAt = new Date("2026-07-19T11:00:00.000Z"); + +describe("buildCycleBackupSection disaster-recovery mode", () => { + it("preserves stable ids, reconciliation fields, and tombstones", async () => { + const prisma = { + cycleProfile: { + findUnique: vi.fn().mockResolvedValue({ + id: "profile-dr", + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "MUCUS", + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + createdAt, + updatedAt, + }), + }, + menstrualCycle: { + findMany: vi.fn().mockResolvedValue([ + { + id: "cycle-dr", + startDate: "2026-07-01", + endDate: "2026-07-28", + periodEndDate: "2026-07-05", + lengthDays: 28, + ovulationDate: "2026-07-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 5, + deletedAt, + createdAt, + updatedAt, + }, + ]), + }, + cycleDayLog: { + findMany: vi.fn().mockResolvedValue([ + { + id: "cycle-day-dr", + date: "2026-07-02", + cycleId: "cycle-dr", + flow: "HEAVY", + intermenstrualBleeding: false, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: "sensitive-ciphertext", + notesEncrypted: "notes-ciphertext", + source: "APPLE_HEALTH", + externalId: "cycle-day-external", + tz: "Europe/London", + syncVersion: 9, + deletedAt, + createdAt, + updatedAt, + symptomLinks: [{ symptom: { key: "cramps" } }], + }, + ]), + }, + }; + + const section = await buildCycleBackupSection( + prisma as never, + "user-1", + { purpose: "disaster-recovery" }, + ); + + expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(prisma.cycleDayLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "user-1" } }), + ); + expect(section.cycleProfile).toEqual({ + id: "profile-dr", + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "MUCUS", + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + expect(section.cycles[0]).toEqual({ + id: "cycle-dr", + startDate: "2026-07-01", + endDate: "2026-07-28", + periodEndDate: "2026-07-05", + lengthDays: 28, + ovulationDate: "2026-07-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 5, + deletedAt: deletedAt.toISOString(), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + }); + expect(section.cycleDayLogs[0]).toEqual({ + id: "cycle-day-dr", + date: "2026-07-02", + cycleId: "cycle-dr", + flow: "HEAVY", + intermenstrualBleeding: false, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: "sensitive-ciphertext", + notesEncrypted: "notes-ciphertext", + source: "APPLE_HEALTH", + externalId: "cycle-day-external", + tz: "Europe/London", + syncVersion: 9, + deletedAt: deletedAt.toISOString(), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + symptomKeys: ["cramps"], + }); + }); +}); diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index bad576278..6affd2607 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -28,8 +28,13 @@ import type { import type { BackupPayload } from "@/lib/validations/backup"; /** The cycle slice of a backup payload. */ +export interface CycleBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + export interface CycleBackupSection { cycleProfile: { + id?: string; goal: string; cycleTrackingEnabled: boolean | null; typicalCycleLength: number | null; @@ -40,8 +45,11 @@ export interface CycleBackupSection { rawChartMode: boolean; discreetNotifications: boolean; sensitiveCategoryEncryption: boolean; + createdAt?: string; + updatedAt?: string; } | null; cycles: Array<{ + id?: string; startDate: string; endDate: string | null; periodEndDate: string | null; @@ -49,8 +57,15 @@ export interface CycleBackupSection { ovulationDate: string | null; ovulationConfirmed: boolean; tz: string | null; + isPredicted?: boolean; + syncVersion?: number; + deletedAt?: string | null; + createdAt?: string; + updatedAt?: string; }>; cycleDayLogs: Array<{ + id?: string; + cycleId?: string | null; date: string; flow: string | null; intermenstrualBleeding: boolean; @@ -71,6 +86,10 @@ export interface CycleBackupSection { source: string; externalId: string | null; tz: string | null; + syncVersion?: number; + deletedAt?: string | null; + createdAt?: string; + updatedAt?: string; symptomKeys: string[]; }>; } @@ -83,15 +102,19 @@ export interface CycleBackupSection { export async function buildCycleBackupSection( prisma: Pick, userId: string, + options: CycleBackupOptions = {}, ): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; const [profile, cycles, dayLogs] = await Promise.all([ prisma.cycleProfile.findUnique({ where: { userId } }), prisma.menstrualCycle.findMany({ - where: { userId, deletedAt: null, isPredicted: false }, + where: disasterRecovery + ? { userId } + : { userId, deletedAt: null, isPredicted: false }, orderBy: { startDate: "asc" }, }), prisma.cycleDayLog.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { date: "asc" }, include: { symptomLinks: { include: { symptom: { select: { key: true } } } }, @@ -102,6 +125,13 @@ export async function buildCycleBackupSection( return { cycleProfile: profile ? { + ...(disasterRecovery + ? { + id: profile.id, + createdAt: profile.createdAt.toISOString(), + updatedAt: profile.updatedAt.toISOString(), + } + : {}), goal: profile.goal, cycleTrackingEnabled: profile.cycleTrackingEnabled, typicalCycleLength: profile.typicalCycleLength, @@ -115,6 +145,16 @@ export async function buildCycleBackupSection( } : null, cycles: cycles.map((c) => ({ + ...(disasterRecovery + ? { + id: c.id, + isPredicted: c.isPredicted, + syncVersion: c.syncVersion, + deletedAt: c.deletedAt?.toISOString() ?? null, + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), + } + : {}), startDate: c.startDate, endDate: c.endDate, periodEndDate: c.periodEndDate, @@ -124,6 +164,16 @@ export async function buildCycleBackupSection( tz: c.tz, })), cycleDayLogs: dayLogs.map((d) => ({ + ...(disasterRecovery + ? { + id: d.id, + cycleId: d.cycleId, + syncVersion: d.syncVersion, + deletedAt: d.deletedAt?.toISOString() ?? null, + createdAt: d.createdAt.toISOString(), + updatedAt: d.updatedAt.toISOString(), + } + : {}), date: d.date, flow: d.flow, intermenstrualBleeding: d.intermenstrualBleeding, @@ -139,7 +189,6 @@ export async function buildCycleBackupSection( pregnancyTest: d.pregnancyTest, progesteroneTest: d.progesteroneTest, contraceptive: d.contraceptive, - // Both ciphertext envelopes carried verbatim — never decrypted here. sensitiveEncrypted: d.sensitiveEncrypted, notesEncrypted: d.notesEncrypted, source: d.source, @@ -258,6 +307,7 @@ export async function restoreCycleData( const p = payload.cycleProfile; await tx.cycleProfile.create({ data: { + ...(p.id ? { id: p.id } : {}), userId: ownerId, goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH", cycleTrackingEnabled: p.cycleTrackingEnabled ?? null, @@ -270,6 +320,8 @@ export async function restoreCycleData( rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true, + ...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}), + ...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}), }, }); } @@ -277,9 +329,11 @@ export async function restoreCycleData( // Recreate observed cycle spans; map startDate → id so day-logs can // re-attach to their owning span. const cycleIdByStart = new Map(); + const restoredCycleIds = new Set(); for (const c of payload.cycles) { const created = await tx.menstrualCycle.create({ data: { + ...(c.id ? { id: c.id } : {}), userId: ownerId, startDate: c.startDate, endDate: c.endDate ?? null, @@ -288,10 +342,15 @@ export async function restoreCycleData( ovulationDate: c.ovulationDate ?? null, ovulationConfirmed: c.ovulationConfirmed ?? false, tz: c.tz ?? null, - isPredicted: false, + isPredicted: c.isPredicted ?? false, + syncVersion: c.syncVersion ?? 0, + deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + ...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}), + ...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}), }, }); cycleIdByStart.set(c.startDate, created.id); + restoredCycleIds.add(created.id); } // Resolve the seeded symptom catalogue once for the link re-creation. @@ -328,11 +387,22 @@ export async function restoreCycleData( const symptomIds = (d.symptomKeys ?? []) .map((k) => symptomIdByKey.get(k)) .filter((v): v is string => v !== undefined); + const cycleId = + d.cycleId !== undefined + ? d.cycleId === null + ? null + : restoredCycleIds.has(d.cycleId) + ? d.cycleId + : (() => { + throw new Error(`Unknown cycle reference: ${d.cycleId}`); + })() + : owningCycleId(d.date); await tx.cycleDayLog.create({ data: { + ...(d.id ? { id: d.id } : {}), userId: ownerId, date: d.date, - cycleId: owningCycleId(d.date), + cycleId, flow: enumOrNull(d.flow, FLOW_LEVELS), intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, @@ -353,6 +423,10 @@ export async function restoreCycleData( source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL", externalId: d.externalId ?? null, tz: d.tz ?? null, + syncVersion: d.syncVersion ?? 0, + deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, + ...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}), + ...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}), ...(symptomIds.length > 0 ? { symptomLinks: { diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 3586b1a6d..30b553b4c 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { Buffer } from "node:buffer"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const mocks = vi.hoisted(() => ({ findMeasurementsPaged: vi.fn(), @@ -29,11 +34,132 @@ vi.mock("@/lib/export/records-backup", () => ({ import { buildFullBackupPayload } from "../full-backup-payload"; const deletedAt = new Date("2026-07-19T12:00:00.000Z"); +const measurementNote = encryptToBytes("canonical measurement note"); + +const appSettings = { + id: "singleton", + registrationEnabled: false, + mfaRequired: true, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + webPushVapidPublicKey: "public-key", + webPushVapidPrivateKeyEncrypted: "encrypted-private-key", + webPushVapidSubject: "mailto:ops@example.test", + apiGlobal: false, + moodLogGlobal: false, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.test/script.js", + umamiWebsiteId: "website-id", + glitchtipEnabled: true, + glitchtipDsn: "https://dsn.example.test/1", + glitchtipEnvironment: "recovery", + reminderLateMinutes: 45, + reminderMissedMinutes: 90, + adminAiKeyEncrypted: "encrypted-ai-key", + adminAiModel: "gpt-test", + adminAiBaseUrl: "https://ai.example.test/v1", + adminCodexAccessTokenEncrypted: "encrypted-access", + adminCodexRefreshTokenEncrypted: "encrypted-refresh", + adminCodexAccountIdEncrypted: "encrypted-account", + adminCodexTokenExpiresAt: new Date("2026-07-21T08:00:00.000Z"), + adminCodexConnectedAt: new Date("2026-07-20T08:00:00.000Z"), + adminCodexConnectionStatus: "connected", + adminAiInsightsFeedbackSummary: { helpful: 7, total: 9 }, + defaultUserTimezone: "Europe/London", + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + assistantHealthScoreExplainerEnabled: false, + moduleAvailabilityJson: { nutrients: false, insights: true }, + documentMaxFileBytes: 12_345_678, + documentQuotaBytes: 9_876_543_210n, +}; function makePrisma() { return { - medication: { findMany: vi.fn().mockResolvedValue([]) }, - medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + appSettings: { findUnique: vi.fn().mockResolvedValue(appSettings) }, + medication: { + findMany: vi.fn().mockResolvedValue([ + { + id: "medication-canonical", + name: "Example", + dose: "2 mg", + treatmentClass: "GENERIC", + dosesPerUnit: 4, + unitsPerDose: { toString: () => "0.5000" }, + active: false, + notificationsEnabled: false, + pausedAt: new Date("2026-07-18T10:00:00.000Z"), + snoozedUntil: new Date("2026-07-22T10:00:00.000Z"), + startsOn: new Date("2026-07-01T00:00:00.000Z"), + endsOn: new Date("2026-08-01T00:00:00.000Z"), + oneShot: false, + asNeeded: false, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"], + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BX10", + rxNormCode: "12345", + lowStockNotifiedAt: new Date("2026-07-17T10:00:00.000Z"), + lowStockNotifiedThresholdDays: 7, + reorderLeadDays: 3, + externalSource: "APPLE_HEALTH", + externalId: "med-external", + createdAt: new Date("2026-06-01T10:00:00.000Z"), + updatedAt: new Date("2026-07-19T10:00:00.000Z"), + schedules: [ + { + id: "schedule-canonical", + windowStart: "08:00", + windowEnd: "10:00", + label: "Morning", + dose: "1 mg", + daysOfWeek: "i2;1,3,5", + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: 90, + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + rollingIntervalDays: null, + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + doseWindows: [ + { timeOfDay: "08:00", start: "07:30", end: "09:00" }, + ], + }, + ], + }, + ]), + }, + medicationIntakeEvent: { + findMany: vi.fn().mockResolvedValue([ + { + id: "intake-canonical", + medicationId: "medication-canonical", + medication: { name: "Example" }, + scheduledFor: new Date("2026-07-19T08:00:00.000Z"), + takenAt: new Date("2026-07-19T08:05:00.000Z"), + skipped: false, + autoMissed: false, + attributionSource: "USER_PIN", + source: "APPLE_HEALTH", + idempotencyKey: "intake-idempotency", + createdAt: new Date("2026-07-19T08:05:00.000Z"), + injectionSite: "THIGH_LEFT", + doseTaken: "1 mg", + inventoryConsumption: [{ itemId: "pen-1", units: 0.5 }], + externalId: "intake-external", + updatedAt: new Date("2026-07-19T08:06:00.000Z"), + syncVersion: 4, + deletedAt, + }, + ]), + }, moodEntry: { findMany: vi.fn().mockResolvedValue([ { @@ -54,17 +180,29 @@ function makePrisma() { } function installSectionMocks() { + void measurementNote; mocks.findMeasurementsPaged.mockResolvedValue([ { id: "measurement-tombstone", - type: "WEIGHT", + type: "SLEEP_DURATION", value: 75, - unit: "kg", + valueMin: 60, + valueMax: 90, + unit: "minutes", measuredAt: new Date("2026-07-19T07:00:00.000Z"), - source: "MANUAL", + source: "IMPORT", notes: null, - notesEncrypted: null, + notesEncrypted: measurementNote, + externalId: "oura-stage-deep", + externalSourceVersion: "oura-v3", + glucoseContext: null, + sleepStage: "DEEP", + rhythmClassification: null, + deviceType: "ring", + syncVersion: 7, deletedAt, + createdAt: new Date("2026-07-19T07:01:00.000Z"), + updatedAt: new Date("2026-07-19T07:02:00.000Z"), }, ]); mocks.buildCycleBackupSection.mockResolvedValue({ @@ -118,14 +256,63 @@ describe("buildFullBackupPayload disaster-recovery mode", () => { { purpose: "disaster-recovery" }, ); expect(payload).toMatchObject({ - schemaVersion: "1", + schemaVersion: "2", exportedAt: "2026-07-20T00:00:00.000Z", + appSettings: { + ...appSettings, + adminCodexTokenExpiresAt: + appSettings.adminCodexTokenExpiresAt.toISOString(), + adminCodexConnectedAt: appSettings.adminCodexConnectedAt.toISOString(), + documentQuotaBytes: appSettings.documentQuotaBytes.toString(), + }, measurements: [ { id: "measurement-tombstone", + type: "SLEEP_DURATION", + value: 75, + valueMin: 60, + valueMax: 90, + unit: "minutes", + measuredAt: "2026-07-19T07:00:00.000Z", + source: "IMPORT", + notesEncrypted: Buffer.from(measurementNote).toString("base64"), + externalId: "oura-stage-deep", + externalSourceVersion: "oura-v3", + glucoseContext: null, + sleepStage: "DEEP", + rhythmClassification: null, + deviceType: "ring", + syncVersion: 7, deletedAt: deletedAt.toISOString(), + createdAt: "2026-07-19T07:01:00.000Z", + updatedAt: "2026-07-19T07:02:00.000Z", }, ], + medications: [ + expect.objectContaining({ + id: "medication-canonical", + asNeeded: false, + deliveryForm: "INJECTION", + unitsPerDose: "0.5000", + schedules: [ + expect.objectContaining({ + id: "schedule-canonical", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + }), + ], + }), + ], + intakeEvents: [ + expect.objectContaining({ + id: "intake-canonical", + medicationId: "medication-canonical", + syncVersion: 4, + deletedAt: deletedAt.toISOString(), + }), + ], moodEntries: [ { id: "mood-tombstone", diff --git a/src/lib/export/__tests__/records-backup.test.ts b/src/lib/export/__tests__/records-backup.test.ts index aa4d432ac..c94532062 100644 --- a/src/lib/export/__tests__/records-backup.test.ts +++ b/src/lib/export/__tests__/records-backup.test.ts @@ -234,6 +234,166 @@ describe("buildRecordsBackupSection", () => { expect(section.documents[0].summary).toBe("Routine panel."); }); + it("preserves every canonical lab, illness, day-log, and allergy field verbatim", async () => { + const prisma = buildMockPrisma(); + const deletedAt = new Date("2026-04-13T12:00:00.000Z"); + const createdAt = new Date("2026-04-10T01:00:00.000Z"); + const updatedAt = new Date("2026-04-12T23:00:00.000Z"); + const labNote = encryptNoteToBytes("Canonical lab note"); + const illnessNote = encryptToBytes("Canonical illness note"); + const dayNote = encryptToBytes("Canonical day note"); + const reaction = encryptToBytes("Canonical reaction"); + const allergyNote = encryptToBytes("Canonical allergy note"); + + prisma.labResult.findMany.mockResolvedValueOnce([ + { + id: "lab-canonical", + panel: "Lipid panel", + analyte: "LDL", + value: 118, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 130, + takenAt: new Date("2026-04-01T09:00:00.000Z"), + biomarkerId: "biomarker-canonical", + biomarker: { name: "LDL Cholesterol" }, + source: "IMPORT", + noteEncrypted: labNote, + createdAt, + updatedAt, + deletedAt, + }, + ]); + prisma.illnessEpisode.findMany.mockResolvedValueOnce([ + { + id: "episode-canonical", + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2026-04-10T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: null, + noteEncrypted: illnessNote, + createdAt, + updatedAt, + deletedAt, + dayLogs: [ + { + id: "day-canonical", + episodeId: "episode-canonical", + date: "2026-04-11", + functionalImpact: 2, + feverC: 38.2, + noteEncrypted: dayNote, + tz: "Europe/London", + createdAt, + updatedAt, + deletedAt, + symptomLinks: [{ severity: 3, symptom: { key: "headache" } }], + }, + ], + }, + ]); + prisma.allergy.findMany.mockResolvedValueOnce([ + { + id: "allergy-canonical", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "INACTIVE", + onsetAt: new Date("2020-01-01T00:00:00.000Z"), + reactionEncrypted: reaction, + notesEncrypted: allergyNote, + createdAt, + updatedAt, + deletedAt, + }, + ]); + + prisma.biomarker.findMany.mockResolvedValueOnce([]); + prisma.familyHistoryEntry.findMany.mockResolvedValueOnce([]); + prisma.workout.findMany.mockResolvedValueOnce([]); + prisma.inboundDocument.findMany.mockResolvedValueOnce([]); + const section = await buildRecordsBackupSection(prisma as never, USER_ID, { + purpose: "disaster-recovery", + }); + const episodeQuery = prisma.illnessEpisode.findMany.mock.calls.at(-1)?.[0]; + expect(episodeQuery).toEqual( + expect.objectContaining({ where: { userId: USER_ID } }), + ); + expect(episodeQuery.include.dayLogs).not.toHaveProperty("where"); + expect(prisma.allergy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: USER_ID } }), + ); + expect(section.labResults[0]).toEqual({ + id: "lab-canonical", + panel: "Lipid panel", + analyte: "LDL", + value: 118, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 130, + takenAt: "2026-04-01T09:00:00.000Z", + biomarkerId: "biomarker-canonical", + biomarkerName: "LDL Cholesterol", + source: "IMPORT", + note: null, + noteEncrypted: Buffer.from(labNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + }); + expect(section.illnessEpisodes[0]).toEqual({ + id: "episode-canonical", + label: "Migraine", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: "2026-04-10T00:00:00.000Z", + resolvedAt: null, + parentConditionId: null, + note: null, + noteEncrypted: Buffer.from(illnessNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + dayLogs: [ + { + id: "day-canonical", + episodeId: "episode-canonical", + date: "2026-04-11", + functionalImpact: 2, + feverC: 38.2, + note: null, + noteEncrypted: Buffer.from(dayNote).toString("base64"), + tz: "Europe/London", + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + symptoms: [{ key: "headache", severity: 3 }], + }, + ], + }); + expect(section.allergies[0]).toEqual({ + id: "allergy-canonical", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "INACTIVE", + onsetAt: "2020-01-01T00:00:00.000Z", + reaction: null, + note: null, + reactionEncrypted: Buffer.from(reaction).toString("base64"), + notesEncrypted: Buffer.from(allergyNote).toString("base64"), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + deletedAt: deletedAt.toISOString(), + }); + }); + it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => { const prisma = buildMockPrisma(); const section = await buildRecordsBackupSection(prisma as never, USER_ID); diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 211cdbf17..0e2303ed0 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -10,6 +10,7 @@ * The decrypted notes are surfaced here (the backup is the human-readable * artefact); an admin restore re-encrypts them on re-insert. */ +import { Buffer } from "node:buffer"; import type { PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { findMeasurementsPaged } from "@/lib/export/paged-measurements"; @@ -50,90 +51,174 @@ export async function buildFullBackupPayload( options: FullBackupOptions = {}, ): Promise { const disasterRecovery = options.purpose === "disaster-recovery"; - const [measurements, medications, intakeEvents, moodEntries, cycle, records] = - await Promise.all([ - // v1.28.25 — keyset-paginated read with a narrow select. The backup - // is inherently whole-history, so on a CGM / per-sample-HR account the - // previous single full-width `findMany` materialised a six-figure row - // set in one shot. The chunked read accumulates the same rows in the - // same `measuredAt desc` order; the payload FORMAT below is untouched - // (restore compatibility). The select is exactly the fields the - // serializer reads: type / value / unit / measuredAt / source + the - // note columns `readNote` decrypts + the `id` cursor key. - findMeasurementsPaged( - prisma, - disasterRecovery ? { userId } : { userId, deletedAt: null }, - { - id: true, - type: true, - value: true, - unit: true, - measuredAt: true, - source: true, - notes: true, - notesEncrypted: true, - deletedAt: true, - }, - ), - prisma.medication.findMany({ - where: { userId }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, + const [ + appSettings, + measurements, + medications, + intakeEvents, + moodEntries, + cycle, + records, + ] = await Promise.all([ + disasterRecovery + ? prisma.appSettings.findUnique({ where: { id: "singleton" } }) + : Promise.resolve(null), + findMeasurementsPaged( + prisma, + disasterRecovery ? { userId } : { userId, deletedAt: null }, + { + id: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + unit: true, + measuredAt: true, + source: true, + notes: true, + notesEncrypted: true, + externalId: true, + externalSourceVersion: true, + glucoseContext: true, + sleepStage: true, + rhythmClassification: true, + deviceType: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, + }, + ), + prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + include: { + tagLinks: { + where: { rating: { not: null } }, + select: { + rating: true, + moodTag: { select: { key: true } }, }, }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: disasterRecovery ? { userId } : { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - include: { - tagLinks: { - where: { rating: { not: null } }, - select: { - rating: true, - moodTag: { select: { key: true } }, - }, - }, - }, - }), - buildCycleBackupSection(prisma, userId), - buildRecordsBackupSection(prisma, userId, { - purpose: disasterRecovery ? "disaster-recovery" : "portable-export", - }), - ]); + }, + }), + buildCycleBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), + buildRecordsBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), + ]); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, exportedAt: (options.exportedAt ?? new Date()).toISOString(), userId, - measurements: measurements.map((m) => ({ - id: m.id, - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - notes: readNote(m.notesEncrypted, m.notes), - deletedAt: m.deletedAt?.toISOString() ?? null, - })), + appSettings: + disasterRecovery && appSettings + ? { + ...appSettings, + adminCodexTokenExpiresAt: + appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null, + adminCodexConnectedAt: + appSettings.adminCodexConnectedAt?.toISOString() ?? null, + documentQuotaBytes: appSettings.documentQuotaBytes.toString(), + } + : null, + measurements: measurements.map((m) => + disasterRecovery + ? { + id: m.id, + type: m.type, + value: m.value, + valueMin: m.valueMin, + valueMax: m.valueMax, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: m.notes, + notesEncrypted: m.notesEncrypted + ? Buffer.from(m.notesEncrypted).toString("base64") + : null, + externalId: m.externalId, + externalSourceVersion: m.externalSourceVersion, + glucoseContext: m.glucoseContext, + sleepStage: m.sleepStage, + rhythmClassification: m.rhythmClassification, + deviceType: m.deviceType, + syncVersion: m.syncVersion, + deletedAt: m.deletedAt?.toISOString() ?? null, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + } + : { + id: m.id, + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt.toISOString(), + source: m.source, + notes: readNote(m.notesEncrypted, m.notes), + deletedAt: m.deletedAt?.toISOString() ?? null, + }, + ), medications: medications.map((m) => ({ + ...(disasterRecovery + ? { + id: m.id, + treatmentClass: m.treatmentClass, + dosesPerUnit: m.dosesPerUnit, + unitsPerDose: m.unitsPerDose.toString(), + notificationsEnabled: m.notificationsEnabled, + pausedAt: m.pausedAt?.toISOString() ?? null, + snoozedUntil: m.snoozedUntil?.toISOString() ?? null, + startsOn: m.startsOn?.toISOString() ?? null, + endsOn: m.endsOn?.toISOString() ?? null, + oneShot: m.oneShot, + asNeeded: m.asNeeded, + deliveryForm: m.deliveryForm, + trackInjectionSites: m.trackInjectionSites, + allowedInjectionSites: m.allowedInjectionSites, + liveActivityEnabled: m.liveActivityEnabled, + criticalAlarmEnabled: m.criticalAlarmEnabled, + atcCode: m.atcCode, + rxNormCode: m.rxNormCode, + lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null, + lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays, + reorderLeadDays: m.reorderLeadDays, + externalSource: m.externalSource, + externalId: m.externalId, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + } + : {}), name: m.name, dose: m.dose, active: m.active, schedules: m.schedules.map((s) => ({ + ...(disasterRecovery + ? { + id: s.id, + daysOfWeek: s.daysOfWeek, + timesOfDay: s.timesOfDay, + reminderGraceMinutes: s.reminderGraceMinutes, + rrule: s.rrule, + rollingIntervalDays: s.rollingIntervalDays, + scheduleType: s.scheduleType, + cyclicOnWeeks: s.cyclicOnWeeks, + cyclicOffWeeks: s.cyclicOffWeeks, + doseWindows: s.doseWindows, + } + : {}), windowStart: s.windowStart, windowEnd: s.windowEnd, label: s.label, @@ -141,6 +226,23 @@ export async function buildFullBackupPayload( })), })), intakeEvents: intakeEvents.map((e) => ({ + ...(disasterRecovery + ? { + id: e.id, + medicationId: e.medicationId, + autoMissed: e.autoMissed, + attributionSource: e.attributionSource, + idempotencyKey: e.idempotencyKey, + createdAt: e.createdAt.toISOString(), + injectionSite: e.injectionSite, + doseTaken: e.doseTaken, + inventoryConsumption: e.inventoryConsumption, + externalId: e.externalId, + updatedAt: e.updatedAt.toISOString(), + syncVersion: e.syncVersion, + deletedAt: e.deletedAt?.toISOString() ?? null, + } + : {}), medication: e.medication.name, scheduledFor: e.scheduledFor.toISOString(), takenAt: e.takenAt?.toISOString() ?? null, diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts index a534de18a..3f360d476 100644 --- a/src/lib/export/records-backup.ts +++ b/src/lib/export/records-backup.ts @@ -57,10 +57,15 @@ export interface LabResultBackupEntry { takenAt: string; source: string; /** Human-readable cross-reference into `biomarkers` below, not an id. */ + /** Stable canonical FK; legacy portable payloads use biomarkerName. */ + biomarkerId?: string | null; biomarkerName: string | null; note: string | null; + /** Base64 ciphertext in canonical DR payloads. */ + noteEncrypted?: string | null; createdAt?: string; updatedAt?: string; + deletedAt?: string | null; } export interface BiomarkerBackupEntry { @@ -140,11 +145,30 @@ interface DisasterRecoveryDocumentRow { updatedAt: Date; } +type CanonicalIllnessDayLog = IllnessDayLogDTO & { + noteEncrypted?: string | null; + tz?: string | null; + createdAt?: string; + deletedAt?: string | null; +}; + +type CanonicalIllnessEpisode = IllnessEpisodeDTO & { + noteEncrypted?: string | null; + deletedAt?: string | null; + dayLogs: CanonicalIllnessDayLog[]; +}; + +type CanonicalAllergy = AllergyDTO & { + reactionEncrypted?: string | null; + notesEncrypted?: string | null; + deletedAt?: string | null; +}; + export interface RecordsBackupSection { labResults: LabResultBackupEntry[]; biomarkers: BiomarkerBackupEntry[]; - illnessEpisodes: Array; - allergies: AllergyDTO[]; + illnessEpisodes: CanonicalIllnessEpisode[]; + allergies: CanonicalAllergy[]; familyHistory: FamilyHistoryEntryDTO[]; workouts: WorkoutBackupEntry[]; documents: DocumentBackupEntry[]; @@ -255,7 +279,7 @@ export async function buildRecordsBackupSection( documentRows, ] = await Promise.all([ prisma.labResult.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { takenAt: "desc" }, include: { biomarker: { select: { name: true } } }, }), @@ -264,18 +288,18 @@ export async function buildRecordsBackupSection( orderBy: { name: "asc" }, }), prisma.illnessEpisode.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { onsetAt: "desc" }, include: { dayLogs: { - where: { deletedAt: null }, + ...(disasterRecovery ? {} : { where: { deletedAt: null } }), orderBy: { date: "asc" }, include: dayLogSymptomInclude, }, }, }), prisma.allergy.findMany({ - where: { userId, deletedAt: null }, + where: disasterRecovery ? { userId } : { userId, deletedAt: null }, orderBy: { createdAt: "desc" }, }), prisma.familyHistoryEntry.findMany({ @@ -293,10 +317,16 @@ export async function buildRecordsBackupSection( ...(disasterRecovery ? { id: r.id, + biomarkerId: r.biomarkerId, + noteEncrypted: r.noteEncrypted + ? Buffer.from(r.noteEncrypted).toString("base64") + : null, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), + deletedAt: r.deletedAt?.toISOString() ?? null, + note: null, } - : {}), + : { note: decryptNoteSoft(r.noteEncrypted) }), panel: r.panel, analyte: r.analyte, value: r.value, @@ -307,7 +337,6 @@ export async function buildRecordsBackupSection( takenAt: r.takenAt.toISOString(), source: r.source, biomarkerName: r.biomarker?.name ?? null, - note: decryptNoteSoft(r.noteEncrypted), })); const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({ @@ -327,12 +356,69 @@ export async function buildRecordsBackupSection( context: decryptContextSoft(b.contextEncrypted), })); - const illnessEpisodes = episodeRows.map((row) => ({ - ...toIllnessEpisodeDTO(row), - dayLogs: row.dayLogs.map(toIllnessDayLogDTO), - })); + const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery + ? episodeRows.map((row) => ({ + id: row.id, + label: row.label, + type: row.type, + lifecycle: row.lifecycle, + onsetAt: row.onsetAt.toISOString(), + resolvedAt: row.resolvedAt?.toISOString() ?? null, + parentConditionId: row.parentConditionId, + note: null, + noteEncrypted: row.noteEncrypted + ? Buffer.from(row.noteEncrypted).toString("base64") + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + dayLogs: row.dayLogs.map((dayLog) => ({ + id: dayLog.id, + episodeId: dayLog.episodeId, + date: dayLog.date, + functionalImpact: dayLog.functionalImpact, + feverC: dayLog.feverC, + note: null, + noteEncrypted: dayLog.noteEncrypted + ? Buffer.from(dayLog.noteEncrypted).toString("base64") + : null, + tz: dayLog.tz, + createdAt: dayLog.createdAt.toISOString(), + updatedAt: dayLog.updatedAt.toISOString(), + deletedAt: dayLog.deletedAt?.toISOString() ?? null, + symptoms: dayLog.symptomLinks.map((link) => ({ + key: link.symptom.key, + severity: link.severity, + })), + })), + })) + : episodeRows.map((row) => ({ + ...toIllnessEpisodeDTO(row), + dayLogs: row.dayLogs.map(toIllnessDayLogDTO), + })); - const allergies = allergyRows.map(toAllergyDTO); + const allergies: CanonicalAllergy[] = disasterRecovery + ? allergyRows.map((row) => ({ + id: row.id, + substance: row.substance, + category: row.category, + type: row.type, + severity: row.severity, + status: row.status, + onsetAt: row.onsetAt?.toISOString() ?? null, + reaction: null, + note: null, + reactionEncrypted: row.reactionEncrypted + ? Buffer.from(row.reactionEncrypted).toString("base64") + : null, + notesEncrypted: row.notesEncrypted + ? Buffer.from(row.notesEncrypted).toString("base64") + : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + })) + : allergyRows.map(toAllergyDTO); const familyHistory = familyRows.map(toFamilyHistoryEntryDTO); const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({ diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index 595f3e375..aba2c9535 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -18,7 +18,12 @@ * of bubbling out of `prisma.createMany` with a useless message. */ import { describe, expect, it } from "vitest"; -import { backupPayloadSchema, summarizeBackup } from "../backup"; +import { + backupPayloadSchema, + isCompatibleSchemaVersion, + parseBackupPayload, + summarizeBackup, +} from "../backup"; const baseEntry = { date: "2026-05-08", @@ -384,6 +389,52 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => { }); }); +describe("backupPayloadSchema — canonical v2 identity compatibility", () => { + const base = { + exportedAt: "2026-07-20T00:00:00.000Z", + userId: "u1", + }; + + it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => { + const payload = parseBackupPayload({ + ...base, + measurements: [ + { + type: "WEIGHT", + value: 75, + unit: "kg", + measuredAt: "2026-07-19T07:00:00.000Z", + }, + ], + }); + + expect(payload.schemaVersion).toBe("1"); + expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true); + }); + + it("requires stable measurement ids in canonical v2 payloads", () => { + const result = backupPayloadSchema.safeParse({ + ...base, + schemaVersion: "2", + measurements: [ + { + type: "SLEEP_DURATION", + value: 120, + unit: "minutes", + measuredAt: "2026-07-19T22:00:00.000Z", + source: "IMPORT", + sleepStage: "DEEP", + }, + ], + }); + + expect(result.success).toBe(false); + expect(isCompatibleSchemaVersion("1")).toBe(true); + expect(isCompatibleSchemaVersion("2")).toBe(true); + expect(isCompatibleSchemaVersion("3")).toBe(false); + }); +}); + describe("backupPayloadSchema — database enum boundaries", () => { const enumPayload = { schemaVersion: "1", diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ca68e8f64..48acd29cc 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -34,19 +34,28 @@ import { DocumentSummaryState, FamilyRelationship, FlowLevel, + GlucoseContext, HomeTestResult, IllnessLifecycle, IllnessType, InboundDocumentKind, InboundDocumentStatus, + InjectionSite, + IntakeAttributionSource, IntakeSource, MeasurementSource, MeasurementType, + MedicationCategory, + MedicationDeliveryForm, + MedicationScheduleType, OvulationTest, + RhythmClassification, SecondarySymptom, + SleepStage, } from "@/generated/prisma/enums"; -export const BACKUP_SCHEMA_VERSION = "1" as const; +export const BACKUP_SCHEMA_VERSION = "2" as const; +const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; const isoDateTime = z .string() @@ -55,44 +64,112 @@ const isoDateTime = z message: "Expected ISO-8601 date-time string", }); +const base64BytesSchema = z + .string() + .min(1) + .refine( + (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + { message: "Expected base64-encoded encrypted bytes" }, + ); + const measurementSchema = z .object({ id: z.string().min(1).optional(), type: z.enum(MeasurementType), value: z.number(), + valueMin: z.number().nullable().optional(), + valueMax: z.number().nullable().optional(), unit: z.string().min(1), measuredAt: isoDateTime, source: z.enum(MeasurementSource).optional(), notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), + glucoseContext: z.enum(GlucoseContext).nullable().optional(), + sleepStage: z.enum(SleepStage).nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + deviceType: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); const medicationScheduleSchema = z .object({ + id: z.string().min(1).optional(), windowStart: z.string().min(1), windowEnd: z.string().min(1), label: z.string().nullable().optional(), dose: z.string().nullable().optional(), + daysOfWeek: z.string().nullable().optional(), + timesOfDay: z.array(z.string()).optional(), + reminderGraceMinutes: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + rollingIntervalDays: z.number().int().nullable().optional(), + scheduleType: z.enum(MedicationScheduleType).optional(), + cyclicOnWeeks: z.number().int().nullable().optional(), + cyclicOffWeeks: z.number().int().nullable().optional(), + doseWindows: z.unknown().nullable().optional(), }) .passthrough(); const medicationSchema = z .object({ + id: z.string().min(1).optional(), name: z.string().min(1), dose: z.string(), + treatmentClass: z.enum(MedicationCategory).optional(), + dosesPerUnit: z.number().int().nullable().optional(), + unitsPerDose: z.string().min(1).optional(), active: z.boolean().optional(), + notificationsEnabled: z.boolean().optional(), + pausedAt: isoDateTime.nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + startsOn: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + oneShot: z.boolean().optional(), + asNeeded: z.boolean().optional(), + deliveryForm: z.enum(MedicationDeliveryForm).optional(), + trackInjectionSites: z.boolean().optional(), + allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), + liveActivityEnabled: z.boolean().optional(), + criticalAlarmEnabled: z.boolean().optional(), + atcCode: z.string().nullable().optional(), + rxNormCode: z.string().nullable().optional(), + lowStockNotifiedAt: isoDateTime.nullable().optional(), + lowStockNotifiedThresholdDays: z.number().int().nullable().optional(), + reorderLeadDays: z.number().int().nullable().optional(), + externalSource: z.enum(IntakeSource).nullable().optional(), + externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), schedules: z.array(medicationScheduleSchema).default([]), }) .passthrough(); const intakeEventSchema = z .object({ + id: z.string().min(1).optional(), + medicationId: z.string().min(1).optional(), medication: z.string().min(1), scheduledFor: isoDateTime, takenAt: isoDateTime.nullable().optional(), skipped: z.boolean().optional(), + autoMissed: z.boolean().optional(), + attributionSource: z.enum(IntakeAttributionSource).optional(), source: z.enum(IntakeSource).optional(), + idempotencyKey: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + injectionSite: z.enum(InjectionSite).nullable().optional(), + doseTaken: z.string().nullable().optional(), + inventoryConsumption: z.unknown().nullable().optional(), + externalId: z.string().nullable().optional(), + updatedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); @@ -156,13 +233,19 @@ const moodEntrySchema = z */ const cycleSpanSchema = z .object({ + id: z.string().min(1).optional(), startDate: z.string().min(1), endDate: z.string().nullable().optional(), periodEndDate: z.string().nullable().optional(), lengthDays: z.number().int().nullable().optional(), ovulationDate: z.string().nullable().optional(), ovulationConfirmed: z.boolean().optional(), + isPredicted: z.boolean().optional(), tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -175,7 +258,9 @@ const cycleSpanSchema = z */ const cycleDayLogSchema = z .object({ + id: z.string().min(1).optional(), date: z.string().min(1), + cycleId: z.string().nullable().optional(), flow: z.enum(FlowLevel).nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), @@ -195,6 +280,10 @@ const cycleDayLogSchema = z source: z.enum(MeasurementSource).optional(), externalId: z.string().nullable().optional(), tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), symptomKeys: z.array(z.string()).default([]), }) .passthrough(); @@ -202,6 +291,7 @@ const cycleDayLogSchema = z /** Cycle-tracking preferences (one row per user). */ const cycleProfileSchema = z .object({ + id: z.string().min(1).optional(), goal: z.enum(CycleTrackingGoal).optional(), cycleTrackingEnabled: z.boolean().nullable().optional(), typicalCycleLength: z.number().int().nullable().optional(), @@ -212,6 +302,53 @@ const cycleProfileSchema = z rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), sensitiveCategoryEncryption: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + +const appSettingsBackupSchema = z + .object({ + id: z.string().min(1), + registrationEnabled: z.boolean(), + mfaRequired: z.boolean(), + defaultLocale: z.string(), + telegramGlobal: z.boolean(), + ntfyGlobal: z.boolean(), + webPushGlobal: z.boolean(), + webPushVapidPublicKey: z.string().nullable(), + webPushVapidPrivateKeyEncrypted: z.string().nullable(), + webPushVapidSubject: z.string().nullable(), + apiGlobal: z.boolean(), + moodLogGlobal: z.boolean(), + umamiEnabled: z.boolean(), + umamiScriptUrl: z.string().nullable(), + umamiWebsiteId: z.string().nullable(), + glitchtipEnabled: z.boolean(), + glitchtipDsn: z.string().nullable(), + glitchtipEnvironment: z.string().nullable(), + reminderLateMinutes: z.number().int(), + reminderMissedMinutes: z.number().int(), + adminAiKeyEncrypted: z.string().nullable(), + adminAiModel: z.string(), + adminAiBaseUrl: z.string(), + adminCodexAccessTokenEncrypted: z.string().nullable(), + adminCodexRefreshTokenEncrypted: z.string().nullable(), + adminCodexAccountIdEncrypted: z.string().nullable(), + adminCodexTokenExpiresAt: isoDateTime.nullable(), + adminCodexConnectedAt: isoDateTime.nullable(), + adminCodexConnectionStatus: z.string(), + adminAiInsightsFeedbackSummary: z.unknown().nullable(), + defaultUserTimezone: z.string().nullable(), + assistantEnabled: z.boolean(), + assistantCoachEnabled: z.boolean(), + assistantBriefingEnabled: z.boolean(), + assistantInsightStatusEnabled: z.boolean(), + assistantCorrelationsEnabled: z.boolean(), + assistantHealthScoreExplainerEnabled: z.boolean(), + moduleAvailabilityJson: z.unknown().nullable(), + documentMaxFileBytes: z.number().int(), + documentQuotaBytes: z.string().regex(/^\d+$/), }) .passthrough(); @@ -237,8 +374,11 @@ const labResultBackupSchema = z takenAt: isoDateTime, source: z.string().min(1), biomarkerName: z.string().nullable().optional(), + biomarkerId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -275,6 +415,10 @@ const illnessDayLogBackupSchema = z symptoms: z.array(illnessSymptomBackupSchema).default([]), note: z.string().nullable().optional(), updatedAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + tz: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), }) .passthrough(); @@ -291,6 +435,8 @@ const illnessEpisodeBackupSchema = z parentConditionId: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), dayLogs: z.array(illnessDayLogBackupSchema).default([]), }) @@ -308,6 +454,9 @@ const allergyBackupSchema = z reaction: z.string().nullable().optional(), note: z.string().nullable().optional(), createdAt: isoDateTime.optional(), + reactionEncrypted: base64BytesSchema.nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), updatedAt: isoDateTime.optional(), }) .passthrough(); @@ -346,14 +495,6 @@ const workoutBackupSchema = z }) .passthrough(); -const base64BytesSchema = z - .string() - .min(1) - .refine( - (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), - { message: "Expected base64-encoded encrypted bytes" }, - ); - const documentBackupSchema = z .object({ id: z.string().min(1), @@ -401,9 +542,10 @@ const backupManifestSchema = z */ export const backupPayloadSchema = z .object({ - schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION), + schemaVersion: z.string().min(1).default(LEGACY_BACKUP_SCHEMA_VERSION), exportedAt: isoDateTime, userId: z.string().min(1), + appSettings: appSettingsBackupSchema.nullable().default(null), measurements: z.array(measurementSchema).default([]), medications: z.array(medicationSchema).default([]), intakeEvents: z.array(intakeEventSchema).default([]), @@ -425,7 +567,19 @@ export const backupPayloadSchema = z documents: z.array(documentBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) - .passthrough(); + .passthrough() + .superRefine((payload, ctx) => { + if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return; + payload.measurements.forEach((measurement, index) => { + if (!measurement.id) { + ctx.addIssue({ + code: "custom", + path: ["measurements", index, "id"], + message: "Canonical v2 measurements require a stable id", + }); + } + }); + }); export type BackupPayload = z.infer; @@ -512,5 +666,8 @@ export function parseBackupPayload(input: string | unknown): BackupPayload { * new shape carried. */ export function isCompatibleSchemaVersion(version: string): boolean { - return version === BACKUP_SCHEMA_VERSION; + return ( + version === LEGACY_BACKUP_SCHEMA_VERSION || + version === BACKUP_SCHEMA_VERSION + ); } diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index e61f05ff9..6649ffa2f 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -1,6 +1,7 @@ import { Buffer } from "node:buffer"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Prisma } from "@/generated/prisma/client"; process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -117,6 +118,50 @@ describe("canonical disaster-recovery backup round-trip", () => { const prisma = getPrismaClient(); const admin = await seedAdminSession(); const ownerId = admin.id; + const settings = await prisma.appSettings.create({ + data: { + id: "singleton", + registrationEnabled: false, + mfaRequired: true, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + webPushVapidPublicKey: "public-dr", + webPushVapidPrivateKeyEncrypted: "private-dr", + webPushVapidSubject: "mailto:dr@example.test", + apiGlobal: false, + moodLogGlobal: false, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.test/script.js", + umamiWebsiteId: "website-dr", + glitchtipEnabled: true, + glitchtipDsn: "https://glitchtip.example.test/1", + glitchtipEnvironment: "dr", + reminderLateMinutes: 47, + reminderMissedMinutes: 93, + adminAiKeyEncrypted: "ai-key-dr", + adminAiModel: "model-dr", + adminAiBaseUrl: "https://ai.example.test/v1", + adminCodexAccessTokenEncrypted: "access-dr", + adminCodexRefreshTokenEncrypted: "refresh-dr", + adminCodexAccountIdEncrypted: "account-dr", + adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"), + adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"), + adminCodexConnectionStatus: "connected", + adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 }, + defaultUserTimezone: "Europe/London", + assistantEnabled: false, + assistantCoachEnabled: false, + assistantBriefingEnabled: false, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: false, + assistantHealthScoreExplainerEnabled: false, + moduleAvailabilityJson: { insights: true, nutrients: false }, + documentMaxFileBytes: 12_345_678, + documentQuotaBytes: 9_876_543_210n, + }, + }); const measurement = await prisma.measurement.create({ data: { @@ -193,6 +238,146 @@ describe("canonical disaster-recovery backup round-trip", () => { }, }); + const medication = await prisma.medication.create({ + data: { + id: "medication-dr", + userId: ownerId, + name: "Canonical medicine", + dose: "2 mg", + treatmentClass: "GENERIC", + dosesPerUnit: 4, + unitsPerDose: "0.5000", + active: false, + notificationsEnabled: false, + pausedAt: new Date("2026-06-25T08:00:00.000Z"), + snoozedUntil: new Date("2026-07-05T08:00:00.000Z"), + startsOn: new Date("2026-06-01T00:00:00.000Z"), + endsOn: new Date("2026-08-01T00:00:00.000Z"), + asNeeded: false, + deliveryForm: "INJECTION", + trackInjectionSites: true, + allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"], + liveActivityEnabled: true, + criticalAlarmEnabled: true, + atcCode: "A10BX10", + rxNormCode: "12345", + lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"), + lowStockNotifiedThresholdDays: 7, + reorderLeadDays: 3, + externalSource: "APPLE_HEALTH", + externalId: "medication-external-dr", + schedules: { + create: { + id: "schedule-dr", + windowStart: "08:00", + windowEnd: "10:00", + label: "Morning", + dose: "1 mg", + daysOfWeek: "i2;1,3,5", + timesOfDay: ["08:00", "20:00"], + reminderGraceMinutes: 90, + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + doseWindows: [ + { timeOfDay: "08:00", start: "07:30", end: "09:00" }, + ], + }, + }, + }, + include: { schedules: true }, + }); + const intake = await prisma.medicationIntakeEvent.create({ + data: { + id: "intake-dr", + userId: ownerId, + medicationId: medication.id, + scheduledFor: new Date("2026-06-29T08:00:00.000Z"), + takenAt: new Date("2026-06-29T08:05:00.000Z"), + autoMissed: false, + attributionSource: "USER_PIN", + source: "APPLE_HEALTH", + idempotencyKey: "intake-idempotency-dr", + injectionSite: "THIGH_LEFT", + doseTaken: "1 mg", + inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }], + externalId: "intake-external-dr", + syncVersion: 5, + deletedAt: tombstoneDeletedAt, + }, + }); + + const cycleProfile = await prisma.cycleProfile.create({ + data: { + id: "cycle-profile-dr", + userId: ownerId, + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + secondarySymptom: "CERVIX", + predictionEnabled: false, + rawChartMode: true, + discreetNotifications: true, + sensitiveCategoryEncryption: true, + }, + }); + const cycle = await prisma.menstrualCycle.create({ + data: { + id: "cycle-dr", + userId: ownerId, + startDate: "2026-06-01", + endDate: "2026-06-28", + periodEndDate: "2026-06-05", + lengthDays: 28, + ovulationDate: "2026-06-14", + ovulationConfirmed: true, + isPredicted: false, + tz: "Europe/London", + syncVersion: 6, + deletedAt: tombstoneDeletedAt, + }, + }); + const cycleCategory = + await prisma.cycleSymptomCategory.findFirstOrThrow(); + const cycleSymptom = await prisma.cycleSymptom.create({ + data: { + id: "cycle-symptom-dr", + userId: ownerId, + categoryId: cycleCategory.id, + key: "cycle_cramps_dr", + labelKey: "cycle.symptom.crampsDr", + }, + }); + const cycleDay = await prisma.cycleDayLog.create({ + data: { + id: "cycle-day-dr", + userId: ownerId, + cycleId: cycle.id, + date: "2026-06-02", + flow: "HEAVY", + intermenstrualBleeding: true, + basalBodyTempC: 36.7, + temperatureExcluded: true, + ovulationTest: "NEGATIVE", + cervicalMucus: "CREAMY", + cervixPosition: "LOW", + cervixFirmness: "FIRM", + cervixOpening: "CLOSED", + sexualActivity: false, + sensitiveEncrypted: "sensitive-cycle-dr", + notesEncrypted: "notes-cycle-dr", + source: "APPLE_HEALTH", + externalId: "cycle-day-external-dr", + tz: "Europe/London", + syncVersion: 7, + deletedAt: tombstoneDeletedAt, + symptomLinks: { create: { symptomId: cycleSymptom.id } }, + }, + }); + const biomarker = await prisma.biomarker.create({ data: { id: "biomarker-dr", @@ -219,6 +404,7 @@ describe("canonical disaster-recovery backup round-trip", () => { takenAt: new Date("2026-06-30T09:00:00.000Z"), source: "MANUAL", noteEncrypted: encryptToBytes("Fasted"), + deletedAt: tombstoneDeletedAt, }, }); @@ -232,6 +418,7 @@ describe("canonical disaster-recovery backup round-trip", () => { onsetAt: new Date("2026-06-20T00:00:00.000Z"), resolvedAt: new Date("2026-06-24T00:00:00.000Z"), noteEncrypted: encryptToBytes("Recovered fully"), + deletedAt: tombstoneDeletedAt, }, }); const symptom = await prisma.illnessSymptom.create({ @@ -250,6 +437,8 @@ describe("canonical disaster-recovery backup round-trip", () => { functionalImpact: 2, feverC: 38.1, noteEncrypted: encryptToBytes("Rest day"), + tz: "Europe/London", + deletedAt: tombstoneDeletedAt, symptomLinks: { create: { symptomId: symptom.id, severity: 2 }, }, @@ -268,6 +457,7 @@ describe("canonical disaster-recovery backup round-trip", () => { onsetAt: new Date("2020-01-01T00:00:00.000Z"), reactionEncrypted: encryptToBytes("Hives"), notesEncrypted: encryptToBytes("Avoid beta-lactams"), + deletedAt: tombstoneDeletedAt, }, }); const family = await prisma.familyHistoryEntry.create({ @@ -342,14 +532,21 @@ describe("canonical disaster-recovery backup round-trip", () => { decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")), ); expect(payload).toMatchObject({ - schemaVersion: "1", + schemaVersion: "2", userId: ownerId, + appSettings: { id: settings.id }, }); expect(payload.documents[0]).not.toHaveProperty("summary"); expect(JSON.stringify(payload.documents[0])).not.toContain( "private PDF bytes", ); + await prisma.appSettings.deleteMany(); + await prisma.medicationIntakeEvent.deleteMany({ where: { userId: ownerId } }); + await prisma.medication.deleteMany({ where: { userId: ownerId } }); + await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } }); + await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } }); + await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } }); await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } }); await prisma.workout.deleteMany({ where: { userId: ownerId } }); await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } }); @@ -373,6 +570,44 @@ describe("canonical disaster-recovery backup round-trip", () => { expect((await restore()).status).toBe(200); + expect( + await prisma.appSettings.findUniqueOrThrow({ + where: { id: settings.id }, + }), + ).toEqual(settings); + expect( + await prisma.medication.findUniqueOrThrow({ + where: { id: medication.id }, + include: { schedules: true }, + }), + ).toEqual(medication); + expect( + await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: intake.id }, + }), + ).toEqual(intake); + expect( + await prisma.cycleProfile.findUniqueOrThrow({ + where: { id: cycleProfile.id }, + }), + ).toEqual(cycleProfile); + expect( + await prisma.menstrualCycle.findUniqueOrThrow({ + where: { id: cycle.id }, + }), + ).toEqual(cycle); + expect( + await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: cycleDay.id }, + }), + ).toEqual(cycleDay); + expect( + await prisma.cycleSymptomLink.findMany({ + where: { dayLogId: cycleDay.id }, + select: { symptomId: true }, + }), + ).toEqual([{ symptomId: cycleSymptom.id }]); + expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe( 1, ); @@ -397,45 +632,19 @@ describe("canonical disaster-recovery backup round-trip", () => { await prisma.measurement.findUniqueOrThrow({ where: { id: measurement.id }, }), - ).toEqual( - expect.objectContaining({ - userId: ownerId, - type: measurement.type, - value: measurement.value, - unit: measurement.unit, - measuredAt: measurement.measuredAt, - source: measurement.source, - deletedAt: null, - }), - ); + ).toEqual(measurement); expect( await prisma.measurement.findUniqueOrThrow({ where: { id: deletedMeasurement.id }, }), - ).toEqual( - expect.objectContaining({ - userId: ownerId, - deletedAt: tombstoneDeletedAt, - }), - ); + ).toEqual(deletedMeasurement); const restoredLab = await prisma.labResult.findUniqueOrThrow({ where: { id: lab.id }, }); expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted"); expect(restoredLab.biomarkerId).toBe(biomarker.id); - expect(restoredLab).toEqual( - expect.objectContaining({ - panel: lab.panel, - analyte: lab.analyte, - value: lab.value, - unit: lab.unit, - referenceLow: lab.referenceLow, - referenceHigh: lab.referenceHigh, - takenAt: lab.takenAt, - source: lab.source, - }), - ); + expect(restoredLab).toEqual(lab); const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({ where: { id: biomarker.id }, }); @@ -456,26 +665,22 @@ describe("canonical disaster-recovery backup round-trip", () => { const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({ where: { id: illness.id }, }); - expect(restoredIllness).toEqual( - expect.objectContaining({ - label: illness.label, - type: illness.type, - lifecycle: illness.lifecycle, - onsetAt: illness.onsetAt, - resolvedAt: illness.resolvedAt, - }), - ); + expect(restoredIllness).toEqual(illness); expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe( "Recovered fully", ); const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({ where: { id: illnessDay.id }, - include: { symptomLinks: true }, }); + expect(restoredIllnessDay).toEqual(illnessDay); expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe( "Rest day", ); - expect(restoredIllnessDay.symptomLinks).toEqual([ + expect( + await prisma.illnessSymptomLink.findMany({ + where: { dayLogId: illnessDay.id }, + }), + ).toEqual([ expect.objectContaining({ symptomId: symptom.id, severity: 2 }), ]); @@ -483,16 +688,7 @@ describe("canonical disaster-recovery backup round-trip", () => { where: { id: allergy.id }, }); expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives"); - expect(restoredAllergy).toEqual( - expect.objectContaining({ - substance: allergy.substance, - category: allergy.category, - type: allergy.type, - severity: allergy.severity, - status: allergy.status, - onsetAt: allergy.onsetAt, - }), - ); + expect(restoredAllergy).toEqual(allergy); const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({ where: { id: family.id }, }); @@ -612,6 +808,149 @@ describe("canonical disaster-recovery backup round-trip", () => { }), ).toEqual([{ id: illness.id }]); }); + + it("rejects a stable measurement id owned by another account without clearing either owner", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const other = await prisma.user.create({ + data: { username: "stable-id-owner-other" }, + }); + const original = await prisma.measurement.create({ + data: { + id: "owner-original-measurement", + userId: admin.id, + type: "PULSE", + value: 70, + unit: "bpm", + measuredAt: new Date("2026-07-08T10:00:00.000Z"), + }, + }); + const conflicting = await prisma.measurement.create({ + data: { + id: "cross-owner-stable-id", + userId: other.id, + type: "PULSE", + value: 65, + unit: "bpm", + measuredAt: new Date("2026-07-08T11:00:00.000Z"), + }, + }); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-12T00:00:00.000Z", + userId: admin.id, + measurements: [ + { + id: conflicting.id, + type: "PULSE", + value: 80, + unit: "bpm", + measuredAt: "2026-07-08T12:00:00.000Z", + source: "IMPORT", + }, + ], + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "STABLE_ID_OWNER_CONFLICT", + data: encrypt(JSON.stringify(payload)), + }, + }); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(500); + expect( + await prisma.measurement.findUnique({ where: { id: original.id } }), + ).toEqual(original); + expect( + await prisma.measurement.findUnique({ where: { id: conflicting.id } }), + ).toEqual(conflicting); + }); + + it("restores many stable-id measurements with a bounded number of writes", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + const measurements = Array.from({ length: 250 }, (_, index) => ({ + id: `stable-measurement-${index}`, + type: "PULSE" as const, + value: 60 + (index % 20), + unit: "bpm", + measuredAt: new Date( + Date.UTC(2026, 6, 10, 0, 0, 0, index), + ).toISOString(), + source: "IMPORT" as const, + externalId: `external-${index}`, + syncVersion: 3, + deletedAt: index % 10 === 0 ? "2026-07-11T00:00:00.000Z" : null, + })); + const payload = backupPayloadSchema.parse({ + schemaVersion: "2", + exportedAt: "2026-07-12T00:00:00.000Z", + userId: admin.id, + measurements, + }); + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "BOUNDED_STABLE_ID_RESTORE", + data: encrypt(JSON.stringify(payload)), + }, + }); + let stableWriteQueries = 0; + const originalTransaction = prisma.$transaction.bind(prisma) as unknown as ( + callback: (tx: Prisma.TransactionClient) => Promise, + options?: { maxWait?: number; timeout?: number }, + ) => Promise; + const transactionSpy = vi.spyOn(prisma, "$transaction"); + transactionSpy.mockImplementation( + (async ( + callback: (tx: Prisma.TransactionClient) => Promise, + options?: { maxWait?: number; timeout?: number }, + ) => + originalTransaction(async (tx) => { + const originalUpsert = tx.measurement.upsert.bind(tx.measurement); + const originalCreateMany = tx.measurement.createMany.bind( + tx.measurement, + ); + vi.spyOn(tx.measurement, "upsert").mockImplementation(async (args) => { + stableWriteQueries += 1; + return originalUpsert(args); + }); + vi.spyOn(tx.measurement, "createMany").mockImplementation( + async (args) => { + stableWriteQueries += 1; + return originalCreateMany(args); + }, + ); + return callback(tx); + }, options)) as never, + ); + + const response = await POST( + makeRequest(backup.id) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + expect(response.status).toBe(200); + transactionSpy.mockRestore(); + expect(stableWriteQueries).toBeLessThanOrEqual(2); + expect( + await prisma.measurement.findMany({ + where: { userId: admin.id }, + orderBy: { id: "asc" }, + select: { id: true }, + }), + ).toEqual( + measurements + .map(({ id }) => ({ id })) + .sort((left, right) => left.id.localeCompare(right.id)), + ); + }); it.each([ { label: "invalid enum", From 7eb3b91925d63867800f4f1fb3f6fb33232ed80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:35:37 +0200 Subject: [PATCH 22/35] fix(import): resolve legacy mood timestamp collisions --- src/app/api/import/__tests__/route.test.ts | 105 ++++++++++- src/app/api/import/route.ts | 202 +++++++++++++-------- 2 files changed, 225 insertions(+), 82 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 6f464c049..3fbc0ce56 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), upsert: vi.fn(), }, - moodEntry: { create: vi.fn(), upsert: vi.fn() }, + moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() }, }, })); @@ -77,6 +77,7 @@ import { auditLog } from "@/lib/auth/audit"; beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null); vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined); vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined); encryptNoteMock.mockImplementation((note: string | null) => @@ -809,6 +810,20 @@ describe("POST /api/import — write failure classification", () => { vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) => writeMood(data as LegacyMoodWrite, committedKeys), ); + vi.mocked(prisma.moodEntry.findUnique).mockImplementation( + async ({ where }) => { + const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; + if (!committedKeys.has(key)) return null; + const isFirstDay = where.userId_date_moodLoggedAt.date === "2026-05-01"; + return { + source: "IMPORT", + mood: isFirstDay ? "GUT" : "OKAY", + score: isFirstDay ? 4 : 3, + tags: null, + externalId: null, + } as never; + }, + ); const payload = { moodEntries: [ @@ -896,18 +911,100 @@ describe("POST /api/import — write failure classification", () => { expect(response.status).toBe(200); const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] .data.moodLoggedAt; - expect(moodLoggedAt.toISOString().slice(0, 10)).toBe("2026-05-01"); expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( - Date.parse("2026-05-01T00:00:00.000Z"), + Date.parse("2026-04-30T22:00:00.000Z"), ); expect(moodLoggedAt.getTime()).toBeLessThan( - Date.parse("2026-05-01T00:05:00.000Z"), + Date.parse("2026-04-30T22:05:00.000Z"), ); } finally { vi.useRealTimers(); } }); + it("accepts the current Berlin day shortly after local midnight", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z")); + vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never); + + const response = await POST( + request({ + moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }], + }), + ); + + expect(response.status).toBe(200); + const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] + .data.moodLoggedAt; + expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( + Date.parse("2026-05-10T22:00:00.000Z"), + ); + expect(moodLoggedAt.getTime()).toBeLessThan( + Date.parse("2026-05-10T22:05:00.000Z"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("probes past a persisted fallback timestamp collision", async () => { + type StoredMood = { + date: string; + mood: string; + score: number; + tags: string | null; + externalId?: string | null; + moodLoggedAt: Date; + }; + + const persisted = new Map(); + vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { + const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; + if (persisted.has(key)) { + throw new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ); + } + persisted.set(key, data as StoredMood); + return {} as never; + }); + vi.mocked(prisma.moodEntry.findUnique).mockImplementation(async ({ where }) => { + const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; + return (persisted.get(key) ?? null) as never; + }); + + const firstPayload = { + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" }, + ], + }; + const collidingPayload = { + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" }, + ], + }; + + await POST(request(firstPayload)); + const collisionResponse = await POST(request(collidingPayload)); + const retryResponse = await POST(request(collidingPayload)); + + expect(await collisionResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 1, skipped: 0 }, + error: null, + }); + expect(await retryResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 1 }, + error: null, + }); + expect(persisted.size).toBe(2); + }); + it("keeps a generated timestamp stable when upserting an external ID", async () => { vi.useFakeTimers(); try { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 29915dd52..43239448d 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -33,6 +33,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -65,6 +67,16 @@ const measurementSchema = z "Wert ausserhalb des plausiblen Bereichs", ); +function fallbackMoodDateStart(date: string): Date | null { + const [year, month, day] = date.split("-").map(Number); + if (!year || !month || !day) return null; + const instant = zonedWallClockToUtc( + { year, month, day, hour: 0, minute: 0 }, + DEFAULT_TIMEZONE, + ); + return moodDateKey(instant, DEFAULT_TIMEZONE) === date ? instant : null; +} + const moodEntrySchema = z .object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -84,14 +96,8 @@ const moodEntrySchema = z }) .superRefine((entry, ctx) => { if (entry.loggedAt) return; - const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`); - const isCalendarDate = - !Number.isNaN(dateStart) && - new Date(dateStart).toISOString().slice(0, 10) === entry.date; - if ( - !isCalendarDate || - !isPlausibleEntryInstant(new Date(dateStart)) - ) { + const dateStart = fallbackMoodDateStart(entry.date); + if (!dateStart || !isPlausibleEntryInstant(dateStart)) { ctx.addIssue({ code: "custom", path: ["date"], @@ -115,20 +121,18 @@ function fallbackMoodIdentity(entry: MoodEntryInput): string { function stableFallbackMoodInstant( entry: MoodEntryInput, occurrence: number, - reservedInstants: Set, + probe: number, ): Date { const digest = createHash("sha256") .update(`${fallbackMoodIdentity(entry)}:${occurrence}`) .digest(); - let offset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; - const dateStart = Date.parse(`${entry.date}T00:00:00.000Z`); - let instant = dateStart + offset; - while (reservedInstants.has(instant)) { - offset = (offset + 1) % ENTRY_INSTANT_CLOCK_SKEW_MS; - instant = dateStart + offset; - } - reservedInstants.add(instant); - return new Date(instant); + const initialOffset = + digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const offset = + (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const dateStart = fallbackMoodDateStart(entry.date); + if (!dateStart) throw new Error("Invalid mood date"); + return new Date(dateStart.getTime() + offset); } const importSchema = z.object({ @@ -295,74 +299,116 @@ export const POST = apiHandler(async (request: NextRequest) => { // Import mood entries if (!writeFailed && data.moodEntries?.length) { const fallbackOccurrences = new Map(); - const reservedFallbackInstants = new Set(); for (const e of data.moodEntries) { - try { - let loggedAt = e.loggedAt; - if (!loggedAt) { - const identity = fallbackMoodIdentity(e); - const occurrence = fallbackOccurrences.get(identity) ?? 0; - fallbackOccurrences.set(identity, occurrence + 1); - loggedAt = stableFallbackMoodInstant( - e, - occurrence, - reservedFallbackInstants, - ); - } + const identity = e.loggedAt ? null : fallbackMoodIdentity(e); + const occurrence = + identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0); + if (identity !== null) { + fallbackOccurrences.set(identity, occurrence + 1); + } - if (e.externalId) { - // v1.12.1 — idempotent re-import keyed on the source-stable id. - // A second import of the same export updates the row in place - // rather than skipping it as a duplicate, so an upstream edit - // (re-scored mood, added tag) is reflected. - await prisma.moodEntry.upsert({ - where: { - userId_source_externalId: { + for ( + let probe = 0; + probe < ENTRY_INSTANT_CLOCK_SKEW_MS; + probe++ + ) { + const loggedAt = + e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe); + try { + if (e.externalId) { + // v1.12.1 — idempotent re-import keyed on the source-stable id. + // A second import of the same export updates the row in place + // rather than skipping it as a duplicate, so an upstream edit + // (re-scored mood, added tag) is reflected. + await prisma.moodEntry.upsert({ + where: { + userId_source_externalId: { + userId, + source: "IMPORT", + externalId: e.externalId, + }, + }, + update: { + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + moodLoggedAt: loggedAt, + }, + create: { userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, source: "IMPORT", externalId: e.externalId, + moodLoggedAt: loggedAt, }, - }, - update: { - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - moodLoggedAt: loggedAt, - }, - create: { - userId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - source: "IMPORT", - externalId: e.externalId, - moodLoggedAt: loggedAt, - }, - }); - } else { - await prisma.moodEntry.create({ - data: { - userId, - date: e.date, - mood: e.mood, - score: e.score, - tags: e.tags || null, - source: "IMPORT", - moodLoggedAt: loggedAt, - }, - }); + }); + } else { + await prisma.moodEntry.create({ + data: { + userId, + date: e.date, + mood: e.mood, + score: e.score, + tags: e.tags || null, + source: "IMPORT", + moodLoggedAt: loggedAt, + }, + }); + } + stats.moodEntries++; + break; + } catch (err) { + if (!isP2002(err)) { + writeFailed = true; + break; + } + if (e.loggedAt) { + stats.skipped++; + break; + } + if (!e.externalId) { + try { + const existing = await prisma.moodEntry.findUnique({ + where: { + userId_date_moodLoggedAt: { + userId, + date: e.date, + moodLoggedAt: loggedAt, + }, + }, + select: { + source: true, + mood: true, + score: true, + tags: true, + externalId: true, + }, + }); + if ( + existing?.source === "IMPORT" && + existing.mood === e.mood && + existing.score === e.score && + existing.tags === (e.tags || null) && + (existing.externalId ?? null) === null + ) { + stats.skipped++; + break; + } + } catch { + writeFailed = true; + break; + } + } + if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) { + writeFailed = true; + } } - stats.moodEntries++; - } catch (err) { - if (isP2002(err)) { - stats.skipped++; - continue; - } - writeFailed = true; - break; } + if (writeFailed) break; } } From e9507a0c936a252df24b4deb6bf83568e9704fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:42:38 +0200 Subject: [PATCH 23/35] fix(backups): address restore review findings --- messages/de.json | 2 +- messages/en.json | 2 +- src/app/api/admin/backups/[id]/restore/route.ts | 12 +++++------- src/app/api/export/__tests__/per-type-routes.test.ts | 2 +- .../admin/__tests__/backups-section-empty.test.tsx | 11 +++++++++++ src/lib/export/__tests__/full-backup-payload.test.ts | 2 +- .../admin-backups-canonical-roundtrip.test.ts | 6 +++--- tests/integration/admin-backups-restore.test.ts | 7 ++++++- 8 files changed, 29 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index ad76c0b03..503a99d3f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6243,7 +6243,7 @@ "restore": "Wiederherstellen", "restoreAria": "Backup für {username} wiederherstellen", "restoreTitle": "Aus Backup wiederherstellen", - "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.", + "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "restoreConfirm": "Jetzt wiederherstellen", "restoreInProgress": "Wiederherstellung läuft…", diff --git a/messages/en.json b/messages/en.json index 7f141bc88..fce2bb3e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6243,7 +6243,7 @@ "restore": "Restore", "restoreAria": "Restore from backup for {username}", "restoreTitle": "Restore from backup", - "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.", + "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.", "restorePromptLabel": "Type RESTORE to continue", "restoreConfirm": "Restore now", "restoreInProgress": "Restoring…", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 7dd1c6afd..0bb3e75ff 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -100,7 +100,7 @@ const handler = apiHandler( details: { reason: "missing_confirmation", backupId: id }, }); return apiError( - "Confirmation token missing — body must include confirm: 'RESTORE'", + "Confirmation token missing — restoring user data and included instance-wide settings requires confirm: 'RESTORE'", 422, ); } @@ -403,13 +403,11 @@ const handler = apiHandler( unit: measurement.unit, source: measurement.source ?? "MANUAL", measuredAt: new Date(measurement.measuredAt), - notes: measurement.notes ?? null, + notes: null, notesEncrypted: - measurement.notesEncrypted !== undefined - ? measurement.notesEncrypted === null - ? null - : decodeEncryptedBytes(measurement.notesEncrypted) - : encryptNote(measurement.notes ?? null), + measurement.notesEncrypted == null + ? encryptNote(measurement.notes ?? null) + : decodeEncryptedBytes(measurement.notesEncrypted), externalId: measurement.externalId ?? null, externalSourceVersion: measurement.externalSourceVersion ?? null, glucoseContext: (measurement.glucoseContext ?? null) as never, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f442c3e15..b46280d47 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -421,7 +421,7 @@ describe("GET /api/export/full-backup", () => { expect(res.headers.get("content-disposition")).toMatch(/\.json"/); const json = await res.json(); expect(json).toMatchObject({ - schemaVersion: "1", + schemaVersion: "2", userId: "user-1", measurements: [], medications: [], diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 8029c7cf9..bf16a336d 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import de from "../../../../messages/de.json"; +import en from "../../../../messages/en.json"; /** * v1.4.15 phase-C5 — `/admin/backups` empty state. @@ -76,4 +78,13 @@ describe("BackupsSection — empty state", () => { const matches = (html.match(/Backup now/g) ?? []).length; expect(matches).toBeGreaterThanOrEqual(2); }); + + it("warns in both catalogs that restore overwrites instance-wide settings", () => { + expect(de.admin.section.backups.restoreDescription).toMatch( + /instanzweite Einstellungen/i, + ); + expect(en.admin.section.backups.restoreDescription).toMatch( + /instance-wide settings/i, + ); + }); }); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 30b553b4c..fd5a23f56 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -76,7 +76,7 @@ const appSettings = { assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { nutrients: false, insights: true }, documentMaxFileBytes: 12_345_678, - documentQuotaBytes: 9_876_543_210n, + documentQuotaBytes: BigInt("9876543210"), }; function makePrisma() { diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 6649ffa2f..d0a24c222 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -159,7 +159,7 @@ describe("canonical disaster-recovery backup round-trip", () => { assistantHealthScoreExplainerEnabled: false, moduleAvailabilityJson: { insights: true, nutrients: false }, documentMaxFileBytes: 12_345_678, - documentQuotaBytes: 9_876_543_210n, + documentQuotaBytes: BigInt("9876543210"), }, }); @@ -917,12 +917,12 @@ describe("canonical disaster-recovery backup round-trip", () => { const originalCreateMany = tx.measurement.createMany.bind( tx.measurement, ); - vi.spyOn(tx.measurement, "upsert").mockImplementation(async (args) => { + vi.spyOn(tx.measurement, "upsert").mockImplementation((args) => { stableWriteQueries += 1; return originalUpsert(args); }); vi.spyOn(tx.measurement, "createMany").mockImplementation( - async (args) => { + (args) => { stableWriteQueries += 1; return originalCreateMany(args); }, diff --git a/tests/integration/admin-backups-restore.test.ts b/tests/integration/admin-backups-restore.test.ts index 952f1a188..6e0ee8b29 100644 --- a/tests/integration/admin-backups-restore.test.ts +++ b/tests/integration/admin-backups-restore.test.ts @@ -100,7 +100,8 @@ describe("POST /api/admin/backups/[id]/restore", () => { unit: "kg", measuredAt: "2026-05-08T07:00:00.000Z", source: "MANUAL", - notes: null, + notes: "private legacy note", + notesEncrypted: null, }, { type: "PULSE", @@ -221,6 +222,8 @@ describe("POST /api/admin/backups/[id]/restore", () => { expect(measurements[0]!.type).toBe("WEIGHT"); expect(measurements[0]!.value).toBe(80.5); expect(measurements[1]!.type).toBe("PULSE"); + expect(measurements[0]!.notes).toBeNull(); + expect(measurements[0]!.notesEncrypted).not.toBeNull(); const medications = await prisma.medication.findMany({ where: { userId: admin.id }, @@ -303,6 +306,8 @@ describe("POST /api/admin/backups/[id]/restore", () => { }); expect(res.status).toBe(422); + const error = (await res.json()) as { error: string }; + expect(error.error).toMatch(/instance-wide settings/i); const denied = await prisma.auditLog.count({ where: { action: "admin.backups.restore.denied", From 6ebc278c2ccce00ca386897fc5e7d9290147de97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:47:28 +0200 Subject: [PATCH 24/35] fix(import): preserve repeated legacy mood rows --- src/app/api/import/__tests__/route.test.ts | 129 +++++++++++++-------- src/app/api/import/route.ts | 5 + 2 files changed, 86 insertions(+), 48 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 3fbc0ce56..89fd1cd01 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -90,6 +90,10 @@ interface ApiErrorEnvelope { error: string; } +function asDate(value: string | Date): Date { + return value instanceof Date ? value : new Date(value); +} + // V3 audit: /api/import POST had no rate-limit. Bulk-injection vector // (max:10000 records per call). Now capped at 5/hour/user. describe("POST /api/import — rate-limit guard", () => { @@ -807,22 +811,24 @@ describe("POST /api/import — write failure classification", () => { return {} as never; }; - vi.mocked(prisma.moodEntry.create).mockImplementation(({ data }) => - writeMood(data as LegacyMoodWrite, committedKeys), + vi.mocked(prisma.moodEntry.create).mockImplementation( + (({ data }) => + writeMood(data as LegacyMoodWrite, committedKeys)) as never, ); vi.mocked(prisma.moodEntry.findUnique).mockImplementation( - async ({ where }) => { - const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; - if (!committedKeys.has(key)) return null; - const isFirstDay = where.userId_date_moodLoggedAt.date === "2026-05-01"; - return { + (({ where }) => { + const composite = where.userId_date_moodLoggedAt; + const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`; + if (!committedKeys.has(key)) return Promise.resolve(null); + const isFirstDay = composite.date === "2026-05-01"; + return Promise.resolve({ source: "IMPORT", mood: isFirstDay ? "GUT" : "OKAY", score: isFirstDay ? 4 : 3, tags: null, externalId: null, - } as never; - }, + } as never); + }) as never, ); const payload = { @@ -856,21 +862,25 @@ describe("POST /api/import — write failure classification", () => { try { vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); const committedKeys = new Set(); - vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { - const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; - if (committedKeys.has(key)) { - throw new Prisma.PrismaClientKnownRequestError( - "Unique constraint failed", - { - code: "P2002", - clientVersion: "test", - meta: { target: ["userId", "date", "moodLoggedAt"] }, - }, - ); - } - committedKeys.add(key); - return {} as never; - }); + vi.mocked(prisma.moodEntry.create).mockImplementation( + (({ data }) => { + const key = `${data.date}:${asDate(data.moodLoggedAt).toISOString()}`; + if (committedKeys.has(key)) { + return Promise.reject( + new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ), + ); + } + committedKeys.add(key); + return Promise.resolve({} as never); + }) as never, + ); const firstResponse = await POST( request({ @@ -909,8 +919,9 @@ describe("POST /api/import — write failure classification", () => { ); expect(response.status).toBe(200); - const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] - .data.moodLoggedAt; + const moodLoggedAt = asDate( + vi.mocked(prisma.moodEntry.create).mock.calls[0][0].data.moodLoggedAt, + ); expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( Date.parse("2026-04-30T22:00:00.000Z"), ); @@ -937,10 +948,10 @@ describe("POST /api/import — write failure classification", () => { expect(response.status).toBe(200); const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0] .data.moodLoggedAt; - expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual( + expect(new Date(moodLoggedAt).getTime()).toBeGreaterThanOrEqual( Date.parse("2026-05-10T22:00:00.000Z"), ); - expect(moodLoggedAt.getTime()).toBeLessThan( + expect(new Date(moodLoggedAt).getTime()).toBeLessThan( Date.parse("2026-05-10T22:05:00.000Z"), ); } finally { @@ -948,7 +959,7 @@ describe("POST /api/import — write failure classification", () => { } }); - it("probes past a persisted fallback timestamp collision", async () => { + it("resolves persisted and repeated-row fallback timestamp collisions", async () => { type StoredMood = { date: string; mood: string; @@ -959,25 +970,33 @@ describe("POST /api/import — write failure classification", () => { }; const persisted = new Map(); - vi.mocked(prisma.moodEntry.create).mockImplementation(async ({ data }) => { - const key = `${data.date}:${data.moodLoggedAt.toISOString()}`; - if (persisted.has(key)) { - throw new Prisma.PrismaClientKnownRequestError( - "Unique constraint failed", - { - code: "P2002", - clientVersion: "test", - meta: { target: ["userId", "date", "moodLoggedAt"] }, - }, - ); - } - persisted.set(key, data as StoredMood); - return {} as never; - }); - vi.mocked(prisma.moodEntry.findUnique).mockImplementation(async ({ where }) => { - const key = `${where.userId_date_moodLoggedAt.date}:${where.userId_date_moodLoggedAt.moodLoggedAt.toISOString()}`; - return (persisted.get(key) ?? null) as never; - }); + vi.mocked(prisma.moodEntry.create).mockImplementation( + (({ data }) => { + const moodLoggedAt = asDate(data.moodLoggedAt); + const key = `${data.date}:${moodLoggedAt.toISOString()}`; + if (persisted.has(key)) { + return Promise.reject( + new Prisma.PrismaClientKnownRequestError( + "Unique constraint failed", + { + code: "P2002", + clientVersion: "test", + meta: { target: ["userId", "date", "moodLoggedAt"] }, + }, + ), + ); + } + persisted.set(key, { ...data, moodLoggedAt } as StoredMood); + return Promise.resolve({} as never); + }) as never, + ); + vi.mocked(prisma.moodEntry.findUnique).mockImplementation( + (({ where }) => { + const composite = where.userId_date_moodLoggedAt; + const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`; + return Promise.resolve((persisted.get(key) ?? null) as never); + }) as never, + ); const firstPayload = { moodEntries: [ @@ -1003,6 +1022,20 @@ describe("POST /api/import — write failure classification", () => { error: null, }); expect(persisted.size).toBe(2); + + const repeatedRowResponse = await POST( + request({ + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" }, + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" }, + ], + }), + ); + expect(await repeatedRowResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 2, skipped: 0 }, + error: null, + }); + expect(persisted.size).toBe(4); }); it("keeps a generated timestamp stable when upserting an external ID", async () => { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 43239448d..aee49a813 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -299,6 +299,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // Import mood entries if (!writeFailed && data.moodEntries?.length) { const fallbackOccurrences = new Map(); + const reservedFallbackInstants = new Set(); for (const e of data.moodEntries) { const identity = e.loggedAt ? null : fallbackMoodIdentity(e); const occurrence = @@ -314,6 +315,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ) { const loggedAt = e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe); + if (!e.loggedAt) { + if (reservedFallbackInstants.has(loggedAt.getTime())) continue; + reservedFallbackInstants.add(loggedAt.getTime()); + } try { if (e.externalId) { // v1.12.1 — idempotent re-import keyed on the source-stable id. From ca1ba8b12838884d949c785705b2ba731efd39e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:53:06 +0200 Subject: [PATCH 25/35] test(whoop): type morning refresh mock --- src/lib/whoop/__tests__/sync-sleep-sweep.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts index 8e7cf6dab..47be030e8 100644 --- a/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts +++ b/src/lib/whoop/__tests__/sync-sleep-sweep.test.ts @@ -28,7 +28,9 @@ const { updateManyMock, upsertMeasurementsMock, morningRefreshMock } = return 1; }, ), - morningRefreshMock: vi.fn(async () => {}), + morningRefreshMock: vi.fn<(...args: unknown[]) => Promise>( + async () => {}, + ), })); vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ From 4124c483eb0696000dc6b1177fd45edb7e1fff66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:53:31 +0200 Subject: [PATCH 26/35] test(import): type legacy mood retry mocks --- src/app/api/import/__tests__/route.test.ts | 38 +++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 89fd1cd01..9fbe930ac 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -812,14 +812,14 @@ describe("POST /api/import — write failure classification", () => { }; vi.mocked(prisma.moodEntry.create).mockImplementation( - (({ data }) => - writeMood(data as LegacyMoodWrite, committedKeys)) as never, + ({ data }: Prisma.MoodEntryCreateArgs) => + writeMood(data as LegacyMoodWrite, committedKeys) as never, ); vi.mocked(prisma.moodEntry.findUnique).mockImplementation( - (({ where }) => { - const composite = where.userId_date_moodLoggedAt; + ({ where }: Prisma.MoodEntryFindUniqueArgs) => { + const composite = where.userId_date_moodLoggedAt!; const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`; - if (!committedKeys.has(key)) return Promise.resolve(null); + if (!committedKeys.has(key)) return Promise.resolve(null) as never; const isFirstDay = composite.date === "2026-05-01"; return Promise.resolve({ source: "IMPORT", @@ -827,8 +827,8 @@ describe("POST /api/import — write failure classification", () => { score: isFirstDay ? 4 : 3, tags: null, externalId: null, - } as never); - }) as never, + } as never) as never; + }, ); const payload = { @@ -863,7 +863,7 @@ describe("POST /api/import — write failure classification", () => { vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z")); const committedKeys = new Set(); vi.mocked(prisma.moodEntry.create).mockImplementation( - (({ data }) => { + ({ data }: Prisma.MoodEntryCreateArgs) => { const key = `${data.date}:${asDate(data.moodLoggedAt).toISOString()}`; if (committedKeys.has(key)) { return Promise.reject( @@ -875,11 +875,11 @@ describe("POST /api/import — write failure classification", () => { meta: { target: ["userId", "date", "moodLoggedAt"] }, }, ), - ); + ) as never; } committedKeys.add(key); - return Promise.resolve({} as never); - }) as never, + return Promise.resolve({} as never) as never; + }, ); const firstResponse = await POST( @@ -971,7 +971,7 @@ describe("POST /api/import — write failure classification", () => { const persisted = new Map(); vi.mocked(prisma.moodEntry.create).mockImplementation( - (({ data }) => { + ({ data }: Prisma.MoodEntryCreateArgs) => { const moodLoggedAt = asDate(data.moodLoggedAt); const key = `${data.date}:${moodLoggedAt.toISOString()}`; if (persisted.has(key)) { @@ -984,18 +984,18 @@ describe("POST /api/import — write failure classification", () => { meta: { target: ["userId", "date", "moodLoggedAt"] }, }, ), - ); + ) as never; } persisted.set(key, { ...data, moodLoggedAt } as StoredMood); - return Promise.resolve({} as never); - }) as never, + return Promise.resolve({} as never) as never; + }, ); vi.mocked(prisma.moodEntry.findUnique).mockImplementation( - (({ where }) => { - const composite = where.userId_date_moodLoggedAt; + ({ where }: Prisma.MoodEntryFindUniqueArgs) => { + const composite = where.userId_date_moodLoggedAt!; const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`; - return Promise.resolve((persisted.get(key) ?? null) as never); - }) as never, + return Promise.resolve((persisted.get(key) ?? null) as never) as never; + }, ); const firstPayload = { From a846bbc90ddf36530df0a01e219aaa9890e0efe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 13:55:22 +0200 Subject: [PATCH 27/35] fix(import): make mood collision probing order independent --- src/app/api/import/__tests__/route.test.ts | 14 ++++++++++++++ src/app/api/import/route.ts | 12 +++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 9fbe930ac..09e8d95b3 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -1023,6 +1023,20 @@ describe("POST /api/import — write failure classification", () => { }); expect(persisted.size).toBe(2); + const reverseOrderResponse = await POST( + request({ + moodEntries: [ + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" }, + { date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" }, + ], + }), + ); + expect(await reverseOrderResponse.json()).toEqual({ + data: { measurements: 0, moodEntries: 0, skipped: 2 }, + error: null, + }); + expect(persisted.size).toBe(2); + const repeatedRowResponse = await POST( request({ moodEntries: [ diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index aee49a813..96983102d 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -315,9 +315,11 @@ export const POST = apiHandler(async (request: NextRequest) => { ) { const loggedAt = e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe); - if (!e.loggedAt) { - if (reservedFallbackInstants.has(loggedAt.getTime())) continue; - reservedFallbackInstants.add(loggedAt.getTime()); + if ( + !e.loggedAt && + reservedFallbackInstants.has(loggedAt.getTime()) + ) { + continue; } try { if (e.externalId) { @@ -364,6 +366,9 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); } + if (!e.loggedAt) { + reservedFallbackInstants.add(loggedAt.getTime()); + } stats.moodEntries++; break; } catch (err) { @@ -400,6 +405,7 @@ export const POST = apiHandler(async (request: NextRequest) => { existing.tags === (e.tags || null) && (existing.externalId ?? null) === null ) { + reservedFallbackInstants.add(loggedAt.getTime()); stats.skipped++; break; } From d966355f83af54d1106460b8b7f2562277029728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:02:08 +0200 Subject: [PATCH 28/35] chore: format data integrity fixes --- .../api/admin/backups/[id]/restore/route.ts | 10 +-- src/app/api/import/route.ts | 17 ++--- src/lib/cycle/__tests__/backup.test.ts | 8 +-- src/lib/whoop/sync.ts | 62 ++++++++++--------- .../admin-backups-canonical-roundtrip.test.ts | 57 ++++++++--------- 5 files changed, 67 insertions(+), 87 deletions(-) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 0bb3e75ff..341577473 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -367,8 +367,7 @@ const handler = apiHandler( adminCodexConnectedAt: settings.adminCodexConnectedAt ? new Date(settings.adminCodexConnectedAt) : null, - adminCodexConnectionStatus: - settings.adminCodexConnectionStatus, + adminCodexConnectionStatus: settings.adminCodexConnectionStatus, adminAiInsightsFeedbackSummary: settings.adminAiInsightsFeedbackSummary as never, defaultUserTimezone: settings.defaultUserTimezone, @@ -381,8 +380,7 @@ const handler = apiHandler( settings.assistantCorrelationsEnabled, assistantHealthScoreExplainerEnabled: settings.assistantHealthScoreExplainerEnabled, - moduleAvailabilityJson: - settings.moduleAvailabilityJson as never, + moduleAvailabilityJson: settings.moduleAvailabilityJson as never, documentMaxFileBytes: settings.documentMaxFileBytes, documentQuotaBytes: BigInt(settings.documentQuotaBytes), }; @@ -492,9 +490,7 @@ const handler = apiHandler( active: m.active ?? true, notificationsEnabled: m.notificationsEnabled ?? true, pausedAt: m.pausedAt ? new Date(m.pausedAt) : null, - snoozedUntil: m.snoozedUntil - ? new Date(m.snoozedUntil) - : null, + snoozedUntil: m.snoozedUntil ? new Date(m.snoozedUntil) : null, startsOn: m.startsOn ? new Date(m.startsOn) : null, endsOn: m.endsOn ? new Date(m.endsOn) : null, oneShot: m.oneShot ?? false, diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 96983102d..fba6e2272 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -126,10 +126,8 @@ function stableFallbackMoodInstant( const digest = createHash("sha256") .update(`${fallbackMoodIdentity(entry)}:${occurrence}`) .digest(); - const initialOffset = - digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; - const offset = - (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const initialOffset = digest.readUInt32BE(0) % ENTRY_INSTANT_CLOCK_SKEW_MS; + const offset = (initialOffset + probe) % ENTRY_INSTANT_CLOCK_SKEW_MS; const dateStart = fallbackMoodDateStart(entry.date); if (!dateStart) throw new Error("Invalid mood date"); return new Date(dateStart.getTime() + offset); @@ -308,17 +306,10 @@ export const POST = apiHandler(async (request: NextRequest) => { fallbackOccurrences.set(identity, occurrence + 1); } - for ( - let probe = 0; - probe < ENTRY_INSTANT_CLOCK_SKEW_MS; - probe++ - ) { + for (let probe = 0; probe < ENTRY_INSTANT_CLOCK_SKEW_MS; probe++) { const loggedAt = e.loggedAt ?? stableFallbackMoodInstant(e, occurrence, probe); - if ( - !e.loggedAt && - reservedFallbackInstants.has(loggedAt.getTime()) - ) { + if (!e.loggedAt && reservedFallbackInstants.has(loggedAt.getTime())) { continue; } try { diff --git a/src/lib/cycle/__tests__/backup.test.ts b/src/lib/cycle/__tests__/backup.test.ts index ee7998e65..170d3c29a 100644 --- a/src/lib/cycle/__tests__/backup.test.ts +++ b/src/lib/cycle/__tests__/backup.test.ts @@ -80,11 +80,9 @@ describe("buildCycleBackupSection disaster-recovery mode", () => { }, }; - const section = await buildCycleBackupSection( - prisma as never, - "user-1", - { purpose: "disaster-recovery" }, - ); + const section = await buildCycleBackupSection(prisma as never, "user-1", { + purpose: "disaster-recovery", + }); expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1" } }), diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index a058d6cb7..ab11f04c6 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -367,37 +367,39 @@ export async function upsertWhoopMeasurements( chunkStart + WHOOP_MEASUREMENT_TRANSACTION_CHUNK_SIZE, readings.length, ); - const verdicts = await prisma.$transaction( - async (tx) => { - const outcomes = []; - for (let index = chunkStart; index < chunkEnd; index++) { - const reading = readings[index]!; - const verdict = await reconcileExternalMeasurement( - tx, - { - userId, - type: reading.type as MeasurementType, - source: "WHOOP", - value: reading.value, - unit: reading.unit, - measuredAt: reading.measuredAt, - externalId: reading.externalId, - sleepStage: reading.sleepStage ?? null, - }, - { exactExternalMatch: "update" }, - ); - if (verdict.status === "failed") { - throw new MeasurementReconciliationError(verdict); + const verdicts = await prisma + .$transaction( + async (tx) => { + const outcomes = []; + for (let index = chunkStart; index < chunkEnd; index++) { + const reading = readings[index]!; + const verdict = await reconcileExternalMeasurement( + tx, + { + userId, + type: reading.type as MeasurementType, + source: "WHOOP", + value: reading.value, + unit: reading.unit, + measuredAt: reading.measuredAt, + externalId: reading.externalId, + sleepStage: reading.sleepStage ?? null, + }, + { exactExternalMatch: "update" }, + ); + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + outcomes.push(verdict); } - outcomes.push(verdict); - } - return outcomes; - }, - { maxWait: 10_000, timeout: 60_000 }, - ).catch((err) => { - invalidateTouchedTypes(); - throw err; - }); + return outcomes; + }, + { maxWait: 10_000, timeout: 60_000 }, + ) + .catch((err) => { + invalidateTouchedTypes(); + throw err; + }); const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; const insertedRows: Array< diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index d0a24c222..ac4c7ca13 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -280,9 +280,7 @@ describe("canonical disaster-recovery backup round-trip", () => { scheduleType: "CYCLIC", cyclicOnWeeks: 3, cyclicOffWeeks: 1, - doseWindows: [ - { timeOfDay: "08:00", start: "07:30", end: "09:00" }, - ], + doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:00" }], }, }, }, @@ -340,8 +338,7 @@ describe("canonical disaster-recovery backup round-trip", () => { deletedAt: tombstoneDeletedAt, }, }); - const cycleCategory = - await prisma.cycleSymptomCategory.findFirstOrThrow(); + const cycleCategory = await prisma.cycleSymptomCategory.findFirstOrThrow(); const cycleSymptom = await prisma.cycleSymptom.create({ data: { id: "cycle-symptom-dr", @@ -542,7 +539,9 @@ describe("canonical disaster-recovery backup round-trip", () => { ); await prisma.appSettings.deleteMany(); - await prisma.medicationIntakeEvent.deleteMany({ where: { userId: ownerId } }); + await prisma.medicationIntakeEvent.deleteMany({ + where: { userId: ownerId }, + }); await prisma.medication.deleteMany({ where: { userId: ownerId } }); await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } }); await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } }); @@ -880,9 +879,7 @@ describe("canonical disaster-recovery backup round-trip", () => { type: "PULSE" as const, value: 60 + (index % 20), unit: "bpm", - measuredAt: new Date( - Date.UTC(2026, 6, 10, 0, 0, 0, index), - ).toISOString(), + measuredAt: new Date(Date.UTC(2026, 6, 10, 0, 0, 0, index)).toISOString(), source: "IMPORT" as const, externalId: `external-${index}`, syncVersion: 3, @@ -907,29 +904,25 @@ describe("canonical disaster-recovery backup round-trip", () => { options?: { maxWait?: number; timeout?: number }, ) => Promise; const transactionSpy = vi.spyOn(prisma, "$transaction"); - transactionSpy.mockImplementation( - (async ( - callback: (tx: Prisma.TransactionClient) => Promise, - options?: { maxWait?: number; timeout?: number }, - ) => - originalTransaction(async (tx) => { - const originalUpsert = tx.measurement.upsert.bind(tx.measurement); - const originalCreateMany = tx.measurement.createMany.bind( - tx.measurement, - ); - vi.spyOn(tx.measurement, "upsert").mockImplementation((args) => { - stableWriteQueries += 1; - return originalUpsert(args); - }); - vi.spyOn(tx.measurement, "createMany").mockImplementation( - (args) => { - stableWriteQueries += 1; - return originalCreateMany(args); - }, - ); - return callback(tx); - }, options)) as never, - ); + transactionSpy.mockImplementation((async ( + callback: (tx: Prisma.TransactionClient) => Promise, + options?: { maxWait?: number; timeout?: number }, + ) => + originalTransaction(async (tx) => { + const originalUpsert = tx.measurement.upsert.bind(tx.measurement); + const originalCreateMany = tx.measurement.createMany.bind( + tx.measurement, + ); + vi.spyOn(tx.measurement, "upsert").mockImplementation((args) => { + stableWriteQueries += 1; + return originalUpsert(args); + }); + vi.spyOn(tx.measurement, "createMany").mockImplementation((args) => { + stableWriteQueries += 1; + return originalCreateMany(args); + }); + return callback(tx); + }, options)) as never); const response = await POST( makeRequest(backup.id) as unknown as Parameters[0], From f36bbc51cdbdb559d0bd10c7b12d7180c39ce2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:05:58 +0200 Subject: [PATCH 29/35] docs: finalize v1.31.2 recovery notes --- CHANGELOG.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f82a440..77cfc1ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,19 @@ measurement identities under database locks, preserve tombstones, emit arrivals only for genuine inserts, and advance cursors only after durable writes. OAuth refresh-token rotation is serialized per user and provider. -- **Webhook success now means the source data is durable.** moodLog writes - before acknowledging delivery, while Withings ECG callbacks enter a durable - queue with bounded retry and replay-safe processing. Operational JSON import - failures are reported as failures instead of misleading duplicate skips. -- **Recovery is complete and owner-safe.** Canonical backups round-trip every - supported record class, including encrypted document content and soft-deleted - measurements. Restore rejects account mismatches before writing, validates - backup enums at the boundary, and preserves recovery integrity across both - uploaded and off-host payloads. +- **Webhook and import success now means the source data is durable.** moodLog + writes before acknowledging delivery, while Withings ECG callbacks enter a + durable queue with bounded retry and replay-safe processing. WHOOP backfills + commit bounded chunks without advancing failed resource cursors, and legacy + mood imports use deterministic, collision-free retry identities. Operational + JSON import failures are reported as failures instead of misleading + duplicate skips. +- **Recovery is complete, exact, and owner-safe.** Canonical backups round-trip + every supported record class, including encrypted document content, + measurement reconciliation identities, and tombstones. Restore rejects + account mismatches before writing, validates backup enums at the boundary, + processes stable measurement IDs in bounded batches, and preserves recovery + integrity across both uploaded and off-host payloads. - **Device and mood edits no longer lose related state.** Same-user APNs registrations merge transactionally without crossing account boundaries, refresh-token bindings follow the surviving device, and mood edits preserve From 74a386fcfdbaf3cc5d5f85791069c92ed9a79b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:18:48 +0200 Subject: [PATCH 30/35] chore(deps): update July dependency bundle --- .github/workflows/coach-eval.yml | 2 +- .github/workflows/dependabot-auto-merge.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/env-check.yml | 2 +- .github/workflows/integration.yml | 2 +- .github/workflows/knip.yml | 2 +- .github/workflows/security.yml | 4 +- package.json | 8 +- pnpm-lock.yaml | 922 ++++++++++-------- .../__tests__/rasterize-pdf-cleanup.test.ts | 51 + src/lib/documents/rasterize-pdf.ts | 12 +- 11 files changed, 603 insertions(+), 406 deletions(-) create mode 100644 src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts diff --git a/.github/workflows/coach-eval.yml b/.github/workflows/coach-eval.yml index 356ee8432..e25dc6843 100644 --- a/.github/workflows/coach-eval.yml +++ b/.github/workflows/coach-eval.yml @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 17b28fe9e..2189a54c4 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -16,7 +16,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 987fc94bd..e118f4dee 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,7 +48,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml index a68503a10..f169e8f58 100644 --- a/.github/workflows/env-check.yml +++ b/.github/workflows/env-check.yml @@ -31,7 +31,7 @@ jobs: uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7d31a8b26..867ce3ac2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 352fd29eb..908b17a84 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b5f939f24..93ac96718 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -19,7 +19,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" @@ -70,7 +70,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "pnpm" diff --git a/package.json b/package.json index f1b40080c..86bff188d 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@napi-rs/canvas": "0.1.80", + "@napi-rs/canvas": "1.0.2", "@node-rs/argon2": "^2.0.2", "@parse/node-apn": "^8.1.0", "@prisma/adapter-pg": "^7.8.0", @@ -87,7 +87,7 @@ "pg-boss": "^12.26.0", "prisma": "^7.8.0", "qrcode": "^1.5.4", - "radix-ui": "^1.6.0", + "radix-ui": "^1.6.2", "react": "19.2.7", "react-day-picker": "^10.0.1", "react-dom": "19.2.7", @@ -125,10 +125,10 @@ "knip": "^6.16.1", "lefthook": "^2.1.10", "pdf-parse": "^2.4.5", - "pdfjs-dist": "5.4.296", + "pdfjs-dist": "6.1.200", "pg": "^8.22.0", "prettier": "^3.9.1", - "prettier-plugin-tailwindcss": "^0.8.0", + "prettier-plugin-tailwindcss": "^0.8.1", "shadcn": "^4.13.0", "tailwindcss": "^4", "testcontainers": "^12.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ed92da2..a5981cb3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) '@napi-rs/canvas': - specifier: 0.1.80 - version: 0.1.80 + specifier: 1.0.2 + version: 1.0.2 '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -115,8 +115,8 @@ importers: specifier: ^1.5.4 version: 1.5.4 radix-ui: - specifier: ^1.6.0 - version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.6.2 + version: 1.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -224,8 +224,8 @@ importers: specifier: ^2.4.5 version: 2.4.5 pdfjs-dist: - specifier: 5.4.296 - version: 5.4.296 + specifier: 6.1.200 + version: 6.1.200 pg: specifier: ^8.22.0 version: 8.22.0 @@ -233,14 +233,14 @@ importers: specifier: ^3.9.1 version: 3.9.1 prettier-plugin-tailwindcss: - specifier: ^0.8.0 - version: 0.8.0(prettier@3.9.1) + specifier: ^0.8.1 + version: 0.8.1(prettier@3.9.1) shadcn: specifier: ^4.13.0 version: 4.13.0(typescript@6.0.3) tailwindcss: specifier: ^4 - version: 4.3.1 + version: 4.3.3 testcontainers: specifier: ^12.0.3 version: 12.0.3 @@ -847,20 +847,20 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} @@ -1152,24 +1152,48 @@ packages: cpu: [arm64] os: [android] + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.80': resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.80': resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} engines: {node: '>= 10'} @@ -1177,6 +1201,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-arm64-musl@0.1.80': resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} engines: {node: '>= 10'} @@ -1184,6 +1215,13 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} engines: {node: '>= 10'} @@ -1191,6 +1229,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.80': resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} engines: {node: '>= 10'} @@ -1198,6 +1243,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-musl@0.1.80': resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} engines: {node: '>= 10'} @@ -1205,16 +1257,39 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.80': resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas@0.1.80': resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} engines: {node: '>= 10'} + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1796,11 +1871,11 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/primitive@1.1.4': - resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + '@radix-ui/primitive@1.1.5': + resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} - '@radix-ui/react-accessible-icon@1.1.10': - resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==} + '@radix-ui/react-accessible-icon@1.1.11': + resolution: {integrity: sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1812,8 +1887,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-accordion@1.2.14': - resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==} + '@radix-ui/react-accordion@1.2.16': + resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1825,8 +1900,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-alert-dialog@1.1.17': - resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} + '@radix-ui/react-alert-dialog@1.1.19': + resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1838,8 +1913,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.10': - resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1851,8 +1926,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-aspect-ratio@1.1.10': - resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==} + '@radix-ui/react-aspect-ratio@1.1.11': + resolution: {integrity: sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1864,8 +1939,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.2.0': - resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==} + '@radix-ui/react-avatar@1.2.2': + resolution: {integrity: sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1877,8 +1952,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.5': - resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} + '@radix-ui/react-checkbox@1.3.7': + resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1890,8 +1965,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.14': - resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==} + '@radix-ui/react-collapsible@1.1.16': + resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1903,8 +1978,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.10': - resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} + '@radix-ui/react-collection@1.1.12': + resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1934,8 +2009,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.3.1': - resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==} + '@radix-ui/react-context-menu@2.3.3': + resolution: {integrity: sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1947,8 +2022,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-context@1.1.4': - resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + '@radix-ui/react-context@1.2.0': + resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1956,8 +2031,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.17': - resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} + '@radix-ui/react-dialog@1.1.19': + resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1978,8 +2053,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.13': - resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} + '@radix-ui/react-dismissable-layer@1.1.15': + resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1991,8 +2066,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.18': - resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} + '@radix-ui/react-dropdown-menu@2.1.20': + resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2013,8 +2088,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.10': - resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} + '@radix-ui/react-focus-scope@1.1.12': + resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2026,8 +2101,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.10': - resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==} + '@radix-ui/react-form@0.1.12': + resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2039,8 +2114,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.17': - resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==} + '@radix-ui/react-hover-card@1.1.19': + resolution: {integrity: sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2061,8 +2136,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.10': - resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} + '@radix-ui/react-label@2.1.11': + resolution: {integrity: sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2074,8 +2149,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.18': - resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==} + '@radix-ui/react-menu@2.1.20': + resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2087,8 +2162,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menubar@1.1.18': - resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==} + '@radix-ui/react-menubar@1.1.20': + resolution: {integrity: sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2100,8 +2175,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-navigation-menu@1.2.16': - resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==} + '@radix-ui/react-navigation-menu@1.2.18': + resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2113,8 +2188,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-one-time-password-field@0.1.10': - resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==} + '@radix-ui/react-one-time-password-field@0.1.12': + resolution: {integrity: sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2126,8 +2201,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-password-toggle-field@0.1.5': - resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==} + '@radix-ui/react-password-toggle-field@0.1.7': + resolution: {integrity: sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2139,8 +2214,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.17': - resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==} + '@radix-ui/react-popover@1.1.19': + resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2152,8 +2227,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.1': - resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} + '@radix-ui/react-popper@1.3.3': + resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2165,8 +2240,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.12': - resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2178,8 +2253,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.6': - resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + '@radix-ui/react-presence@1.1.7': + resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2204,8 +2279,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.6': - resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2217,8 +2292,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.10': - resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==} + '@radix-ui/react-progress@1.1.12': + resolution: {integrity: sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2230,8 +2305,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.4.1': - resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==} + '@radix-ui/react-radio-group@1.4.3': + resolution: {integrity: sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2243,8 +2318,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.13': - resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} + '@radix-ui/react-roving-focus@1.1.15': + resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2256,8 +2331,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.12': - resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==} + '@radix-ui/react-scroll-area@1.2.14': + resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2269,8 +2344,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-select@2.3.1': - resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} + '@radix-ui/react-select@2.3.3': + resolution: {integrity: sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2282,8 +2357,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.10': - resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==} + '@radix-ui/react-separator@1.1.11': + resolution: {integrity: sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2295,8 +2370,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slider@1.4.1': - resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==} + '@radix-ui/react-slider@1.4.3': + resolution: {integrity: sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2326,8 +2401,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.3.1': - resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==} + '@radix-ui/react-switch@1.3.3': + resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2339,8 +2414,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.15': - resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} + '@radix-ui/react-tabs@1.1.17': + resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2352,8 +2427,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.17': - resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==} + '@radix-ui/react-toast@1.2.19': + resolution: {integrity: sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2365,8 +2440,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle-group@1.1.13': - resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==} + '@radix-ui/react-toggle-group@1.1.15': + resolution: {integrity: sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2391,8 +2466,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.12': - resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==} + '@radix-ui/react-toggle@1.1.14': + resolution: {integrity: sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2404,8 +2479,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toolbar@1.1.13': - resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==} + '@radix-ui/react-toolbar@1.1.15': + resolution: {integrity: sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2417,8 +2492,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.10': - resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} + '@radix-ui/react-tooltip@1.2.12': + resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2475,8 +2550,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-escape-keydown@1.1.2': - resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + '@radix-ui/react-use-escape-keydown@1.1.3': + resolution: {integrity: sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2538,8 +2613,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.6': - resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} + '@radix-ui/react-visually-hidden@1.2.7': + resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -5562,6 +5637,10 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} + pdfjs-dist@6.1.200: + resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + engines: {node: '>=22.13.0 || >=24'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -5666,6 +5745,10 @@ packages: resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -5698,8 +5781,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-tailwindcss@0.8.0: - resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + prettier-plugin-tailwindcss@0.8.1: + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==} engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -5833,8 +5916,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radix-ui@1.6.0: - resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} + radix-ui@1.6.2: + resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -6365,8 +6448,8 @@ packages: tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - tailwindcss@4.3.1: - resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -6425,11 +6508,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.4.8: - resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} - tldts@7.4.8: - resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} hasBin: true tmp@0.2.7: @@ -7656,22 +7739,22 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} '@grpc/grpc-js@1.14.4': dependencies: @@ -7944,33 +8027,66 @@ snapshots: '@napi-rs/canvas-android-arm64@0.1.80': optional: true + '@napi-rs/canvas-android-arm64@1.0.2': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.80': optional: true + '@napi-rs/canvas-darwin-arm64@1.0.2': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.80': optional: true + '@napi-rs/canvas-darwin-x64@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.80': optional: true + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.80': optional: true + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.80': optional: true + '@napi-rs/canvas-linux-x64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.80': optional: true + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + optional: true + '@napi-rs/canvas@0.1.80': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.80 @@ -7984,6 +8100,20 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.80 '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@napi-rs/canvas@1.0.2': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -8497,27 +8627,27 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/primitive@1.1.4': {} + '@radix-ui/primitive@1.1.5': {} - '@radix-ui/react-accessible-icon@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accessible-icon@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-accordion@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accordion@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8525,41 +8655,41 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-alert-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-aspect-ratio@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-avatar@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-avatar@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -8569,13 +8699,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-checkbox@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -8585,14 +8715,14 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collapsible@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collapsible@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 @@ -8601,11 +8731,11 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8625,12 +8755,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-context-menu@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-context-menu@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8638,24 +8768,24 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-context@1.1.4(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-context@1.2.0(@types/react@19.2.14)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 @@ -8672,27 +8802,27 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dropdown-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8706,10 +8836,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8717,30 +8847,30 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-form@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-form@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-hover-card@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-hover-card@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8755,31 +8885,31 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 @@ -8790,17 +8920,17 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-menubar@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-menubar@1.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8808,38 +8938,38 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-navigation-menu@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-navigation-menu@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-one-time-password-field@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-one-time-password-field@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.14)(react@19.2.7) @@ -8850,13 +8980,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-password-toggle-field@0.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-password-toggle-field@0.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.14)(react@19.2.7) @@ -8866,19 +8996,19 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popover@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) aria-hidden: 1.2.6 @@ -8889,13 +9019,13 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -8907,9 +9037,9 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8917,7 +9047,7 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 @@ -8935,7 +9065,7 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 @@ -8944,25 +9074,25 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-progress@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-progress@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-radio-group@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-radio-group@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -8972,32 +9102,34 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-scroll-area@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-scroll-area@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 @@ -9006,28 +9138,28 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-select@2.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9036,24 +9168,24 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-separator@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-slider@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-slider@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -9078,12 +9210,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-switch@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-switch@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.7) @@ -9093,15 +9225,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9109,34 +9241,34 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toast@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toast@1.2.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle-group@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle-group@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9155,10 +9287,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toggle@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9166,35 +9298,35 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-toolbar@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toolbar@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tooltip@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.4 + '@radix-ui/primitive': 1.1.5 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9237,7 +9369,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.14)(react@19.2.7)': + '@radix-ui/react-use-escape-keydown@1.1.3(@types/react@19.2.14)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) react: 19.2.7 @@ -9282,9 +9414,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -12416,6 +12548,10 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.80 + pdfjs-dist@6.1.200: + optionalDependencies: + '@napi-rs/canvas': 1.0.2 + perfect-debounce@2.1.0: {} performance-now@2.1.0: @@ -12519,6 +12655,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-array@3.0.4: {} @@ -12537,7 +12679,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.8.0(prettier@3.9.1): + prettier-plugin-tailwindcss@0.8.1(prettier@3.9.1): dependencies: prettier: 3.9.1 @@ -12639,63 +12781,63 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + radix-ui@1.6.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-accessible-icon': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-accordion': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-alert-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-aspect-ratio': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-avatar': 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-checkbox': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.5 + '@radix-ui/react-accessible-icon': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-context-menu': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context': 1.2.0(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dropdown-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-form': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-hover-card': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menubar': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-navigation-menu': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-one-time-password-field': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-password-toggle-field': 0.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popover': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-progress': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-radio-group': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-scroll-area': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-select': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slider': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.20(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-switch': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tabs': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toast': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toolbar': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tooltip': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-switch': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.14)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.3(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -13359,7 +13501,7 @@ snapshots: tailwindcss@4.3.0: {} - tailwindcss@4.3.1: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -13476,12 +13618,12 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.4.8: + tldts-core@7.4.9: optional: true - tldts@7.4.8: + tldts@7.4.9: dependencies: - tldts-core: 7.4.8 + tldts-core: 7.4.9 optional: true tmp@0.2.7: {} @@ -13496,7 +13638,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.8 + tldts: 7.4.9 optional: true tr46@0.0.3: {} @@ -13717,7 +13859,7 @@ snapshots: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.19 + postcss: 8.5.20 rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: diff --git a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts new file mode 100644 index 000000000..d5219da8b --- /dev/null +++ b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts @@ -0,0 +1,51 @@ +import { Buffer } from "node:buffer"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { destroyLoadingTask } = vi.hoisted(() => ({ + destroyLoadingTask: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../native-canvas-support", () => ({ + nativeCanvasSupported: () => true, +})); +vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({ + getDocument: () => ({ + destroy: destroyLoadingTask, + promise: Promise.resolve({ + numPages: 1, + canvasFactory: { + create: () => ({ + canvas: { + toBuffer: () => Buffer.from([0xff, 0xd8]), + }, + context: {}, + }), + }, + getPage: async () => ({ + getViewport: ({ scale }: { scale: number }) => ({ + width: 100 * scale, + height: 100 * scale, + }), + render: () => ({ promise: Promise.resolve() }), + cleanup: vi.fn(), + }), + }), + }), +})); + +import { rasterizePdf } from "../rasterize-pdf"; + +describe("rasterizePdf PDF.js lifecycle", () => { + beforeEach(() => { + destroyLoadingTask.mockClear(); + }); + + it("destroys the loading task after rendering", async () => { + await expect(rasterizePdf(Buffer.from("synthetic"))).resolves.toMatchObject({ + ok: true, + }); + expect(destroyLoadingTask).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index fa4da7222..0545837d1 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -102,6 +102,9 @@ interface PdfDocument { numPages: number; canvasFactory: PdfCanvasFactory; getPage(pageNumber: number): Promise; +} +interface PdfLoadingTask { + promise: Promise; destroy(): Promise; } interface PdfjsModule { @@ -110,7 +113,7 @@ interface PdfjsModule { verbosity?: number; isEvalSupported?: boolean; useSystemFonts?: boolean; - }): { promise: Promise }; + }): PdfLoadingTask; } /** @@ -125,6 +128,7 @@ export async function rasterizePdf( maxPages: number = RASTER_MAX_PAGES, ): Promise { let doc: PdfDocument | null = null; + let task: PdfLoadingTask | null = null; // CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64 // build uses AVX2 — an unsupported CPU dies with an uncatchable SIGILL on // the first render. Degrade to the existing no-raster path instead (scanned @@ -142,7 +146,7 @@ export async function rasterizePdf( const pdfjs = (await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule; - const task = pdfjs.getDocument({ + task = pdfjs.getDocument({ data: new Uint8Array(buffer), verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs. isEvalSupported: false, // never eval font programs (defence in depth). @@ -205,9 +209,9 @@ export async function rasterizePdf( }); return { ok: false }; } finally { - if (doc) { + if (task) { try { - await doc.destroy(); + await task.destroy(); } catch { // Best-effort teardown; a destroy failure must not mask the result. } From b9425de40e90304fcc8ee392e27cb4e32265d3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:25:24 +0200 Subject: [PATCH 31/35] fix(polar): use AccessLink collection endpoints --- src/lib/polar/__tests__/client.test.ts | 122 +++++++++++++++++++++---- src/lib/polar/__tests__/sync.test.ts | 12 +++ src/lib/polar/client.ts | 90 ++++++++---------- src/lib/polar/sync.ts | 10 +- 4 files changed, 159 insertions(+), 75 deletions(-) diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 6f8fbfe70..69b2248ae 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { POLAR_OAUTH_SCOPE, exchangeCode, + fetchActivities, fetchCardioLoads, fetchNightlyRecharges, + fetchSleeps, fetchSpo2, getAuthorizationUrl, getPolarCredentials, @@ -99,6 +101,12 @@ describe("exchangeCode", () => { const tok = await exchangeCode("code123", CREDS); expect(tok.access_token).toBe("tok"); expect(tok.x_user_id).toBe(42); + expect(fetchMock.mock.calls[0]![0]).toBe( + "https://polarremote.com/v2/oauth2/token", + ); + expect(fetchMock.mock.calls[0]![1]).toEqual( + expect.objectContaining({ method: "POST" }), + ); const [, init] = fetchMock.mock.calls[0]!; const headers = init!.headers as Record; expect(headers.Authorization).toBe( @@ -120,9 +128,13 @@ describe("registerUser", () => { installFetchMock([{ status: 409, body: {} }]); await expect(registerUser("tok", "42")).resolves.toBeUndefined(); }); - it("succeeds on a 200", async () => { - installFetchMock([{ status: 200, body: {} }]); + it("posts to the documented registration path", async () => { + const fetchMock = installFetchMock([{ status: 200, body: {} }]); await expect(registerUser("tok", "42")).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + "https://www.polaraccesslink.com/v3/users", + expect.objectContaining({ method: "POST" }), + ); }); it("throws on a 500", async () => { installFetchMock([{ status: 500 }]); @@ -132,16 +144,55 @@ describe("registerUser", () => { }); }); +describe("Polar AccessLink request paths", () => { + it.each([ + { + resource: "nightly recharge", + request: () => fetchNightlyRecharges("tok"), + url: "https://www.polaraccesslink.com/v3/users/nightly-recharge", + }, + { + resource: "sleep", + request: () => fetchSleeps("tok"), + url: "https://www.polaraccesslink.com/v3/users/sleep", + }, + { + resource: "daily activity", + request: () => fetchActivities("tok"), + url: "https://www.polaraccesslink.com/v3/users/activities", + }, + { + resource: "cardio load", + request: () => fetchCardioLoads("tok"), + url: "https://www.polaraccesslink.com/v3/users/cardio-load", + }, + { + resource: "SpO2", + request: () => fetchSpo2("tok"), + url: "https://www.polaraccesslink.com/v3/users/biosensing/spo2", + }, + ])("uses the documented GET path for $resource", async ({ request, url }) => { + const fetchMock = installFetchMock([{ status: 204 }]); + + await request(); + + expect(fetchMock).toHaveBeenCalledWith( + url, + expect.objectContaining({ method: "GET" }), + ); + }); +}); + describe("fetchNightlyRecharges", () => { it("returns [] on a 204 No Content", async () => { installFetchMock([{ status: 204 }]); - expect(await fetchNightlyRecharges("tok", "42")).toEqual([]); + expect(await fetchNightlyRecharges("tok")).toEqual([]); }); it("unwraps the recharges array", async () => { installFetchMock([ { status: 200, body: { recharges: [{ date: "2026-06-10" }] } }, ]); - const r = await fetchNightlyRecharges("tok", "42"); + const r = await fetchNightlyRecharges("tok"); expect(r).toHaveLength(1); expect(r[0]!.date).toBe("2026-06-10"); }); @@ -364,8 +415,8 @@ describe("mapSleep", () => { describe("mapActivity — distance", () => { it("maps distance_from_steps to WALKING_RUNNING_DISTANCE in metres", () => { const a: PolarActivity = { - date: "2026-06-10", - "active-steps": 8000, + start_time: "2026-06-10T00:00:00", + steps: 8000, distance_from_steps: 4590.53, }; const dist = mapActivity(a).find( @@ -393,39 +444,67 @@ describe("mapCardioLoad", () => { }); describe("mapSpo2", () => { - it("maps blood_oxygen_percentage to OXYGEN_SATURATION (percent)", () => { - const r: PolarSpo2 = { date: "2026-06-10", blood_oxygen_percentage: 96.4 }; + it("maps the documented SpO2 fields to an exact-timestamp reading", () => { + const r: PolarSpo2 = { + test_time: 1_781_059_600, + blood_oxygen_percent: 96, + }; const mapped = mapSpo2(r); expect(mapped[0]?.type).toBe("OXYGEN_SATURATION"); - expect(mapped[0]?.value).toBe(96.4); + expect(mapped[0]?.value).toBe(96); expect(mapped[0]?.unit).toBe("%"); + expect(mapped[0]?.measuredAt.toISOString()).toBe( + "2026-06-10T02:46:40.000Z", + ); }); it("rejects out-of-range readings", () => { - expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual( + expect(mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 })).toEqual( [], ); expect( - mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }), + mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 120 }), ).toEqual([]); }); }); describe("fetchCardioLoads", () => { - it("unwraps the cardio-loads array", async () => { + it("returns the documented top-level cardio-load array", async () => { installFetchMock([ - { status: 200, body: { "cardio-loads": [{ date: "2026-06-10" }] } }, + { status: 200, body: [{ date: "2026-06-10", cardio_load: 123.45 }] }, ]); - const r = await fetchCardioLoads("tok", "42"); + const r = await fetchCardioLoads("tok"); expect(r).toHaveLength(1); }); }); describe("fetchSpo2", () => { - it("unwraps the tests array", async () => { + it("returns the documented top-level SpO2 array", async () => { installFetchMock([ - { status: 200, body: { tests: [{ date: "2026-06-10" }] } }, + { + status: 200, + body: [{ test_time: 1_781_059_600, blood_oxygen_percent: 96 }], + }, ]); - const r = await fetchSpo2("tok", "42"); + const r = await fetchSpo2("tok"); + expect(r).toHaveLength(1); + }); +}); + +describe("fetchActivities", () => { + it("returns the documented top-level activity array", async () => { + installFetchMock([ + { + status: 200, + body: [ + { + start_time: "2026-06-10T00:00:00", + active_calories: 540.6, + steps: 8123, + }, + ], + }, + ]); + const r = await fetchActivities("tok"); expect(r).toHaveLength(1); }); }); @@ -433,9 +512,9 @@ describe("fetchSpo2", () => { describe("mapActivity", () => { it("maps steps and active energy to canonical MeasurementType enum values", () => { const a: PolarActivity = { - date: "2026-06-10", - "active-steps": 8123, - "active-calories": 540.6, + start_time: "2026-06-10T00:00:00", + steps: 8123, + active_calories: 540.6, calories: 2400, }; const mapped = mapActivity(a); @@ -445,6 +524,9 @@ describe("mapActivity", () => { 540.6, ); }); + it("ignores an activity when Polar omits its optional start time", () => { + expect(mapActivity({})).toEqual([]); + }); }); describe("POLAR_OAUTH_SCOPE", () => { diff --git a/src/lib/polar/__tests__/sync.test.ts b/src/lib/polar/__tests__/sync.test.ts index b80055b53..9b717ee72 100644 --- a/src/lib/polar/__tests__/sync.test.ts +++ b/src/lib/polar/__tests__/sync.test.ts @@ -168,6 +168,18 @@ describe("syncUserPolar", () => { expect(recordSuccessMock).not.toHaveBeenCalled(); }); + it("authenticates all collection reads with the access token only", async () => { + getConnMock.mockResolvedValue(CONN); + + await syncUserPolar("u1"); + + expect(fetchRechargesMock).toHaveBeenCalledWith("tok"); + expect(fetchSleepsMock).toHaveBeenCalledWith("tok"); + expect(fetchActivitiesMock).toHaveBeenCalledWith("tok"); + expect(fetchCardioLoadsMock).toHaveBeenCalledWith("tok"); + expect(fetchSpo2Mock).toHaveBeenCalledWith("tok"); + }); + it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => { getConnMock.mockResolvedValue(CONN); fetchRechargesMock.mockResolvedValue([ diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 21f5c3fdf..215b984e1 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -13,9 +13,9 @@ * - Polar access tokens DO NOT expire and there is NO refresh token. The * `User.polarRefreshTokenEncrypted` column stays null; `getValidToken` * never refreshes. A revoked grant surfaces as a 401 on the next data read. - * - The token response carries `x_user_id` — Polar's numeric member id — which - * every data path needs (`/v3/users/{userId}/...`). It is persisted as - * `User.polarUserIdEncrypted` at connect time. + * - The token response carries `x_user_id` — Polar's numeric member id. It + * remains necessary for user registration and direct user-information + * lookups, while collection reads identify the user from the access token. * - The client must register the user once (`POST /v3/users`) before data * reads work; a 409 from registration means "already registered" and is * treated as success. @@ -198,11 +198,11 @@ export interface PolarSleep { heart_rate_samples?: Record; } -/** One Polar daily-activity summary. */ +/** One current AccessLink daily-activity summary. */ export interface PolarActivity { - date: string; - "active-calories"?: number | null; - "active-steps"?: number | null; + start_time?: string; + active_calories?: number | null; + steps?: number | null; calories?: number | null; /** Estimated walking/running distance derived from steps, in METRES. */ distance_from_steps?: number | null; @@ -223,11 +223,11 @@ export interface PolarCardioLoad { cardio_load_ratio?: number | null; } -/** One Polar SpO2 (Elixir pulse-ox) test result. `blood_oxygen_percentage` is - * the recorded SpO2 reading in percent (0..100). */ +/** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix + * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ export interface PolarSpo2 { - date: string; - blood_oxygen_percentage?: number | null; + test_time?: number; + blood_oxygen_percent?: number | null; } interface CollectionResult { @@ -238,7 +238,7 @@ async function fetchCollection( path: string, accessToken: string, verb: string, - recordsKey: string, + recordsKey?: string, ): Promise { const start = performance.now(); const res = await safeFetch(`${POLAR_API_BASE}${path}`, { @@ -267,73 +267,63 @@ async function fetchCollection( // 204 No Content — a window with no records. Polar returns an empty body. if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as - CollectionResult | Record | null; + | CollectionResult + | Record + | T[] + | null; if (!json) return []; + if (Array.isArray(json)) return json; + if (!recordsKey) return []; const list = (json as Record)[recordsKey]; return Array.isArray(list) ? (list as T[]) : []; } export function fetchNightlyRecharges( accessToken: string, - userId: string, ): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/nightly-recharge`, + "/v3/users/nightly-recharge", accessToken, "fetchNightlyRecharges", "recharges", ); } -export function fetchSleeps( - accessToken: string, - userId: string, -): Promise { +export function fetchSleeps(accessToken: string): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/sleep`, + "/v3/users/sleep", accessToken, "fetchSleeps", "nights", ); } -export function fetchActivities( - accessToken: string, - userId: string, -): Promise { +export function fetchActivities(accessToken: string): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/activity-transactions`, + "/v3/users/activities", accessToken, "fetchActivities", - "activity-log", ); } /** Training Load Pro collection — `cardio_load` strain figures for the recent - * window. Records live under the `cardio-loads` key. */ + * window. */ export function fetchCardioLoads( accessToken: string, - userId: string, ): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/cardio-load`, + "/v3/users/cardio-load", accessToken, "fetchCardioLoads", - "cardio-loads", ); } -/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Records - * live under the `tests` key. */ -export function fetchSpo2( - accessToken: string, - userId: string, -): Promise { +/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */ +export function fetchSpo2(accessToken: string): Promise { return fetchCollection( - `/v3/users/${encodeURIComponent(userId)}/spo2-tests`, + "/v3/users/biosensing/spo2", accessToken, "fetchSpo2", - "tests", ); } @@ -567,16 +557,15 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { return out; } -/** Map one Polar daily-activity record: `ACTIVITY_STEPS` + - * `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under - * `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the - * total `calories`, which includes BMR — matching the Fitbit active-only - * convention). */ +/** Map one current AccessLink daily-activity record: `steps` + + * `active_calories` where Polar reports them. The active energy portion is + * used instead of total `calories`, which includes BMR. */ export function mapActivity(a: PolarActivity): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(a.date); + if (typeof a.start_time !== "string") return []; + const measuredAt = canonicalDailyTimestamp(a.start_time.slice(0, 10)); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; - const steps = a["active-steps"]; + const steps = a.steps; if (typeof steps === "number" && steps >= 0) { out.push({ type: "ACTIVITY_STEPS", @@ -586,7 +575,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { fieldTag: "steps", }); } - const activeCalories = a["active-calories"]; + const activeCalories = a.active_calories; if (typeof activeCalories === "number" && activeCalories >= 0) { out.push({ type: "ACTIVE_ENERGY_BURNED", @@ -630,12 +619,13 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { ]; } -/** Map one Polar SpO2 test result: `blood_oxygen_percentage` → - * `OXYGEN_SATURATION` (percent, 0..100). */ +/** Map one Polar SpO2 test result: `blood_oxygen_percent` → + * `OXYGEN_SATURATION` at the test's exact Unix timestamp. */ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - const measuredAt = canonicalDailyTimestamp(r.date); + if (typeof r.test_time !== "number") return []; + const measuredAt = new Date(r.test_time * 1000); if (Number.isNaN(measuredAt.getTime())) return []; - const pct = r.blood_oxygen_percentage; + const pct = r.blood_oxygen_percent; if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; return [ { diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index e3a4c5283..f43c184d9 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -115,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise { try { const [recharges, sleeps, activities, cardioLoads, spo2Tests] = await Promise.all([ - fetchNightlyRecharges(conn.accessToken, conn.polarUserId), - fetchSleeps(conn.accessToken, conn.polarUserId), - fetchActivities(conn.accessToken, conn.polarUserId), - fetchCardioLoads(conn.accessToken, conn.polarUserId), - fetchSpo2(conn.accessToken, conn.polarUserId), + fetchNightlyRecharges(conn.accessToken), + fetchSleeps(conn.accessToken), + fetchActivities(conn.accessToken), + fetchCardioLoads(conn.accessToken), + fetchSpo2(conn.accessToken), ]); for (const r of recharges) { readings.push(...toUpsert(mapNightlyRecharge(r), "recharge")); From 3c5afea9129d31f89afb6eb2caef12526501f916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:30:44 +0200 Subject: [PATCH 32/35] test(documents): format PDF cleanup coverage --- src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts index d5219da8b..4e0d2cdba 100644 --- a/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf-cleanup.test.ts @@ -43,9 +43,11 @@ describe("rasterizePdf PDF.js lifecycle", () => { }); it("destroys the loading task after rendering", async () => { - await expect(rasterizePdf(Buffer.from("synthetic"))).resolves.toMatchObject({ - ok: true, - }); + await expect(rasterizePdf(Buffer.from("synthetic"))).resolves.toMatchObject( + { + ok: true, + }, + ); expect(destroyLoadingTask).toHaveBeenCalledOnce(); }); }); From 1840779dd7d3700cca574c46e26f4c75f5afa131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:36:21 +0200 Subject: [PATCH 33/35] fix(polar): align collection response fields --- src/lib/polar/__tests__/client.test.ts | 4 +++- src/lib/polar/client.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 69b2248ae..8618cd637 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -203,7 +203,7 @@ describe("mapNightlyRecharge", () => { const rec: PolarNightlyRecharge = { date: "2026-06-10", nightly_recharge_status: 6, - hrv_avg: 55, + heart_rate_variability_avg: 55, heart_rate_avg: 52.4, breathing_rate_avg: 14.2, }; @@ -448,6 +448,7 @@ describe("mapSpo2", () => { const r: PolarSpo2 = { test_time: 1_781_059_600, blood_oxygen_percent: 96, + source_device_id: "device-1", }; const mapped = mapSpo2(r); expect(mapped[0]?.type).toBe("OXYGEN_SATURATION"); @@ -456,6 +457,7 @@ describe("mapSpo2", () => { expect(mapped[0]?.measuredAt.toISOString()).toBe( "2026-06-10T02:46:40.000Z", ); + expect(mapped[0]?.externalId).toBe("spo2:1781059600:device-1"); }); it("rejects out-of-range readings", () => { expect(mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 })).toEqual( diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 215b984e1..bc488dd22 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -180,7 +180,7 @@ export interface PolarNightlyRecharge { ans_charge?: number | null; ans_charge_status?: number | null; heart_rate_avg?: number | null; - hrv_avg?: number | null; + heart_rate_variability_avg?: number | null; breathing_rate_avg?: number | null; } @@ -226,6 +226,7 @@ export interface PolarCardioLoad { /** One Polar SpO2 (Elixir pulse-ox) test result. `test_time` is a Unix * timestamp in seconds; `blood_oxygen_percent` is the recorded percentage. */ export interface PolarSpo2 { + source_device_id?: string; test_time?: number; blood_oxygen_percent?: number | null; } @@ -395,10 +396,13 @@ export function mapNightlyRecharge( fieldTag: "recovery", }); } - if (typeof r.hrv_avg === "number" && r.hrv_avg > 0) { + if ( + typeof r.heart_rate_variability_avg === "number" && + r.heart_rate_variability_avg > 0 + ) { out.push({ type: "HRV_RMSSD", - value: round2(r.hrv_avg), + value: round2(r.heart_rate_variability_avg), unit: "ms", measuredAt, fieldTag: "hrv_rmssd", @@ -634,6 +638,7 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { unit: "%", measuredAt, fieldTag: "spo2", + externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`, }, ]; } From 00a8c81c8d97cb2293fdb0f971edeea62e9bc6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:40:09 +0200 Subject: [PATCH 34/35] docs: include Polar and dependency fixes --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cfc1ac3..e193d2a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ registrations merge transactionally without crossing account boundaries, refresh-token bindings follow the surviving device, and mood edits preserve rated factors unless the caller explicitly replaces them. +- **Polar sync follows the current AccessLink collection API.** Collection + requests no longer include the obsolete user ID path segment, and current + activity, Nightly Recharge, cardio-load, sleep, and SpO2 response shapes are + normalized without losing stable measurement identity. +- **The July dependency bundle is included.** PDF.js 6 now releases its loading + task correctly; Canvas 1.0, Radix UI 1.6.2, Tailwind CSS 4.3.3, the Tailwind + Prettier plugin 0.8.1, and setup-node 7 are integrated. No migrations. No breaking changes. From ad5806c53ae37f86fae8755fec780b46d82acf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 14:43:54 +0200 Subject: [PATCH 35/35] chore: format Polar client fix --- src/lib/polar/__tests__/client.test.ts | 6 +++--- src/lib/polar/client.ts | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 8618cd637..52179f387 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -460,9 +460,9 @@ describe("mapSpo2", () => { expect(mapped[0]?.externalId).toBe("spo2:1781059600:device-1"); }); it("rejects out-of-range readings", () => { - expect(mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 })).toEqual( - [], - ); + expect( + mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 0 }), + ).toEqual([]); expect( mapSpo2({ test_time: 1_781_059_600, blood_oxygen_percent: 120 }), ).toEqual([]); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index bc488dd22..fe41c1ca6 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -268,10 +268,7 @@ async function fetchCollection( // 204 No Content — a window with no records. Polar returns an empty body. if (res.status === 204) return []; const json = (await res.json().catch(() => null)) as - | CollectionResult - | Record - | T[] - | null; + CollectionResult | Record | T[] | null; if (!json) return []; if (Array.isArray(json)) return json; if (!recordsKey) return [];