mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #569 from MBombeck/release/v1.31.2-data-integrity
release: HealthLog v1.31.2 data integrity hardening
This commit is contained in:
92 files changed
+10028
-2748
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
## [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 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
|
||||
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.
|
||||
|
||||
## [1.31.1] — 2026-07-19
|
||||
|
||||
- **Arrival events now follow committed inserts across every writer.** Provider
|
||||
|
||||
+10
-4
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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…",
|
||||
|
||||
+1
-1
@@ -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…",
|
||||
|
||||
+5
-5
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
Generated
+532
-390
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
|
||||
+130
-100
@@ -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,139 @@ 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" } },
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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,18 @@ 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(prisma.moodEntry.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 {
|
||||
@@ -78,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", () => {
|
||||
@@ -234,7 +250,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 +409,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 +592,574 @@ 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<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function measurement(value = 80): Record<string, unknown> {
|
||||
return {
|
||||
type: "WEIGHT",
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function moodEntry(): Record<string, unknown> {
|
||||
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("resumes a partially committed legacy mood import without duplicates", async () => {
|
||||
type LegacyMoodWrite = { date: string; moodLoggedAt: Date };
|
||||
|
||||
const committedKeys = new Set<string>();
|
||||
let writeAttempt = 0;
|
||||
const writeMood = async (
|
||||
data: LegacyMoodWrite,
|
||||
target: Set<string>,
|
||||
): Promise<never> => {
|
||||
writeAttempt++;
|
||||
if (writeAttempt === 2) {
|
||||
throw new Error("transient write failure");
|
||||
}
|
||||
|
||||
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
|
||||
if (target.has(key)) {
|
||||
throw new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["userId", "date", "moodLoggedAt"] },
|
||||
},
|
||||
);
|
||||
}
|
||||
target.add(key);
|
||||
return {} as never;
|
||||
};
|
||||
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) =>
|
||||
writeMood(data as LegacyMoodWrite, committedKeys) as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
|
||||
({ 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) as never;
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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: 1, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
expect(committedKeys.size).toBe(2);
|
||||
});
|
||||
|
||||
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<string>();
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) => {
|
||||
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"] },
|
||||
},
|
||||
),
|
||||
) as never;
|
||||
}
|
||||
committedKeys.add(key);
|
||||
return Promise.resolve({} as never) 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("anchors a date-only mood row to its recorded day", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
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-01", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
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"),
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeLessThan(
|
||||
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(new Date(moodLoggedAt).getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-05-10T22:00:00.000Z"),
|
||||
);
|
||||
expect(new Date(moodLoggedAt).getTime()).toBeLessThan(
|
||||
Date.parse("2026-05-10T22:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves persisted and repeated-row fallback timestamp collisions", async () => {
|
||||
type StoredMood = {
|
||||
date: string;
|
||||
mood: string;
|
||||
score: number;
|
||||
tags: string | null;
|
||||
externalId?: string | null;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
|
||||
const persisted = new Map<string, StoredMood>();
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) => {
|
||||
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"] },
|
||||
},
|
||||
),
|
||||
) as never;
|
||||
}
|
||||
persisted.set(key, { ...data, moodLoggedAt } as StoredMood);
|
||||
return Promise.resolve({} as never) as never;
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
|
||||
({ 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;
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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: [
|
||||
{ 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 () => {
|
||||
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"),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
+235
-89
@@ -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 {
|
||||
@@ -27,6 +32,9 @@ 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";
|
||||
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
|
||||
@@ -59,22 +67,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(),
|
||||
});
|
||||
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}$/),
|
||||
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 = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart || !isPlausibleEntryInstant(dateStart)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["date"],
|
||||
message: "Mood date must be between 1900 and today",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type MoodEntryInput = z.infer<typeof moodEntrySchema>;
|
||||
|
||||
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,
|
||||
probe: number,
|
||||
): Date {
|
||||
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 dateStart = fallbackMoodDateStart(entry.date);
|
||||
if (!dateStart) throw new Error("Invalid mood date");
|
||||
return new Date(dateStart.getTime() + offset);
|
||||
}
|
||||
|
||||
const importSchema = z.object({
|
||||
measurements: z.array(measurementSchema).max(10000).optional(),
|
||||
@@ -113,6 +170,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 +222,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 +271,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,59 +295,122 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
).catch(() => {});
|
||||
|
||||
// Import mood entries
|
||||
if (data.moodEntries?.length) {
|
||||
if (!writeFailed && data.moodEntries?.length) {
|
||||
const fallbackOccurrences = new Map<string, number>();
|
||||
const reservedFallbackInstants = new Set<number>();
|
||||
for (const e of data.moodEntries) {
|
||||
try {
|
||||
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
|
||||
// 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: {
|
||||
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
|
||||
const occurrence =
|
||||
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
|
||||
if (identity !== null) {
|
||||
fallbackOccurrences.set(identity, occurrence + 1);
|
||||
}
|
||||
|
||||
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())) {
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!e.loggedAt) {
|
||||
reservedFallbackInstants.add(loggedAt.getTime());
|
||||
}
|
||||
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
|
||||
) {
|
||||
reservedFallbackInstants.add(loggedAt.getTime());
|
||||
stats.skipped++;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (probe === ENTRY_INSTANT_CLOCK_SKEW_MS - 1) {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
stats.moodEntries++;
|
||||
} catch {
|
||||
// Unique constraint violation — skip duplicate
|
||||
stats.skipped++;
|
||||
}
|
||||
if (writeFailed) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,13 +455,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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -31,6 +31,64 @@ 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<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
updateMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown> & {
|
||||
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 +217,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 +232,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 +257,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 +300,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 +492,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 +545,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);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,60 @@ 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<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
updateMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown> & {
|
||||
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 +244,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),
|
||||
]);
|
||||
|
||||
@@ -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,28 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: async (
|
||||
tx: {
|
||||
measurement: {
|
||||
createManyAndReturn: (args: {
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
};
|
||||
},
|
||||
input: Record<string, unknown>,
|
||||
) => {
|
||||
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 +142,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}`),
|
||||
|
||||
@@ -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,10 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
|
||||
* 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 +372,20 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
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 +458,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
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<string, number>();
|
||||
for (const p of prepared) {
|
||||
if (!isStatsExternalId(p.row.externalId as string)) continue;
|
||||
@@ -508,153 +469,101 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
const insertKeys = new Set<string>();
|
||||
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
|
||||
) {
|
||||
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 += 1;
|
||||
duplicateCount++;
|
||||
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;
|
||||
}
|
||||
} 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.
|
||||
|
||||
if (!statsRow && hasSameReadingSibling(p)) {
|
||||
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 (isMergeableSource(p.row.source as string)) {
|
||||
inBatchCandidates.push({
|
||||
duplicateCount++;
|
||||
crossSourceMergedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "updated":
|
||||
case "resurrected":
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
updatedCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
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 (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<number>();
|
||||
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<string, Prepared>();
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +589,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
failed: failedCount,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -760,6 +670,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
updated: updatedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
failed: failedCount,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -784,15 +695,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// 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 +754,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,15 @@
|
||||
*/
|
||||
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 +21,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<typeof TagLinksModule>()),
|
||||
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 +61,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 +106,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 +188,158 @@ 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<string, unknown> }) => {
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
/<MoodTagPicker[\s\S]*ratedFactors=\{editRatedFactors\}[\s\S]*onRateFactor=\{rateEditFactor\}/,
|
||||
);
|
||||
expect(listSrc).toContain("ratedFactors: editRatedFactors,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(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 = {}) {
|
||||
<MoodTagPicker
|
||||
selected={editTagKeys}
|
||||
onToggle={toggleEditTagKey}
|
||||
ratedFactors={editRatedFactors}
|
||||
onRateFactor={rateEditFactor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
-5
@@ -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<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
|
||||
userId: string,
|
||||
options: CycleBackupOptions = {},
|
||||
): Promise<CycleBackupSection> {
|
||||
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<string, string>();
|
||||
const restoredCycleIds = new Set<string>();
|
||||
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: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,9 @@ interface PdfDocument {
|
||||
numPages: number;
|
||||
canvasFactory: PdfCanvasFactory;
|
||||
getPage(pageNumber: number): Promise<PdfPage>;
|
||||
}
|
||||
interface PdfLoadingTask {
|
||||
promise: Promise<PdfDocument>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
interface PdfjsModule {
|
||||
@@ -110,7 +113,7 @@ interface PdfjsModule {
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
}): { promise: Promise<PdfDocument> };
|
||||
}): PdfLoadingTask;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +128,7 @@ export async function rasterizePdf(
|
||||
maxPages: number = RASTER_MAX_PAGES,
|
||||
): Promise<RasterResult> {
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
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(),
|
||||
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");
|
||||
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: BigInt("9876543210"),
|
||||
};
|
||||
|
||||
function makePrisma() {
|
||||
return {
|
||||
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([
|
||||
{
|
||||
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() {
|
||||
void measurementNote;
|
||||
mocks.findMeasurementsPaged.mockResolvedValue([
|
||||
{
|
||||
id: "measurement-tombstone",
|
||||
type: "SLEEP_DURATION",
|
||||
value: 75,
|
||||
valueMin: 60,
|
||||
valueMax: 90,
|
||||
unit: "minutes",
|
||||
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
|
||||
source: "IMPORT",
|
||||
notes: 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({
|
||||
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: "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",
|
||||
externalId: "moodlog-42",
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
factors: [{ key: "sleep_quality", rating: 4 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -35,84 +36,189 @@ 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<FullBackupResult> {
|
||||
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,
|
||||
{ userId, deletedAt: null },
|
||||
{
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
},
|
||||
),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
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: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
buildCycleBackupSection(prisma, userId),
|
||||
buildRecordsBackupSection(prisma, userId),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
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: new Date().toISOString(),
|
||||
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
|
||||
userId,
|
||||
measurements: measurements.map((m) => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: readNote(m.notesEncrypted, m.notes),
|
||||
})),
|
||||
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,
|
||||
@@ -120,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,
|
||||
@@ -127,12 +250,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,
|
||||
|
||||
@@ -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;
|
||||
@@ -70,11 +57,20 @@ 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 {
|
||||
/** Present in canonical DR payloads; name remains the natural key. */
|
||||
id?: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
lowerBound: number | null;
|
||||
@@ -82,9 +78,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,20 +113,70 @@ 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;
|
||||
}
|
||||
|
||||
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<IllnessEpisodeDTO & { dayLogs: IllnessDayLogDTO[] }>;
|
||||
allergies: AllergyDTO[];
|
||||
illnessEpisodes: CanonicalIllnessEpisode[];
|
||||
allergies: CanonicalAllergy[];
|
||||
familyHistory: FamilyHistoryEntryDTO[];
|
||||
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 +208,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 +223,52 @@ export async function buildRecordsBackupSection(
|
||||
| "inboundDocument"
|
||||
>,
|
||||
userId: string,
|
||||
options: RecordsBackupOptions = {},
|
||||
): Promise<RecordsBackupSection> {
|
||||
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,
|
||||
@@ -184,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 } } },
|
||||
}),
|
||||
@@ -193,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({
|
||||
@@ -215,26 +310,23 @@ 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,
|
||||
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,
|
||||
@@ -245,10 +337,16 @@ 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) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: b.id,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
updatedAt: b.updatedAt.toISOString(),
|
||||
}
|
||||
: {}),
|
||||
name: b.name,
|
||||
unit: b.unit,
|
||||
lowerBound: b.lowerBound,
|
||||
@@ -258,15 +356,79 @@ 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) => ({
|
||||
...(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 +445,47 @@ 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 +496,12 @@ 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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Prisma.TransactionClient, "$queryRaw">,
|
||||
provider: SerializedRefreshProvider,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const key = `provider-token-refresh:${provider}:${userId}`;
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended(${key}, 0))
|
||||
`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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<number>
|
||||
>(),
|
||||
}));
|
||||
|
||||
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<void>,
|
||||
) =>
|
||||
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<WithingsEcgSyncPayload> {
|
||||
return { data } as Job<WithingsEcgSyncPayload>;
|
||||
}
|
||||
|
||||
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<WithingsEcgSyncPayload>\([\s\S]*?WITHINGS_ECG_SYNC_QUEUE[\s\S]*?handleWithingsEcgSync/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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++;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildFullBackupPayload: vi.fn(),
|
||||
encrypt: vi.fn((value: string) => value),
|
||||
getWorkerPrisma: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/export/full-backup-payload", () => ({
|
||||
buildFullBackupPayload: mocks.buildFullBackupPayload,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt }));
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: vi.fn(
|
||||
async (_name: string, run: (event: object) => Promise<void>) =>
|
||||
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" }]),
|
||||
},
|
||||
dataBackup: { upsert: mocks.upsert },
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleDataBackup canonical DR payload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getWorkerPrisma.mockReturnValue(buildPrismaMock());
|
||||
mocks.upsert.mockResolvedValue({});
|
||||
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("serializes the shared canonical disaster-recovery payload", async () => {
|
||||
await handleDataBackup([]);
|
||||
|
||||
const prisma = mocks.getWorkerPrisma.mock.results[0]!.value;
|
||||
expect(mocks.buildFullBackupPayload).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
"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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,123 +58,10 @@ export async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
|
||||
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" },
|
||||
}),
|
||||
// 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),
|
||||
]);
|
||||
|
||||
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) => ({
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags,
|
||||
source: e.source,
|
||||
loggedAt: e.moodLoggedAt.toISOString(),
|
||||
})),
|
||||
// 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).
|
||||
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);
|
||||
|
||||
@@ -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<WithingsEcgSyncPayload>(
|
||||
WITHINGS_ECG_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsEcgSync,
|
||||
);
|
||||
await boss.work<WithingsOAuthStateCleanupPayload>(
|
||||
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
|
||||
@@ -11,8 +11,11 @@ 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 +99,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<WithingsEcgSyncPayload>[],
|
||||
) {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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> = {},
|
||||
): 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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown>[]) {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Measurement, "measuredAt" | "sleepStage">,
|
||||
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<void> {
|
||||
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<
|
||||
Array<PrismaTypes.MeasurementGetPayload<{ select: typeof rowSelect }>>
|
||||
> {
|
||||
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<void> {
|
||||
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<MeasurementReconciliationVerdict> {
|
||||
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;
|
||||
void _ownerId;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,32 @@ 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", () => {
|
||||
|
||||
+45
-26
@@ -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<string[]> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
+19
-55
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncSuccess: recordSuccessMock,
|
||||
@@ -129,6 +139,25 @@ 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 +197,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 +356,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 +437,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 +450,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 +636,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 +655,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 +671,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]]);
|
||||
|
||||
|
||||
+43
-66
@@ -27,9 +27,9 @@
|
||||
* Idempotency: `externalId = <resource>:<day>:<fieldTag>` for the day-keyed
|
||||
* collections; sleep rows carry a record-scoped `sleep:<record-id>:<fieldTag>`
|
||||
* 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,49 @@ 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<string, number>();
|
||||
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: {
|
||||
const verdicts = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const outcomes = [];
|
||||
for (const r of readings) {
|
||||
const verdict = await reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
type: r.type as MeasurementType,
|
||||
source: "OURA",
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
externalId: r.externalId,
|
||||
sleepStage: r.sleepStage ?? null,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
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");
|
||||
});
|
||||
@@ -152,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,
|
||||
};
|
||||
@@ -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,69 @@ 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,
|
||||
source_device_id: "device-1",
|
||||
};
|
||||
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",
|
||||
);
|
||||
expect(mapped[0]?.externalId).toBe("spo2:1781059600:device-1");
|
||||
});
|
||||
it("rejects out-of-range readings", () => {
|
||||
expect(mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 0 })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
mapSpo2({ date: "2026-06-10", blood_oxygen_percentage: 120 }),
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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 +514,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 +526,9 @@ describe("mapActivity", () => {
|
||||
540.6,
|
||||
);
|
||||
});
|
||||
it("ignores an activity when Polar omits its optional start time", () => {
|
||||
expect(mapActivity({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POLAR_OAUTH_SCOPE", () => {
|
||||
|
||||
@@ -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<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncSuccess: recordSuccessMock,
|
||||
@@ -93,6 +103,25 @@ 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 +157,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", () => {
|
||||
@@ -139,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([
|
||||
@@ -245,7 +286,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 +304,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 +379,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 +398,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 +414,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]]);
|
||||
|
||||
|
||||
+45
-53
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ export interface PolarSleep {
|
||||
heart_rate_samples?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 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,12 @@ 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;
|
||||
source_device_id?: string;
|
||||
test_time?: number;
|
||||
blood_oxygen_percent?: number | null;
|
||||
}
|
||||
|
||||
interface CollectionResult<T> {
|
||||
@@ -238,7 +239,7 @@ async function fetchCollection<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
verb: string,
|
||||
recordsKey: string,
|
||||
recordsKey?: string,
|
||||
): Promise<T[]> {
|
||||
const start = performance.now();
|
||||
const res = await safeFetch(`${POLAR_API_BASE}${path}`, {
|
||||
@@ -267,73 +268,60 @@ async function fetchCollection<T>(
|
||||
// 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<T> | Record<string, T[]> | null;
|
||||
CollectionResult<T> | Record<string, T[]> | T[] | null;
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json;
|
||||
if (!recordsKey) return [];
|
||||
const list = (json as Record<string, unknown>)[recordsKey];
|
||||
return Array.isArray(list) ? (list as T[]) : [];
|
||||
}
|
||||
|
||||
export function fetchNightlyRecharges(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarNightlyRecharge[]> {
|
||||
return fetchCollection<PolarNightlyRecharge>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
|
||||
"/v3/users/nightly-recharge",
|
||||
accessToken,
|
||||
"fetchNightlyRecharges",
|
||||
"recharges",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchSleeps(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarSleep[]> {
|
||||
export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
|
||||
return fetchCollection<PolarSleep>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/sleep`,
|
||||
"/v3/users/sleep",
|
||||
accessToken,
|
||||
"fetchSleeps",
|
||||
"nights",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchActivities(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
): Promise<PolarActivity[]> {
|
||||
export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
|
||||
return fetchCollection<PolarActivity>(
|
||||
`/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<PolarCardioLoad[]> {
|
||||
return fetchCollection<PolarCardioLoad>(
|
||||
`/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<PolarSpo2[]> {
|
||||
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */
|
||||
export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
|
||||
return fetchCollection<PolarSpo2>(
|
||||
`/v3/users/${encodeURIComponent(userId)}/spo2-tests`,
|
||||
"/v3/users/biosensing/spo2",
|
||||
accessToken,
|
||||
"fetchSpo2",
|
||||
"tests",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,10 +393,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",
|
||||
@@ -567,16 +558,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 +576,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 +620,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 [
|
||||
{
|
||||
@@ -644,6 +635,7 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "spo2",
|
||||
externalId: `spo2:${r.test_time}:${r.source_device_id ?? "unknown"}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
+52
-74
@@ -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 `<date>:<fieldTag>` 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 `<date>:<fieldTag>`. 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,
|
||||
@@ -111,11 +115,11 @@ export async function syncUserPolar(userId: string): Promise<number> {
|
||||
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"));
|
||||
@@ -187,8 +191,9 @@ export async function syncUserPolar(userId: string): Promise<number> {
|
||||
/**
|
||||
* 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,49 @@ 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<string, number>();
|
||||
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: {
|
||||
const verdicts = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const outcomes = [];
|
||||
for (const r of readings) {
|
||||
const verdict = await reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
type: r.type as MeasurementType,
|
||||
source: "POLAR",
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
externalId: r.externalId,
|
||||
sleepStage: r.sleepStage ?? null,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, StravaWorkoutRow>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
+49
-27
@@ -68,6 +68,22 @@ export function classifyStravaFailure(err: unknown): FailureKind {
|
||||
return toFailureKind(classifyStravaError(err));
|
||||
}
|
||||
|
||||
async function recordStravaFailure(
|
||||
userId: string,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
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 +214,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 +252,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 +264,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 +309,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 } },
|
||||
|
||||
@@ -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",
|
||||
@@ -383,3 +388,181 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
|
||||
expect(summary.documents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
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<string | number>]> = [
|
||||
["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<PropertyKey, unknown> = candidate;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
cursor = cursor[segment] as Record<PropertyKey, unknown>;
|
||||
}
|
||||
cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM";
|
||||
|
||||
const parsed = backupPayloadSchema.safeParse(candidate);
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
+271
-51
@@ -20,8 +20,42 @@
|
||||
* a useful summary for them.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
AllergyCategory,
|
||||
AllergySeverity,
|
||||
AllergyStatus,
|
||||
AllergyType,
|
||||
CervicalMucus,
|
||||
CervixFirmness,
|
||||
CervixOpening,
|
||||
CervixPosition,
|
||||
ContraceptiveKind,
|
||||
CycleTrackingGoal,
|
||||
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()
|
||||
@@ -30,42 +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({
|
||||
type: z.string().min(1),
|
||||
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.string().min(1).optional(),
|
||||
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(),
|
||||
source: z.string().min(1).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();
|
||||
|
||||
@@ -97,14 +201,25 @@ 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,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
factors: z.array(moodFactorSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -118,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();
|
||||
|
||||
@@ -137,26 +258,32 @@ const cycleSpanSchema = z
|
||||
*/
|
||||
const cycleDayLogSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
date: z.string().min(1),
|
||||
flow: z.string().nullable().optional(),
|
||||
cycleId: 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(),
|
||||
syncVersion: z.number().int().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
symptomKeys: z.array(z.string()).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -164,35 +291,79 @@ const cycleDayLogSchema = z
|
||||
/** Cycle-tracking preferences (one row per user). */
|
||||
const cycleProfileSchema = z
|
||||
.object({
|
||||
goal: z.string().min(1).optional(),
|
||||
id: 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(),
|
||||
sensitiveCategoryEncryption: z.boolean().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/* ── v1.28 backup-completeness shapes ──────────────────────────────
|
||||
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();
|
||||
|
||||
/* ── 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(),
|
||||
@@ -203,12 +374,18 @@ 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();
|
||||
|
||||
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 +393,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();
|
||||
|
||||
@@ -236,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();
|
||||
|
||||
@@ -243,8 +426,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
|
||||
@@ -252,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([]),
|
||||
})
|
||||
@@ -261,14 +446,17 @@ 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(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
reactionEncrypted: base64BytesSchema.nullable().optional(),
|
||||
notesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -276,7 +464,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(),
|
||||
@@ -287,6 +475,7 @@ const familyHistoryBackupSchema = z
|
||||
|
||||
const workoutBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
sportType: z.string().min(1),
|
||||
startedAt: isoDateTime,
|
||||
endedAt: isoDateTime,
|
||||
@@ -299,24 +488,39 @@ 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(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
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(),
|
||||
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.enum(DocumentSummaryState).optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -338,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([]),
|
||||
@@ -350,9 +555,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([]),
|
||||
@@ -362,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<typeof backupPayloadSchema>;
|
||||
|
||||
@@ -383,21 +600,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;
|
||||
}
|
||||
|
||||
@@ -449,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
|
||||
);
|
||||
}
|
||||
@@ -9,24 +9,33 @@
|
||||
*/
|
||||
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<(...args: unknown[]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: (...args: unknown[]) =>
|
||||
morningRefreshMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -92,6 +101,7 @@ beforeEach(() => {
|
||||
updateManyMock.mockClear().mockResolvedValue({ count: 0 });
|
||||
upsertMeasurementsMock.mockClear();
|
||||
fetchSleepsMock.mockReset();
|
||||
morningRefreshMock.mockClear();
|
||||
});
|
||||
|
||||
describe("syncUserSleep — record-scoped stale-segment sweep", () => {
|
||||
@@ -155,4 +165,72 @@ describe("syncUserSleep — record-scoped stale-segment sweep", () => {
|
||||
await expect(syncUserSleep("user-1")).resolves.toBe(1);
|
||||
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");
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,9 @@ const {
|
||||
recordSyncSuccess,
|
||||
isReauthRequired,
|
||||
emitArrivalMock,
|
||||
reconcileMock,
|
||||
recomputeBucketsMock,
|
||||
invalidateStatusMock,
|
||||
} = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
whoopConnection: {
|
||||
@@ -30,6 +33,8 @@ const {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
refreshAccessTokenMock: vi.fn(),
|
||||
fetchRecoveriesMock: vi.fn(),
|
||||
@@ -38,7 +43,14 @@ const {
|
||||
isReauthRequired: vi.fn<(...a: unknown[]) => Promise<boolean>>(
|
||||
async () => false,
|
||||
),
|
||||
reconcileMock: vi.fn(),
|
||||
emitArrivalMock: vi.fn(),
|
||||
recomputeBucketsMock: vi.fn<(...a: unknown[]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
invalidateStatusMock: vi.fn<(...a: unknown[]) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
|
||||
@@ -57,6 +69,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",
|
||||
@@ -73,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", () => ({
|
||||
@@ -105,6 +124,23 @@ import { syncUserRecovery } from "../sync-recovery";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.$transaction.mockImplementation(
|
||||
async (run: (tx: unknown) => unknown) => run(prismaMock),
|
||||
);
|
||||
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);
|
||||
});
|
||||
@@ -128,7 +164,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");
|
||||
|
||||
@@ -137,15 +173,14 @@ 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",
|
||||
@@ -154,20 +189,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 () => {
|
||||
@@ -371,8 +412,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,16 +431,19 @@ 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",
|
||||
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 () => {
|
||||
@@ -403,7 +456,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 +465,208 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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("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<string, StoredRow> };
|
||||
|
||||
let committedRows = new Map<string, StoredRow>();
|
||||
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();
|
||||
expect(recomputeBucketsMock).toHaveBeenCalled();
|
||||
expect(invalidateStatusMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
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(invalidateStatusMock).toHaveBeenCalledTimes(2);
|
||||
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);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+33
-24
@@ -40,6 +40,14 @@ import {
|
||||
} from "./sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function enqueueMorningRefreshForMeasuredAts(
|
||||
userId: string,
|
||||
measuredAts: Date[],
|
||||
): void {
|
||||
if (measuredAts.length === 0) return;
|
||||
void maybeEnqueueMorningRefresh(userId, measuredAts).catch(() => {});
|
||||
}
|
||||
|
||||
export async function syncUserSleep(
|
||||
userId: string,
|
||||
opts: { fullSync?: boolean } = {},
|
||||
@@ -94,21 +102,26 @@ 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 imported = await upsertWhoopMeasurements(userId, readings, {
|
||||
onInserted: (rows) => {
|
||||
insertedSleepMeasuredAts = rows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt);
|
||||
},
|
||||
});
|
||||
// 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");
|
||||
|
||||
// S4 — trigger the debounced morning refresh on a last-night segment landing.
|
||||
void maybeEnqueueMorningRefresh(userId, insertedSleepMeasuredAts).catch(
|
||||
() => {},
|
||||
);
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
@@ -156,20 +169,16 @@ export async function syncWhoopSleepById(
|
||||
{ prefix: `${record.id}:`, keepIds },
|
||||
]);
|
||||
|
||||
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),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 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(
|
||||
() => {},
|
||||
);
|
||||
|
||||
enqueueMorningRefreshForMeasuredAts(userId, insertedSleepMeasuredAts);
|
||||
return imported;
|
||||
}
|
||||
+181
-180
@@ -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,14 @@ 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,
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
import { refreshAccessToken } from "./client";
|
||||
import { getUserWhoopCredentials } from "./credentials";
|
||||
import {
|
||||
@@ -198,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,11 +323,13 @@ 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). Idempotent: the `(userId, type, source, externalId)`
|
||||
* unique key makes a re-post (WHOOP re-score) overwrite in place. Returns the
|
||||
* 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.
|
||||
*
|
||||
* Best-effort on the rollup fold + insight invalidate — a populator hiccup
|
||||
@@ -341,103 +345,100 @@ export async function upsertWhoopMeasurements(
|
||||
if (readings.length === 0) return 0;
|
||||
|
||||
let imported = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
let insertedRows: Array<
|
||||
InsertedMeasurementArrivalRow & { externalId: string | null }
|
||||
> = [];
|
||||
const touchedTypes = new Set<MeasurementType>();
|
||||
const invalidateTouchedTypes = (): void => {
|
||||
if (touchedTypes.size === 0) return;
|
||||
|
||||
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,
|
||||
void invalidateStatusInsightsForTypes(userId, [...touchedTypes]).catch(
|
||||
(err) => {
|
||||
getEvent()?.addWarning(
|
||||
`whoop: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
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<string, number>();
|
||||
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: {
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
opts.onInserted?.(insertedRows);
|
||||
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) => {
|
||||
getEvent()?.addWarning(
|
||||
`whoop: status-insight invalidate failed for ${userId}: ${err}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`whoop: rollup recompute failed for ${userId}: ${err}`,
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
return outcomes;
|
||||
},
|
||||
{ maxWait: 10_000, timeout: 60_000 },
|
||||
)
|
||||
.catch((err) => {
|
||||
invalidateTouchedTypes();
|
||||
throw err;
|
||||
});
|
||||
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
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);
|
||||
touchedTypes.add(dirty.type);
|
||||
}
|
||||
const type = reading.type as MeasurementType;
|
||||
touchedTypes.add(type);
|
||||
imported++;
|
||||
touched.push({ type, measuredAt: reading.measuredAt });
|
||||
if (verdict.status === "inserted") {
|
||||
insertedRows.push(verdict.row);
|
||||
}
|
||||
}
|
||||
|
||||
opts.onInserted?.(insertedRows);
|
||||
void emitInsertedMeasurementArrivals(userId, insertedRows, "whoop").catch(
|
||||
() => {},
|
||||
);
|
||||
try {
|
||||
const keys = collapseToTypeDayKeys(touched);
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`whoop: rollup recompute failed for ${userId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
invalidateTouchedTypes();
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,23 @@ 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 +277,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 +295,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 +312,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 +320,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 +351,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<string, unknown>;
|
||||
}) => {
|
||||
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 +369,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 +392,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,28 +492,20 @@ 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,
|
||||
},
|
||||
];
|
||||
return [row.type, { externalId: row.externalId, value: row.value }];
|
||||
}),
|
||||
);
|
||||
expect(byType.get("RESTING_HEART_RATE")).toEqual({
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<number> {
|
||||
export async function syncUserEcg(
|
||||
userId: string,
|
||||
options: { startdate?: number; enddate?: number } = {},
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
}
|
||||
|
||||
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<number> {
|
||||
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<number> {
|
||||
getEvent()?.addWarning(
|
||||
`withings ecg: failed to upsert recording (${classification}, ${measuredAt.toISOString()}): ${err}`,
|
||||
);
|
||||
sourceWriteError ??= { cause: err };
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -500,6 +525,11 @@ export async function syncUserEcg(userId: string): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceWriteError) {
|
||||
await recordWithingsSyncFailure(userId, sourceWriteError.cause);
|
||||
throw sourceWriteError.cause;
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "withings");
|
||||
return imported;
|
||||
}
|
||||
+72
-163
@@ -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,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<number, string[]>();
|
||||
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: {
|
||||
userId,
|
||||
type: SLEEP_TYPE,
|
||||
source: "WITHINGS",
|
||||
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: {
|
||||
const verdict = await reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type: SLEEP_TYPE,
|
||||
source: "WITHINGS",
|
||||
value: minutes,
|
||||
unit: getUnitForType(SLEEP_TYPE),
|
||||
measuredAt,
|
||||
source: "WITHINGS",
|
||||
sleepStage: stage,
|
||||
externalId,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
{ 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++;
|
||||
}
|
||||
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,68 +474,53 @@ 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: {
|
||||
} 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,
|
||||
externalId: `withings:sleep:${userId}:${sessionId}:${vital.fieldTag}`,
|
||||
},
|
||||
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 },
|
||||
},
|
||||
});
|
||||
{ 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++;
|
||||
} 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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
// v1.4.39.1 — refresh the persistent rollup table for every distinct
|
||||
// (type, day) the sync touched. Sleep segments collapse heavily —
|
||||
|
||||
+59
-40
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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,45 @@ 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<typeof POST>[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 +290,11 @@ describe("POST /api/admin/backups/upload", () => {
|
||||
buildMultipart(file) as unknown as Parameters<typeof POST>[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 },
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
// 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<Response>)(
|
||||
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<Response>
|
||||
)(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<Response>
|
||||
)(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<Response>
|
||||
)(
|
||||
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<Response>
|
||||
)(
|
||||
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,215 @@ 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),
|
||||
},
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -199,6 +199,159 @@ 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";
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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<typeof WhoopClientModule>()),
|
||||
refreshAccessToken: whoopRefresh,
|
||||
fetchRecoveries: whoopRecoveries,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WithingsClientModule>()),
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user