chore(release): v1.4.30.1

Categories endpoint + conflict-resolution lock. A two-item follow-up to v1.4.30: the categorisation overlay shipped yesterday as a TypeScript map; the new `GET /api/measurement-categories` endpoint projects it over HTTP so the iOS-side picker can fetch the canonical shape rather than carry a hard-coded mirror — the unblocker the iOS team flagged as the one missing piece. In parallel the SyncMode conflict-resolution policy that the v0.6.0 standalone-first track depends on lands as §13 of `08-locked-contracts.md`.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 13:20:49 +02:00
1 parent 3ece95f192
commit 175e3aea97
8 files changed
+618 -30

No files matched your search

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(): NextRequest {
return new NextRequest("http://localhost/api/measurement-categories", {
method: "GET",
});
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/measurement-categories", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req());
expect(res.status).toBe(401);
});
it("returns the categories + assignments envelope to a logged-in user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
version: number;
categories: Array<{ id: string; labelKey: string; order: number }>;
assignments: Record<string, string>;
};
error: null;
};
expect(body.error).toBeNull();
expect(body.data.version).toBe(1);
expect(body.data.categories.length).toBe(8);
// Stable order — vitals first, metabolic last.
expect(body.data.categories[0]).toEqual({
id: "vitals",
labelKey: "categories.vitals",
order: 0,
});
expect(body.data.categories.at(-1)).toEqual({
id: "metabolic",
labelKey: "categories.metabolic",
order: 7,
});
// Every category carries a `categories.<id>` translation key.
for (const entry of body.data.categories) {
expect(entry.labelKey).toBe(`categories.${entry.id}`);
}
});
it("returns an assignments map covering every MeasurementType in the overlay", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { assignments: Record<string, string> };
};
// Parity with the source map — the endpoint is a thin projection,
// so the assignment count and per-key category must match exactly.
expect(Object.keys(body.data.assignments).length).toBe(
MEASUREMENT_CATEGORIES.size,
);
for (const [type, category] of MEASUREMENT_CATEGORIES) {
expect(body.data.assignments[type]).toBe(category);
}
});
it("sets the 10-minute public cache header", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req());
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe("public, max-age=600");
});
});
@@ -0,0 +1,89 @@
/**
* `GET /api/measurement-categories` — public categorisation overlay.
*
* Exposes the UI-side `MEASUREMENT_CATEGORIES` map at
* `src/lib/measurements/categories.ts` as an HTTP contract. iOS reads
* this on cold start to drive the HealthKit permission picker
* (one consent screen per category instead of a flat enum list) and
* caches the response client-side; the web Insights nav and the Coach
* evidence shelf will read the same shape post-v1.5.
*
* Response envelope:
*
* {
* "data": {
* "version": 1,
* "categories": [
* { "id": "vitals", "labelKey": "categories.vitals", "order": 0 },
* …
* ],
* "assignments": {
* "BLOOD_PRESSURE_SYS": "vitals",
* "WEIGHT": "body",
* …
* }
* },
* "error": null
* }
*
* - `requireAuth()` — any logged-in user. No admin gate; no PII.
* - `Cache-Control: public, max-age=600` — the overlay is stable across
* a 10-minute window. Clients that need a forced refresh can bypass
* their HTTP cache.
* - `version: 1` is an additive marker; the categorisation shape will
* evolve additively, never by reshuffling existing assignments.
*
* Locked contract per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
MEASUREMENT_CATEGORIES,
type MeasurementCategory,
} from "@/lib/measurements/categories";
import { annotate } from "@/lib/logging/context";
/**
* Stable category ordering for the iOS picker. Mirrors the
* insertion order of `MEASUREMENT_CATEGORIES` so the UX is identical
* across clients that read the canonical map directly and clients
* that consume this endpoint.
*/
const CATEGORY_ORDER: readonly MeasurementCategory[] = [
"vitals",
"body",
"activity",
"sleep",
"hearing",
"environment",
"cardiovascular",
"metabolic",
] as const;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const GET = apiHandler(async (_request: NextRequest) => {
await requireAuth();
const categories = CATEGORY_ORDER.map((id, order) => ({
id,
labelKey: `categories.${id}`,
order,
}));
const assignments: Record<string, MeasurementCategory> = {};
for (const [type, category] of MEASUREMENT_CATEGORIES) {
assignments[type] = category;
}
annotate({ action: { name: "measurement-categories.read" } });
const response = apiSuccess({
version: 1 as const,
categories,
assignments,
});
response.headers.set("Cache-Control", "public, max-age=600");
return response;
});