feat(medications): let a delegate manage a composition, bypass the proxy for label uploads, and keep the guards current

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:37:26 +02:00
1 parent eb3cc4237f
commit 9d32a8acb0
19 files changed
+293 -30

No files matched your search

+2 -1
View File
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt",
"lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt",
"lifeEventUpdate": "{name} hat ein Lebensereignis geändert",
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt"
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert"
},
"section": {
"measurements": {
+2 -1
View File
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} removed an own vaccine",
"lifeEventCreate": "{name} added a life event",
"lifeEventUpdate": "{name} changed a life event",
"lifeEventDelete": "{name} removed a life event"
"lifeEventDelete": "{name} removed a life event",
"medicationIngredientsUpdate": "{name} changed what a supplement contains"
},
"section": {
"measurements": {
+4 -3
View File
@@ -11365,8 +11365,8 @@
"iodine": "Yodo",
"water": "Agua",
"caffeine": "Cafeína",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
"dha": "DHA (ácido graso omega-3)",
"epa": "EPA (ácido graso omega-3)"
},
"page": {
"title": "Ingesta de nutrientes",
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} quitó una vacuna propia",
"lifeEventCreate": "{name} añadió un acontecimiento vital",
"lifeEventUpdate": "{name} cambió un acontecimiento vital",
"lifeEventDelete": "{name} eliminó un acontecimiento vital"
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento"
},
"section": {
"measurements": {
+2 -1
View File
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} a retiré un vaccin personnel",
"lifeEventCreate": "{name} a ajouté un événement de vie",
"lifeEventUpdate": "{name} a modifié un événement de vie",
"lifeEventDelete": "{name} a supprimé un événement de vie"
"lifeEventDelete": "{name} a supprimé un événement de vie",
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément"
},
"section": {
"measurements": {
+4 -3
View File
@@ -11365,8 +11365,8 @@
"iodine": "Iodio",
"water": "Acqua",
"caffeine": "Caffeina",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
"dha": "DHA (acido grasso omega-3)",
"epa": "EPA (acido grasso omega-3)"
},
"page": {
"title": "Apporto di nutrienti",
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} ha rimosso un vaccino personale",
"lifeEventCreate": "{name} ha aggiunto un evento della vita",
"lifeEventUpdate": "{name} ha modificato un evento della vita",
"lifeEventDelete": "{name} ha rimosso un evento della vita"
"lifeEventDelete": "{name} ha rimosso un evento della vita",
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore"
},
"section": {
"measurements": {
+2 -1
View File
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요",
"lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요",
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요"
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요"
},
"section": {
"measurements": {
+4 -3
View File
@@ -11365,8 +11365,8 @@
"iodine": "Jod",
"water": "Woda",
"caffeine": "Kofeina",
"dha": "DHA (omega-3)",
"epa": "EPA (omega-3)"
"dha": "DHA (kwas omega-3)",
"epa": "EPA (kwas omega-3)"
},
"page": {
"title": "Spożycie składników odżywczych",
@@ -12030,7 +12030,8 @@
"customVaccineDelete": "{name} usunął własną szczepionkę",
"lifeEventCreate": "{name} dodał wydarzenie życiowe",
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
"lifeEventDelete": "{name} usunął wydarzenie życiowe"
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
"medicationIngredientsUpdate": "{name} zmienił skład suplementu"
},
"section": {
"measurements": {
@@ -139,3 +139,77 @@ describe("insights unit-literal guard", () => {
expect(missing).toEqual([]);
});
});
/**
* v1.43 — no nutrient unit spelled outside the nutrient catalogue.
*
* A supplement's composition converts an entered amount to the catalogue's
* canonical unit, and the nutrient reads judge a day against an upper limit
* in that unit. A µg/mg slip is a 1000× error, and the way it gets in is a
* second place that spells a unit or a factor: a converter that knows "IU"
* itself, a read that compares against "mg". So the rule is that the code
* under `src/lib/nutrients/` and `src/lib/medications/ingredients/` names no
* unit as a string literal; units and factors come from
* `src/lib/nutrients/catalog.ts` (`NUTRIENT_UNIT`, `inputUnits`,
* `toCanonicalNutrientAmount`, `parseNutrientInputUnit`), the one file allowed
* to spell them.
*/
const NUTRIENT_UNIT_DIRS = [
join(process.cwd(), "src/lib/nutrients"),
join(process.cwd(), "src/lib/medications/ingredients"),
];
const NUTRIENT_CATALOG_FILE = join(
process.cwd(),
"src/lib/nutrients/catalog.ts",
);
/** A string literal that is nothing but a nutrient unit. */
const UNIT_LITERAL =
/(["'`])(mg|ug|µg|μg|mcg|g|kg|ml|mL|l|L|IU|IE|UI|iu|ie|ui)\1/g;
function sourceFilesUnder(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
out.push(...sourceFilesUnder(full));
} else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
out.push(full);
}
}
return out;
}
describe("nutrient unit-literal guard", () => {
const files = NUTRIENT_UNIT_DIRS.flatMap(sourceFilesUnder);
it("scans the nutrient and composition code", () => {
expect(files.length).toBeGreaterThanOrEqual(6);
expect(files).toContain(NUTRIENT_CATALOG_FILE);
});
it("the matcher sees the catalogue's own spellings", () => {
// The catalogue is where units live, so a matcher that finds nothing
// there would make the next assertion vacuously true.
const catalog = readFileSync(NUTRIENT_CATALOG_FILE, "utf8");
const seen = new Set([...catalog.matchAll(UNIT_LITERAL)].map((m) => m[2]));
for (const unit of ["mg", "ug", "IU", "ml"]) {
expect(seen.has(unit), `expected to see "${unit}" in the catalogue`).toBe(
true,
);
}
});
it("names no unit outside the catalogue", () => {
const offenders: string[] = [];
for (const file of files) {
if (file === NUTRIENT_CATALOG_FILE) continue;
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(UNIT_LITERAL)) {
offenders.push(`${file.slice(file.indexOf("src/"))}: ${match[0]}`);
}
}
expect(offenders).toEqual([]);
});
});
@@ -83,6 +83,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"folds duplicate rows of doses already recorded",
"lib/doctor-report/collect.ts":
"administration ledger lists recorded doses; rates gate via expectsDoses",
"lib/medications/ingredients/recompute.ts":
"sums the nutrients of doses that were taken; a recorded dose counts whatever the switch says",
};
/**
@@ -94,6 +96,10 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
// as stored, schedule and switch included.
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
"lib/export/restore-backup.ts": "restore writes the stored rows back",
// v1.43 — the units a taken dose consumed, resolved per slot exactly as
// the inventory does. It reads only doses already recorded as taken.
"lib/medications/ingredients/recompute.ts":
"units per slot of doses already taken",
"app/api/export/route.ts": "data export of the stored record",
"app/api/export/medications/route.ts": "medication CSV export of the record",
// Intake WRITE helpers: they run only when a dose is being recorded, bind
+5 -1
View File
@@ -74,7 +74,9 @@ describe("proxy matcher", () => {
const routes = apiRoutePaths();
expect(routes.length).toBeGreaterThan(300);
for (const path of PROXY_BYPASS_ROUTES) {
for (const route of PROXY_BYPASS_ROUTES) {
// A dynamic segment is filled the way `apiRoutePaths` fills it.
const path = route.replace(/\[[^\]]+\]/g, "x");
expect(routes, `${path} is not a route`).toContain(path);
expect(re.test(path), `${path} still passes the proxy`).toBe(false);
expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe(
@@ -98,6 +100,8 @@ describe("proxy matcher", () => {
"/api/user/avatar/abc",
"/api/documents/inbound/abc/extract",
"/api/import/csv/preview",
// The composition PUT beside the label upload stays behind the proxy.
"/api/medications/abc/ingredients",
]) {
expect(re.test(path), path).toBe(true);
}
@@ -31,6 +31,12 @@ vi.mock("@/lib/db", () => ({
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
// v1.43 — no composition: the GET carries `ingredients: []` and a PUT
// queues no supplement recompute.
medicationIngredient: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn(),
},
}));
@@ -17,6 +17,8 @@ vi.mock("@/lib/db", () => ({
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { updateMany: vi.fn() },
medicationComplianceRollup: { deleteMany: vi.fn() },
// v1.43 — no composition, so no supplement recompute is queued.
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
@@ -890,7 +890,11 @@ describe("GET /api/medications — provenance echo (v1.32.25)", () => {
end: new Date("2026-07-24T23:59:59Z"),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue(
medications as never,
// v1.43 — every row carries the ingredient count the list reads.
medications.map((m) => ({
_count: { ingredients: 0 },
...(m as object),
})) as never,
);
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
@@ -1018,6 +1022,8 @@ describe("GET /api/medications — intake actions follow the course (#1040)", ()
dose: null,
},
],
// v1.43 — the ingredient count the list reads for `hasIngredients`.
_count: { ingredients: 0 },
...over,
};
}
+123 -9
View File
@@ -5,6 +5,7 @@
* per-nutrient summaries (latest day + total, days-with-data), the
* module gate, and the `days` query validation.
*/
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -111,11 +112,41 @@ describe("GET /api/nutrients", () => {
// Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
// fold takes the first row per nutrient as the latest.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
{ nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
{
nutrient: "caffeine",
unit: "mg",
day: "2026-07-13",
amount: 310,
source: "APPLE_HEALTH",
},
{
nutrient: "caffeine",
unit: "mg",
day: "2026-07-12",
amount: 250,
source: "APPLE_HEALTH",
},
{
nutrient: "caffeine",
unit: "mg",
day: "2026-07-11",
amount: 400,
source: "APPLE_HEALTH",
},
{
nutrient: "vitamin_d",
unit: "ug",
day: "2026-07-13",
amount: 22.5,
source: "APPLE_HEALTH",
},
{
nutrient: "vitamin_d",
unit: "ug",
day: "2026-07-10",
amount: 20,
source: "APPLE_HEALTH",
},
] as never);
const res = await GET(getReq("?days=7"));
@@ -129,6 +160,9 @@ describe("GET /api/nutrients", () => {
latestDay: "2026-07-13",
latestAmount: 22.5,
daysWithData: 2,
latestBySource: { APPLE_HEALTH: 22.5 },
upperLimit: NUTRIENT_CATALOG.vitamin_d.upperLimit,
latestAboveUpperLimit: false,
},
{
nutrient: "caffeine",
@@ -136,6 +170,9 @@ describe("GET /api/nutrients", () => {
latestDay: "2026-07-13",
latestAmount: 310,
daysWithData: 3,
latestBySource: { APPLE_HEALTH: 310 },
upperLimit: null,
latestAboveUpperLimit: null,
},
]);
});
@@ -145,10 +182,34 @@ describe("GET /api/nutrients", () => {
// day logged via both APPLE_HEALTH and MANUAL must still count as ONE
// day, not two.
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{ nutrient: "water", unit: "ml", day: "2026-07-13", amount: 900 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 600 },
{ nutrient: "water", unit: "ml", day: "2026-07-12", amount: 400 },
{ nutrient: "water", unit: "ml", day: "2026-07-11", amount: 500 },
{
nutrient: "water",
unit: "ml",
day: "2026-07-13",
amount: 900,
source: "APPLE_HEALTH",
},
{
nutrient: "water",
unit: "ml",
day: "2026-07-12",
amount: 600,
source: "APPLE_HEALTH",
},
{
nutrient: "water",
unit: "ml",
day: "2026-07-12",
amount: 400,
source: "MANUAL",
},
{
nutrient: "water",
unit: "ml",
day: "2026-07-11",
amount: 500,
source: "APPLE_HEALTH",
},
] as never);
const res = await GET(getReq("?days=7"));
@@ -163,6 +224,59 @@ describe("GET /api/nutrients", () => {
// four, since the 12th's second source row didn't match the pinned
// `latestDay` (the 13th) and opened a phantom extra day.
daysWithData: 3,
latestBySource: { APPLE_HEALTH: 900 },
upperLimit: null,
latestAboveUpperLimit: null,
});
});
it("splits the latest day by source and judges it against the upper limit (v1.43)", async () => {
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
{
nutrient: "vitamin_d",
unit: "ug",
day: "2026-07-13",
amount: 30,
source: "APPLE_HEALTH",
},
{
nutrient: "vitamin_d",
unit: "ug",
day: "2026-07-13",
amount: 80,
source: "SUPPLEMENT",
},
{
nutrient: "magnesium",
unit: "mg",
day: "2026-07-13",
amount: 300,
source: "APPLE_HEALTH",
},
{
nutrient: "magnesium",
unit: "mg",
day: "2026-07-13",
amount: 200,
source: "SUPPLEMENT",
},
] as never);
const res = await GET(getReq("?days=7"));
const body = (await res.json()) as OverviewResponse;
const byCode = new Map(body.data.nutrients.map((n) => [n.nutrient, n]));
// 110 µg in total against a total-scope limit of 100.
expect(byCode.get("vitamin_d")).toMatchObject({
latestAmount: 110,
latestBySource: { APPLE_HEALTH: 30, SUPPLEMENT: 80 },
latestAboveUpperLimit: true,
});
// 500 mg in total, but the limit covers the supplement share only (200).
expect(byCode.get("magnesium")).toMatchObject({
latestAmount: 500,
latestBySource: { APPLE_HEALTH: 300, SUPPLEMENT: 200 },
upperLimit: { value: 250, scope: "supplemental" },
latestAboveUpperLimit: false,
});
});
+20 -1
View File
@@ -45,6 +45,9 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [
"/api/medications/intake/dose-history-import",
// Lab scan upload, 12 MB.
"/api/labs/ocr/extract",
// Supplement label photo, 10 MB (v1.43). The one entry with a dynamic
// segment: `[id]` stands for any one path segment.
"/api/medications/[id]/ingredients/extract",
// Avatar image, 2 MB.
"/api/user/avatar",
// Native-client batches: measurements 4 MB, workouts 5 MB, the rest 2 MB.
@@ -56,10 +59,26 @@ export const PROXY_BYPASS_ROUTES: readonly string[] = [
"/api/cycle/day-logs/bulk",
];
/** An entry with a `[segment]` matches any one path segment there. */
const DYNAMIC_BYPASS_PATTERNS: readonly RegExp[] = PROXY_BYPASS_ROUTES.filter(
(route) => route.includes("["),
).map(
(route) =>
new RegExp(
`^${route
.split("/")
.map((part) => (/^\[[^\]/]+\]$/.test(part) ? "[^/]+" : part))
.join("/")}$`,
),
);
export function isProxyBypassRoute(pathname: string): boolean {
const normalised =
pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
return PROXY_BYPASS_ROUTES.includes(normalised);
return (
PROXY_BYPASS_ROUTES.includes(normalised) ||
DYNAMIC_BYPASS_PATTERNS.some((pattern) => pattern.test(normalised))
);
}
/**
+24 -3
View File
@@ -156,9 +156,27 @@ describe("getNutrients — overview mode (no `nutrient` arg)", () => {
it("sums a day across sources and reports latest day / total / days-with-data", async () => {
vi.mocked(nutrientIntakeDay.findMany).mockResolvedValue([
// Two sources on the SAME latest day — must sum, not double-count the day.
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 1200 },
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 500 },
{ nutrient: "water", unit: "ml", day: "2026-07-09", amount: 2000 },
{
nutrient: "water",
unit: "ml",
day: "2026-07-10",
amount: 1200,
source: "APPLE_HEALTH",
},
{
nutrient: "water",
unit: "ml",
day: "2026-07-10",
amount: 500,
source: "MANUAL",
},
{
nutrient: "water",
unit: "ml",
day: "2026-07-09",
amount: 2000,
source: "APPLE_HEALTH",
},
] as never);
const result = overview(await getNutrients("user-1", {}));
@@ -171,6 +189,9 @@ describe("getNutrients — overview mode (no `nutrient` arg)", () => {
latestDay: "2026-07-10",
latestAmount: 1700,
daysWithData: 2,
latestBySource: { APPLE_HEALTH: 1200, MANUAL: 500 },
upperLimit: null,
latestAboveUpperLimit: null,
},
]);
});
@@ -83,5 +83,5 @@ export async function medicationHasComposition(
where: { userId, medicationId },
select: { id: true },
});
return row !== null;
return Boolean(row);
}
+4
View File
@@ -255,6 +255,10 @@ export function recordActivityVerbLine(
});
case "medication.delete":
return t("recordSharing.activityVerb.medicationDelete", { name });
case "medication.ingredients.update":
return t("recordSharing.activityVerb.medicationIngredientsUpdate", {
name,
});
case "medication.glp1.update":
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
case "medication.schedule_revision.created":
+1 -1
View File
@@ -825,6 +825,6 @@ export const config = {
// the matcher and `apiHandler` performs the proxy's duties for it. The
// alternation must stay equal to that list; `proxy-body-limit.test.ts`
// compiles this exactly as Next does and checks.
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
"/((?!_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.json|robots\\.txt|sitemap\\.xml|api/(?:import(?:/csv|/apple-health-export|/health-connect-export)?|admin/import-apple-health-export|admin/backups/upload|documents/inbound|medications/intake/(?:dose-history-import|bulk)|labs/ocr/extract|medications/[^/]+/ingredients/extract|user/avatar|measurements/batch|workouts/batch|insights/ecg|mood-entries/bulk|cycle/day-logs/bulk)/?$|.*\\.(?:svg|png|jpg|jpeg|gif|webp|txt|xml)$).*)",
],
};