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 @@
|
||||
/**
|
||||
* `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