Merge branch 'feat/v144-userneeds' into release/v1.44.0

# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
#	src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:24:01 +02:00
commit aa899a9d54
86 files changed
+12949 -9

No files matched your search

@@ -0,0 +1,168 @@
/**
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
*
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
* last goal returns the column to NULL rather than storing an empty object.
* The calendar: the module gate answers before any read, a future or malformed
* day is refused, and the reader is asked for exactly the window the period
* needs and never past today.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
readActivityDays: vi.fn(),
requireModuleEnabled: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
})),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: mocks.requireModuleEnabled,
}));
vi.mock("@/lib/activity/calendar-read", () => ({
readActivityDays: mocks.readActivityDays,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
import { Prisma } from "@/generated/prisma/client";
import { GET as calendarGet } from "../calendar/route";
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
type Handler = (req: NextRequest) => Promise<Response>;
function patch(body: unknown) {
return (goalsPatch as unknown as Handler)(
new NextRequest("http://localhost/api/activity/goals", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function calendar(query: string) {
return (calendarGet as unknown as Handler)(
new NextRequest(`http://localhost/api/activity/calendar${query}`),
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.findUnique.mockResolvedValue({
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
});
mocks.update.mockResolvedValue({});
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
});
describe("activity goals", () => {
it("reads every goal, unset ones as null", async () => {
const res = await (goalsGet as unknown as () => Promise<Response>)();
const body = await res.json();
expect(body.data).toEqual({
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: 50000,
stepsPerMonth: null,
});
});
it("changes what the body names and keeps the rest", async () => {
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
expect(res.status).toBe(200);
expect(mocks.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
activityGoalsJson: {
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: null,
stepsPerMonth: 250000,
},
},
});
expect(mocks.auditLog).toHaveBeenCalledWith(
"activity.goals.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("stores NULL once the last goal is cleared", async () => {
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
Prisma.DbNull,
);
});
it.each([
[{ caloriesPerWeek: 3000 }],
[{ workoutsPerWeek: 0 }],
[{ stepsPerWeek: 2.5 }],
[{ stepsPerMonth: "200000" }],
])("refuses %j without writing", async (body) => {
const res = await patch(body);
expect(res.status).toBe(422);
expect(mocks.update).not.toHaveBeenCalled();
});
});
describe("activity calendar", () => {
it("answers the module gate before reading anything", async () => {
mocks.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
const res = await calendar("?period=month");
expect(res.status).toBe(403);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
"refuses %s",
async (q) => {
const res = await calendar(q);
expect(res.status).toBe(422);
},
);
it("refuses a day after today", async () => {
const res = await calendar("?date=2999-01-01");
expect(res.status).toBe(422);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it("reads the baseline window before the period and nothing past the grid", async () => {
const res = await calendar("?period=week&date=2025-03-12");
expect(res.status).toBe(200);
const args = mocks.readActivityDays.mock.calls[0][0];
// Week of Monday 2025-03-10; 84 days before it.
expect(args.fromKey).toBe("2024-12-16");
expect(args.toKey).toBe("2025-03-16");
expect(args.tz).toBe("Europe/Berlin");
const body = await res.json();
expect(body.data.start).toBe("2025-03-10");
expect(body.data.goals.workoutsPerWeek).toBe(3);
expect(body.data.days).toHaveLength(7);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* `GET /api/activity/calendar` — a week or a month of activity against the
* person's own normal, with their weekly and monthly goals (D1036).
*
* Computed server-side, without AI, from the stored readings: steps (the
* canonical-source day totals the steps chart draws), exercise minutes,
* workouts after the cross-source collapse, and a training-load score when a
* device or HealthLog provides one. What "usual" means is in
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
* never a population norm.
*
* Owned by the Workouts module, like the page that shows it: a switched-off
* module answers 403 here as `/api/workouts` does.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
buildActivityCalendar,
periodBounds,
} from "@/lib/activity/calendar-math";
import { readActivityDays } from "@/lib/activity/calendar-read";
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
import { parseActivityGoals } from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
/** How far back the calendar pages: ten years, the series route's ceiling. */
const MAX_DAYS_BACK = 3650 - 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "activity.calendar.read" } });
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const params = new URL(request.url).searchParams;
const parsed = activityCalendarQuerySchema.safeParse({
period: params.get("period") ?? undefined,
date: params.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const tz = await resolveUserTimezone(user.id);
const now = new Date();
const today = userDayKey(now, tz);
const anchor = parsed.data.date ?? today;
if (anchor > today) {
return apiError("The calendar cannot show a day after today", 422, {
errorCode: "activity.calendar.future_date",
});
}
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
return apiError("The calendar reaches back ten years", 422, {
errorCode: "activity.calendar.too_old",
});
}
const bounds = periodBounds(parsed.data.period, anchor);
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
const [{ days, loadType }, goalRow] = await Promise.all([
readActivityDays({
userId: user.id,
tz,
fromKey: bounds.readStart,
toKey: lastRead,
now,
includeWorkouts: true,
}),
prisma.user.findUnique({
where: { id: user.id },
select: { activityGoalsJson: true },
}),
]);
const calendar = buildActivityCalendar({
bounds,
today,
input: days,
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
});
annotate({
meta: {
activity_calendar_period: bounds.period,
activity_calendar_days_read: days.length,
activity_calendar_load_type: loadType ?? "none",
},
});
return apiSuccess({ ...calendar, timezone: tz, loadType });
});
+90
View File
@@ -0,0 +1,90 @@
/**
* GET / PATCH the caller's activity goals (D1036).
*
* Every goal is optional and set by the person; there is no default goal.
* PATCH replaces the goals the body names (`null` clears one) and keeps the
* rest; an unknown key or an out-of-bounds goal is refused with the
* multi-issue 422. The goals live on their own `User` column
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
ACTIVITY_GOAL_KEYS,
activityGoalsSchema,
hasNoActivityGoals,
mergeActivityGoals,
parseActivityGoals,
type ActivityGoals,
} from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
async function readGoals(userId: string): Promise<ActivityGoals> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { activityGoalsJson: true },
});
return parseActivityGoals(row?.activityGoalsJson ?? null);
}
function setKeys(goals: ActivityGoals): string[] {
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
}
export const GET = apiHandler(async () => {
// Owner-only, like the write: a delegate sees the goals where they matter,
// inside the calendar, which carries them.
const { user } = await requireAuth();
const goals = await readGoals(user.id);
annotate({
action: { name: "activity.goals.read" },
meta: { set: setKeys(goals) },
});
return apiSuccess(goals);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
where: { id: user.id },
data: {
activityGoalsJson: hasNoActivityGoals(next)
? Prisma.DbNull
: toJson(next),
},
});
await auditLog("activity.goals.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { set: setKeys(next) },
});
annotate({
action: { name: "activity.goals.update" },
meta: { set: setKeys(next) },
});
return apiSuccess(next);
});
@@ -0,0 +1,165 @@
/**
* The request side of `/api/import/labs-csv` and `/api/import/medications-csv`
* (#1215): module gate first, a separate bucket for dry runs, the content
* type, the byte cap while streaming, the mapping refused before anything is
* read, and an audit row only for a real import.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
checkRateLimit: vi.fn(),
requireModuleEnabled: vi.fn(),
importLabsCsv: vi.fn(),
importMedicationsCsv: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: mocks.requireModuleEnabled,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/import/labs-csv", () => ({
importLabsCsv: mocks.importLabsCsv,
}));
vi.mock("@/lib/import/medications-csv", () => ({
importMedicationsCsv: mocks.importMedicationsCsv,
}));
import { CsvTooLargeError } from "@/lib/import/csv-stream";
import { POST as labsPost } from "../labs-csv/route";
import { POST as medsPost } from "../medications-csv/route";
type Handler = (req: NextRequest) => Promise<Response>;
const RESULT = {
kind: "labs",
dryRun: false,
rows: 2,
valid: 2,
inserted: 2,
duplicates: 0,
failed: 0,
errors: [],
errorsTruncated: false,
preview: [],
};
function post(
handler: unknown,
query: string,
body = "a,b\n1,2\n",
type = "text/csv",
) {
return (handler as Handler)(
new NextRequest(`http://localhost/api/import/x?${query}`, {
method: "POST",
headers: { "content-type": type },
body,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
mocks.importLabsCsv.mockResolvedValue(RESULT);
mocks.importMedicationsCsv.mockResolvedValue({
...RESULT,
kind: "medications",
});
});
const LABS = "analyte=0&value=1&unit=2&date=3";
const MEDS = "name=0&dose=1&times=2";
describe("record CSV import routes", () => {
it("answers the module gate before reading the file", async () => {
mocks.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
expect((await post(labsPost, LABS)).status).toBe(403);
expect(mocks.requireModuleEnabled).toHaveBeenCalledWith("user-1", "labs");
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
});
it("refuses a mapping without the required columns, or with a shared column", async () => {
expect((await post(labsPost, "analyte=0&value=1")).status).toBe(422);
expect((await post(labsPost, "analyte=0&value=0&date=1")).status).toBe(422);
expect((await post(medsPost, "name=0")).status).toBe(422);
expect(mocks.importLabsCsv).not.toHaveBeenCalled();
});
it("spends the preview bucket on a dry run and the import bucket otherwise", async () => {
await post(labsPost, `${LABS}&dryRun=1`);
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
"import-preview:user-1",
30,
3_600_000,
);
expect(mocks.auditLog).not.toHaveBeenCalled();
await post(medsPost, MEDS);
expect(mocks.checkRateLimit).toHaveBeenLastCalledWith(
"import:user-1",
5,
3_600_000,
);
expect(mocks.auditLog).toHaveBeenCalledWith(
"import.medications.csv",
expect.objectContaining({ userId: "user-1" }),
);
});
it("answers 429 when the bucket is spent", async () => {
mocks.checkRateLimit.mockResolvedValue({ allowed: false });
expect((await post(labsPost, LABS)).status).toBe(429);
});
it("refuses a body that is not CSV", async () => {
expect((await post(labsPost, LABS, "{}", "application/json")).status).toBe(
415,
);
});
it("turns the streaming cap into a 413", async () => {
mocks.importLabsCsv.mockRejectedValue(new CsvTooLargeError(1));
expect((await post(labsPost, LABS)).status).toBe(413);
});
it("calls a file without data rows empty", async () => {
mocks.importLabsCsv.mockResolvedValue({ ...RESULT, rows: 0 });
expect((await post(labsPost, LABS)).status).toBe(422);
});
it("hands the parsed mapping and the account zone to the importer", async () => {
const res = await post(
labsPost,
`${LABS}&decimal=comma&dateOrder=dmy&delimiter=semicolon`,
);
expect(res.status).toBe(200);
const args = mocks.importLabsCsv.mock.calls[0][0];
expect(args.tz).toBe("Europe/Berlin");
expect(args.query).toMatchObject({
analyte: 0,
value: 1,
unit: 2,
date: 3,
decimal: "comma",
dateOrder: "dmy",
delimiter: "semicolon",
dryRun: false,
header: true,
});
expect(args.stream).toBeInstanceOf(ReadableStream);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* `POST /api/import/labs-csv` — lab results from a spreadsheet (#1215).
*
* Body: the CSV file, streamed. Query: the column mapping and the file's
* conventions (`labsCsvQuerySchema`). `dryRun=1` checks every row and reports
* what would happen without writing; the real import writes in chunks, skips
* readings the record already holds and reports every refused row by line.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { importLabsCsv } from "@/lib/import/labs-csv";
import { handleRecordImport } from "@/lib/import/record-import-route";
import { labsCsvQuerySchema } from "@/lib/validations/record-import";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) =>
handleRecordImport(request, {
kind: "labs",
module: "labs",
schema: labsCsvQuerySchema,
run: (args) => importLabsCsv(args),
}),
);
@@ -0,0 +1,25 @@
/**
* `POST /api/import/medications-csv` — medications from a spreadsheet (#1215).
*
* Body: the CSV file, streamed. Query: the column mapping and the file's
* conventions (`medicationsCsvQuerySchema`). Maps only name, dose, daily
* times, as-needed and a start and end date; reminders stay off unless the
* query turns them on. `dryRun=1` checks without writing.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { importMedicationsCsv } from "@/lib/import/medications-csv";
import { handleRecordImport } from "@/lib/import/record-import-route";
import { medicationsCsvQuerySchema } from "@/lib/validations/record-import";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) =>
handleRecordImport(request, {
kind: "medications",
module: "medications",
schema: medicationsCsvQuerySchema,
run: (args) => importMedicationsCsv(args),
}),
);
@@ -0,0 +1,42 @@
/**
* `GET /api/measurements/provenance/compare` — two sources of one metric side
* by side over up to a year of UTC days, with the source the ladder picked
* each day (#1204). Read-only; one aggregate query, at most one row per day
* and source.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readSourceComparison } from "@/lib/provenance/source-provenance";
import { sourceComparisonQuerySchema } from "@/lib/validations/source-provenance";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "measurement.provenance.compare" } });
const params = new URL(request.url).searchParams;
const parsed = sourceComparisonQuerySchema.safeParse({
type: params.get("type") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
a: params.get("a") ?? undefined,
b: params.get("b") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const result = await readSourceComparison({
userId: user.id,
...parsed.data,
});
annotate({
meta: {
provenance_type: result.type,
provenance_days: result.days.length,
provenance_sources: result.sources.length,
},
});
return apiSuccess(result);
});
@@ -0,0 +1,40 @@
/**
* `GET /api/measurements/provenance` — which sources delivered a metric on a
* day, which one the source-priority ladder picked, and why (#1204, #1108,
* #576). Read-only; the explanation comes from the same rank SQL the charts
* and summaries pick with (`src/lib/provenance/source-provenance.ts`).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readDayProvenance } from "@/lib/provenance/source-provenance";
import { dayProvenanceQuerySchema } from "@/lib/validations/source-provenance";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "measurement.provenance.day" } });
const params = new URL(request.url).searchParams;
const parsed = dayProvenanceQuerySchema.safeParse({
type: params.get("type") ?? undefined,
date: params.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const result = await readDayProvenance({
userId: user.id,
type: parsed.data.type,
date: parsed.data.date,
});
annotate({
meta: {
provenance_type: result.type,
provenance_sources: result.sources.length,
provenance_pick_reason: result.pickReason ?? "none",
},
});
return apiSuccess(result);
});