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:
Marc-André Bombeck authored and GitHub committed 2026-05-16 12:37:55 +02:00
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,
});
}
+3
View File
@@ -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 },
+220
View File
@@ -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,
});
}
+2 -1
View File
@@ -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,
},
+93
View File
@@ -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,
},
});
});