mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
This commit is contained in:
1 parent
945b556664
commit
3ece95f192
49 files changed
+4744
-32
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `POST /api/admin/drain-per-sample-cumulative` — operator-triggered
|
||||
* variant of `scripts/drain-per-sample-cumulative.ts`. Collapses
|
||||
* pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row
|
||||
* per day per cumulative type. See `06-ios-responsibilities.md`
|
||||
* §"Cumulative metrics: daily aggregation on iOS".
|
||||
*
|
||||
* Body:
|
||||
* { userId?: string, dryRun?: boolean }
|
||||
*
|
||||
* - `userId` — single user mode. Omit to drain every user.
|
||||
* - `dryRun` — preview-only; no DB writes. Default: `true`. Set to
|
||||
* `false` to commit.
|
||||
*
|
||||
* Response shape (always 200 on success):
|
||||
* { processed, inserted (always 0), totals: { ... }, buckets: [...] }
|
||||
*
|
||||
* Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates
|
||||
* to admin per the v1.4.25 security boundary).
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
|
||||
interface DrainBody {
|
||||
userId?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.drain.cumulative" } });
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
|
||||
const userId =
|
||||
typeof body.userId === "string" && body.userId.length > 0
|
||||
? body.userId
|
||||
: undefined;
|
||||
// Default to dryRun = true so a malformed body never silently writes.
|
||||
// Operator must explicitly send `dryRun: false` to commit.
|
||||
const dryRun = body.dryRun === false ? false : true;
|
||||
|
||||
await auditLog("admin.drain.cumulative.start", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: userId ?? "ALL", dryRun },
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId,
|
||||
dryRun,
|
||||
log: (line) => lines.push(line),
|
||||
});
|
||||
|
||||
if (!dryRun) {
|
||||
await auditLog("admin.drain.cumulative.commit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
targetUserId: userId ?? "ALL",
|
||||
...summary.totals,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
dryRun,
|
||||
totals: summary.totals,
|
||||
bucketCount: summary.buckets.length,
|
||||
// Truncate to the first 50 buckets so the response stays
|
||||
// bounded — operators inspect the CLI for the full output.
|
||||
bucketsPreview: summary.buckets.slice(0, 50),
|
||||
logTail: lines.slice(-20),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -88,6 +88,9 @@ export const PUT = apiHandler(
|
||||
if (data.tags !== undefined) {
|
||||
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
|
||||
}
|
||||
if (data.note !== undefined) {
|
||||
updateData.note = data.note;
|
||||
}
|
||||
|
||||
const entry = await prisma.moodEntry.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* `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(),
|
||||
note: z.string().max(500).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,
|
||||
note: entry.note ?? null,
|
||||
source: entry.source,
|
||||
moodLoggedAt: entry.moodLoggedAt,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the mood + tags + note triple. 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,
|
||||
note: entry.note ?? 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,
|
||||
});
|
||||
}
|
||||
@@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { mood, tags, moodLoggedAt, source } = parsed.data;
|
||||
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
@@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
source: source ?? "MANUAL",
|
||||
moodLoggedAt,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user