mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(api): SyncMode foundation — syncVersion column, sync-state endpoint, bulk backfill routes
R-E C-2 server-side. Three additive columns + three new endpoints
let the iOS SyncMode store pair, drain, and reconcile against a
fresh paired-server.
Schema (migration 0062, additive):
- Measurement.sync_version (Int, default 1) for last-writer-wins
- Measurement.deleted_at (Timestamp, nullable) for soft-deletes
- User.last_synced_at (Timestamp, nullable) for the handshake
checkpoint
Endpoints:
- GET /api/sync/state — handshake response (lastSyncedAt, server
clock, live + tombstoned measurement counters); call also bumps
the checkpoint so iOS reads the OLD value then trusts subsequent
writes
- POST /api/mood-entries/bulk — UPSERT per entry keyed by
(userId, date, moodLoggedAt); probe-then-upsert so the response
reliably reports inserted vs duplicate; rate-limited at 60/min
- POST /api/medications/intake/bulk — INSERT per entry with
P2002-on-idempotencyKey → duplicate; up-front user-owns-medication
check skips foreign rows with a documented reason
iOS contract: every change is additive. Existing per-sample POSTs
keep round-tripping (sync_version defaults to 1 server-side;
deleted_at stays NULL on every existing read path; last_synced_at is
opt-in). The new endpoints are net-new — no iOS consumer yet.
Tests cover the handshake checkpoint advance, live-vs-tombstoned
counters, clean batch ingest, re-post upsert semantics, ownership
filtering, idempotency-key collision, and over-cap rejection — all
against the real-Postgres container.
This commit is contained in:
1 parent
0e92f96e72
commit
0ccc00b774
8 files changed
+1073
No files matched your search
@@ -0,0 +1,31 @@
|
||||
-- v1.4.30 — iOS SyncMode foundation (R-E C-2)
|
||||
--
|
||||
-- Three additive columns the iOS app reads when it pairs with the
|
||||
-- server:
|
||||
--
|
||||
-- measurements.sync_version — monotonic version counter per row.
|
||||
-- Default = 1 for legacy rows; the
|
||||
-- server bumps it on every update so
|
||||
-- paired clients can do last-writer-
|
||||
-- wins reconciliation.
|
||||
-- measurements.deleted_at — soft-delete timestamp. The
|
||||
-- by-external-ids DELETE path flips
|
||||
-- this rather than removing rows so
|
||||
-- paired iOS clients can reconcile
|
||||
-- deletions without losing the audit
|
||||
-- trail.
|
||||
-- users.last_synced_at — last-known synchronization checkpoint
|
||||
-- the iOS SyncMode store reads via
|
||||
-- `GET /api/sync/state`.
|
||||
--
|
||||
-- All three are nullable / defaulted so existing rows + existing iOS
|
||||
-- POSTs round-trip unchanged. The `IF NOT EXISTS` guards keep the
|
||||
-- migration idempotent against partial historical runs (same posture
|
||||
-- as 0058_user_research_mode and 0061_audit_log_carrier).
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN IF NOT EXISTS "sync_version" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "last_synced_at" TIMESTAMP(3);
|
||||
@@ -181,6 +181,14 @@ model User {
|
||||
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
|
||||
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
|
||||
|
||||
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known
|
||||
// synchronization checkpoint per user. iOS reads it via
|
||||
// `GET /api/sync/state` to decide whether to drain its local
|
||||
// SwiftData backlog before opening a fresh anchored-query window.
|
||||
// NULL = never synced; the SyncMode store treats that as "force
|
||||
// a full refresh on the next pair".
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
|
||||
// v1.4.25 W6c — Doctor-report section toggles.
|
||||
// Shape:
|
||||
// { "bp": boolean,
|
||||
@@ -396,6 +404,21 @@ model Measurement {
|
||||
// include `deviceType` so two ingest paths from the same source
|
||||
// still dedup on (type, source, externalId).
|
||||
deviceType String? @map("device_type")
|
||||
// v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically
|
||||
// incremented on every server-side mutation of the row. iOS uses
|
||||
// it for last-writer-wins reconciliation when two devices write
|
||||
// the same logical reading. Default = 1 for the create path; the
|
||||
// update path is responsible for bumping it. Existing rows
|
||||
// start at the default after the additive migration so legacy
|
||||
// ingest keeps round-tripping unchanged.
|
||||
syncVersion Int @default(1) @map("sync_version")
|
||||
// v1.4.30 — soft-delete column. The DELETE path on the
|
||||
// by-external-ids endpoint flips this rather than removing the row
|
||||
// so paired iOS clients can reconcile deletions without losing the
|
||||
// audit trail. NULL = live row; non-NULL = tombstoned at the
|
||||
// recorded instant. The unique-by-externalId index keeps both
|
||||
// states distinguishable for the bulk-backfill UPSERT path.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* `POST /api/medications/intake/bulk` — iOS SyncMode bulk backfill
|
||||
* for medication intake events.
|
||||
*
|
||||
* Mirrors the mood-entries bulk endpoint shape so the iOS sync
|
||||
* engine reuses the same retry / cursor plumbing for both.
|
||||
*
|
||||
* Body:
|
||||
* { entries: BulkIntakeEntry[] } — capped at 500 per call.
|
||||
*
|
||||
* Each entry:
|
||||
* {
|
||||
* medicationId, — required (existing Medication row)
|
||||
* scheduledFor, — ISO timestamp (offset); defaults to now()
|
||||
* takenAt?, — ISO timestamp; omit + skipped=false = pending
|
||||
* skipped?, — boolean; default false
|
||||
* idempotencyKey?, — Medication.intake_events.idempotency_key
|
||||
* (existing UNIQUE column; serves as the
|
||||
* per-entry dedup hint)
|
||||
* }
|
||||
*
|
||||
* Response (always 200): processed / inserted / duplicates / skipped /
|
||||
* entries[] mirroring the measurements + workouts batch envelope.
|
||||
*
|
||||
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
|
||||
* §"Cumulative metrics" sibling section for the SyncMode rationale.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkEntrySchema = z.object({
|
||||
medicationId: z.string().min(1),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
takenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`medications:intake:bulk:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"entries" in rawBody &&
|
||||
Array.isArray((rawBody as { entries: unknown }).entries) &&
|
||||
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
|
||||
) {
|
||||
return apiError(
|
||||
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
|
||||
422,
|
||||
{ errorCode: "medications.intake.bulk.too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = bulkPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422, {
|
||||
errorCode: "medications.intake.bulk.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
|
||||
// Up-front ownership check — the user can only POST intake events
|
||||
// against medications they own. One read covers every medicationId
|
||||
// in the batch.
|
||||
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
|
||||
const ownedMedications = await prisma.medication.findMany({
|
||||
where: { id: { in: medicationIds }, userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let duplicates = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
|
||||
if (!ownedSet.has(entry.medicationId)) {
|
||||
const reason = "medication_not_found";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduledFor = entry.scheduledFor ?? new Date();
|
||||
// The idempotencyKey, when supplied, has a UNIQUE index. A
|
||||
// re-submission of the same key returns the existing row via
|
||||
// P2002 → status: "duplicate".
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
} catch (err: unknown) {
|
||||
// P2002 = unique-constraint violation; the idempotencyKey
|
||||
// already exists → "duplicate", not an error.
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2002"
|
||||
) {
|
||||
const existing = entry.idempotencyKey
|
||||
? await prisma.medicationIntakeEvent.findUnique({
|
||||
where: { idempotencyKey: entry.idempotencyKey },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
duplicates += 1;
|
||||
results.push({
|
||||
index: i,
|
||||
status: "duplicate",
|
||||
id: existing?.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("medications.intake.bulk.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* `POST /api/mood-entries/bulk` — iOS SyncMode bulk backfill.
|
||||
*
|
||||
* When iOS pairs a fresh device with the server, it drains its local
|
||||
* SwiftData mood log via this endpoint in one shot. Per-entry UPSERT
|
||||
* semantics keyed by `externalId` so re-runs are idempotent.
|
||||
*
|
||||
* Body:
|
||||
* { entries: BulkMoodEntry[] } — capped at 500 per call.
|
||||
*
|
||||
* Response (always 200):
|
||||
* {
|
||||
* processed,
|
||||
* inserted,
|
||||
* duplicates,
|
||||
* skipped: [{ index, reason }, ...],
|
||||
* entries: [{ index, status, id? }, ...]
|
||||
* }
|
||||
*
|
||||
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
|
||||
* §"Cumulative metrics" sibling section for the SyncMode rationale,
|
||||
* and `08-locked-contracts.md` §2 for the batch envelope shape.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
getScoreForMood,
|
||||
moodLevelEnum,
|
||||
moodSourceEnum,
|
||||
} from "@/lib/validations/moodlog";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkEntrySchema = z.object({
|
||||
mood: moodLevelEnum,
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
/**
|
||||
* Optional iOS-side identifier (e.g. SwiftData row UUID) that lets
|
||||
* the bulk endpoint dedup idempotently when iOS retries the same
|
||||
* batch after a network hiccup. Mirrors the `externalId` posture on
|
||||
* the measurements batch endpoint. NULL = no dedup hint; the
|
||||
* existing `(userId, date, moodLoggedAt)` unique index still
|
||||
* protects against straight-up duplicates.
|
||||
*/
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"entries" in rawBody &&
|
||||
Array.isArray((rawBody as { entries: unknown }).entries) &&
|
||||
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
|
||||
) {
|
||||
return apiError(
|
||||
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
|
||||
422,
|
||||
{ errorCode: "mood.bulk.too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = bulkPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422, {
|
||||
errorCode: "mood.bulk.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let duplicates = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const date = moodDateKey(entry.moodLoggedAt, tz);
|
||||
const score = getScoreForMood(entry.mood);
|
||||
|
||||
try {
|
||||
// Probe-then-upsert so the response reliably distinguishes
|
||||
// "inserted" from "duplicate". Two round-trips per entry is
|
||||
// acceptable given the 500-entry cap; a more cache-friendly
|
||||
// shape (batched probe) is a v1.4.31 optimisation if the cap
|
||||
// grows.
|
||||
const existing = await prisma.moodEntry.findUnique({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: user.id,
|
||||
date,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const result = await prisma.moodEntry.upsert({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: user.id,
|
||||
date,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
source: entry.source,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the mood + tags pair. The iOS client
|
||||
// only re-posts an existing entry when it has new data; the
|
||||
// server trusts that decision.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: result.id });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: result.id });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("mood.bulk.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.bulk.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `GET /api/sync/state` — iOS SyncMode handshake.
|
||||
*
|
||||
* The iOS SyncMode store reads this endpoint on pair / unpair, on
|
||||
* background-to-foreground, and before opening a fresh anchored
|
||||
* HealthKit query window. The response is a compact JSON shape iOS
|
||||
* compares against its local SwiftData state to decide whether to
|
||||
* (a) drain a pending outbox, (b) request a full refresh, or (c) sit
|
||||
* quiet.
|
||||
*
|
||||
* Response shape:
|
||||
* {
|
||||
* userId,
|
||||
* timezone,
|
||||
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
|
||||
* serverNow, — `new Date().toISOString()` for clock skew
|
||||
* measurements: {
|
||||
* lastUpdatedAt, — MAX(updated_at) for live rows
|
||||
* liveCount, — COUNT(*) WHERE deleted_at IS NULL
|
||||
* tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* No body. requireAuth() — cookie + Bearer both accepted (iOS uses
|
||||
* Bearer per `05-auth-flows.md`).
|
||||
*
|
||||
* Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md`
|
||||
* §13 cross-reference. The shape is additive going forward.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Two aggregate reads + one user-side bump. The aggregate reads run
|
||||
// against indexed columns (userId, deleted_at) so they stay
|
||||
// O(rows-for-user) and not O(rows-across-tenants).
|
||||
const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([
|
||||
prisma.measurement.findFirst({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { updatedAt: true },
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: { userId: user.id, deletedAt: { not: null } },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { lastSyncedAt: true, timezone: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// The handshake also bumps the user's lastSyncedAt — the call IS
|
||||
// the handshake. iOS reads the OLD lastSyncedAt from the response
|
||||
// and then trusts that subsequent server writes after the new
|
||||
// checkpoint will round-trip via the standard read paths.
|
||||
const previous = currentUser?.lastSyncedAt ?? null;
|
||||
const nextCheckpoint = new Date();
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastSyncedAt: nextCheckpoint },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "sync.state" },
|
||||
meta: {
|
||||
liveCount,
|
||||
tombstonedCount,
|
||||
lastSyncedAtBefore: previous?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
userId: user.id,
|
||||
timezone: currentUser?.timezone ?? "Europe/Berlin",
|
||||
lastSyncedAt: previous?.toISOString() ?? null,
|
||||
serverNow: nextCheckpoint.toISOString(),
|
||||
measurements: {
|
||||
lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null,
|
||||
liveCount,
|
||||
tombstonedCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres
|
||||
* integration.
|
||||
*
|
||||
* Asserts the iOS SyncMode bulk-backfill contract:
|
||||
* - inserts a clean batch
|
||||
* - skips entries that point at a medication the user doesn't own
|
||||
* - returns `duplicate` when an idempotencyKey is re-used
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-medications-intake-bulk";
|
||||
const OTHER_USER_ID = "user-medications-intake-other";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
let ownedMedId = "";
|
||||
let foreignMedId = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: TEST_USER_ID,
|
||||
username: "intake-bulk",
|
||||
email: "intake-bulk@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
username: "intake-other",
|
||||
email: "intake-other@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
],
|
||||
});
|
||||
const owned = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Mounjaro",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
ownedMedId = owned.id;
|
||||
const foreign = await prisma.medication.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
name: "Levothyroxin",
|
||||
dose: "50µg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
foreignMedId = foreign.id;
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/intake/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/medications/intake/bulk (real Postgres)", () => {
|
||||
it("inserts a clean batch", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
takenAt: "2026-05-16T08:02:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-17T08:00:00.000Z",
|
||||
skipped: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { processed: number; inserted: number; duplicates: number };
|
||||
};
|
||||
expect(json.data.processed).toBe(2);
|
||||
expect(json.data.inserted).toBe(2);
|
||||
|
||||
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips entries that reference a medication the user doesn't own", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: foreignMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
};
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(json.data.skipped).toEqual([
|
||||
{ index: 1, reason: "medication_not_found" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const body = {
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
idempotencyKey: "ios-sync-key-001",
|
||||
},
|
||||
],
|
||||
};
|
||||
await POST(makeRequest(body));
|
||||
const res = await POST(makeRequest(body));
|
||||
const json = (await res.json()) as {
|
||||
data: { duplicates: number; inserted: number };
|
||||
};
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(json.data.inserted).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.4.30 — `POST /api/mood-entries/bulk` real-Postgres integration.
|
||||
*
|
||||
* Asserts the iOS SyncMode bulk-backfill contract:
|
||||
* - inserts a clean batch
|
||||
* - upserts on re-post (last-writer-wins on mood / tags)
|
||||
* - rejects oversize batches
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-mood-bulk";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "mood-bulk",
|
||||
email: "mood-bulk@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/mood-entries/bulk (real Postgres)", () => {
|
||||
it("inserts a clean batch of three entries", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T14:00:00.000Z",
|
||||
tags: ["coffee"],
|
||||
},
|
||||
{
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T20:00:00.000Z",
|
||||
source: "DAYLIO",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
expect(json.data.processed).toBe(3);
|
||||
expect(json.data.inserted).toBe(3);
|
||||
expect(json.data.duplicates).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
});
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored[0].mood).toBe("GUT");
|
||||
expect(stored[0].score).toBe(4);
|
||||
expect(stored[0].tz).toBe("Europe/Berlin");
|
||||
});
|
||||
|
||||
it("upserts when the same (userId, date, moodLoggedAt) tuple is re-posted", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const body = {
|
||||
entries: [
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await POST(makeRequest(body));
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
// Same timestamp + user but a different mood.
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
tags: ["workout"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: { duplicates: number; inserted: number };
|
||||
};
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(json.data.inserted).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].mood).toBe("SUPER_GUT");
|
||||
expect(stored[0].score).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects an over-cap batch with 422 and the documented error code", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: new Date(2026, 4, 16, 8, i % 60).toISOString(),
|
||||
}));
|
||||
const res = await POST(makeRequest({ entries }));
|
||||
expect(res.status).toBe(422);
|
||||
const json = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(json.meta?.errorCode).toBe("mood.bulk.too_large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.4.30 — `GET /api/sync/state` real-Postgres integration.
|
||||
*
|
||||
* The endpoint is the iOS SyncMode handshake. Asserts:
|
||||
* - first call returns `lastSyncedAt: null` then writes the
|
||||
* checkpoint
|
||||
* - subsequent call sees the previous checkpoint
|
||||
* - measurement counters distinguish live vs tombstoned rows
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-sync-state";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "sync-state",
|
||||
email: "sync-state@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/sync/state", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("GET /api/sync/state (real Postgres)", () => {
|
||||
it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => {
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
userId: string;
|
||||
timezone: string;
|
||||
lastSyncedAt: string | null;
|
||||
serverNow: string;
|
||||
measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.userId).toBe(TEST_USER_ID);
|
||||
expect(json.data.timezone).toBe("Europe/Berlin");
|
||||
expect(json.data.lastSyncedAt).toBeNull();
|
||||
expect(json.data.measurements.liveCount).toBe(0);
|
||||
expect(json.data.measurements.tombstonedCount).toBe(0);
|
||||
|
||||
// Server-side, lastSyncedAt now carries a value.
|
||||
const after = await getPrismaClient().user.findUnique({
|
||||
where: { id: TEST_USER_ID },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
expect(after?.lastSyncedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns the previous checkpoint on the second call", async () => {
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
await GET(makeRequest());
|
||||
const res = await GET(makeRequest());
|
||||
const json = (await res.json()) as {
|
||||
data: { lastSyncedAt: string | null };
|
||||
};
|
||||
expect(json.data.lastSyncedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes live vs tombstoned measurement counts", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 64,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 70,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-05-16T11:00:00.000Z"),
|
||||
deletedAt: new Date("2026-05-16T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
const res = await GET(makeRequest());
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: { liveCount: number; tombstonedCount: number } };
|
||||
};
|
||||
expect(json.data.measurements.liveCount).toBe(1);
|
||||
expect(json.data.measurements.tombstonedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user