mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge remote-tracking branch 'origin/work/parity-contracts' into release/v1.30.34
This commit is contained in:
commit
d68a7ca244
28 files changed
+1087
-86
No files matched your search
@@ -87,6 +87,8 @@ const ALL_KINDS = [
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
"mood",
|
||||
"bmi",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
@@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => {
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// BMI is derived from weight + the profile height and belongs to no
|
||||
// module, so it rides through with the other core vitals.
|
||||
"bmi",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the mood card when the mood module is off", async () => {
|
||||
// Mood is not a MeasurementType, so it gates through the synthetic
|
||||
// `MOOD_ENTRY` key rather than a measurement enum value. Without that
|
||||
// join the card would be unmapped and silently ship past a disabled
|
||||
// module.
|
||||
setDisabledModules(["mood"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("mood");
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("bmi");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
|
||||
@@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
// The mood card reads the canonical daily series; its own aggregation is
|
||||
// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
|
||||
// empty series by default so the legacy assertions keep their "no mood"
|
||||
// expectation, and overridden per test below.
|
||||
vi.mock("@/lib/analytics/mood-series", () => ({
|
||||
buildMoodDailySeries: vi.fn(async () => ({
|
||||
entries: [],
|
||||
summary: null,
|
||||
entryCount: 0,
|
||||
source: "live" as const,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -85,6 +98,7 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.compliance.takenToday).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mood + BMI cards.
|
||||
*
|
||||
* Both metrics existed in the record but the summary payload emitted
|
||||
* neither, so the native home screen could not render those two tiles while
|
||||
* every other tile worked. Both are resolved server-side from the canonical
|
||||
* source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
|
||||
* client consumes a finished figure instead of re-deriving one.
|
||||
*/
|
||||
describe("GET /api/dashboard/summary — mood + BMI cards", () => {
|
||||
function cardsOf(body: unknown): Array<Record<string, unknown>> {
|
||||
return (body as { data: { metrics: Array<Record<string, unknown>> } }).data
|
||||
.metrics;
|
||||
}
|
||||
|
||||
it("emits a mood card from the canonical daily series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(buildMoodDailySeries).mockResolvedValue({
|
||||
entries: [
|
||||
{ date: "2026-07-01", score: 2, samples: 1 },
|
||||
{ date: "2026-07-02", score: 3, samples: 2 },
|
||||
{ date: "2026-07-03", score: 4.5, samples: 1 },
|
||||
],
|
||||
summary: null,
|
||||
entryCount: 3,
|
||||
source: "rollup",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
|
||||
|
||||
expect(mood).toBeDefined();
|
||||
// Headline is the newest day's score, verbatim from the series — the
|
||||
// route must not re-average or re-round what the helper resolved.
|
||||
expect(mood?.latestValue).toBe(4.5);
|
||||
expect(mood?.sparkline).toEqual([2, 3, 4.5]);
|
||||
expect(mood?.trend).toBe("up");
|
||||
expect(mood?.allTimeCount).toBe(3);
|
||||
expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
|
||||
expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
|
||||
expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits the mood card when the account has never logged a mood", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a BMI card computed from the profile height and latest weight", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// 180 cm profile.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
} as never);
|
||||
// `computeBmi` reads recent weight rows through `measurement.findMany`.
|
||||
// 81 kg at 1.80 m => 25.0.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi");
|
||||
|
||||
expect(bmi).toBeDefined();
|
||||
// The canonical helper's rounding (1 dp), not a second formula.
|
||||
expect(bmi?.latestValue).toBe(25);
|
||||
expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi");
|
||||
expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi");
|
||||
});
|
||||
|
||||
it("omits the BMI card when the profile carries no height", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Default profile has `heightCm: null`; weight alone cannot yield a BMI.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ import {
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { computeBmi } from "@/lib/insights/derived/bmi";
|
||||
import { isDerivedOk } from "@/lib/insights/derived/types";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
@@ -96,7 +99,9 @@ type MetricKind =
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation";
|
||||
| "oxygenSaturation"
|
||||
| "mood"
|
||||
| "bmi";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
@@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
mood: "dashboard.metric.title.mood",
|
||||
bmi: "dashboard.metric.title.bmi",
|
||||
};
|
||||
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
mood: "dashboard.metric.unit.mood",
|
||||
bmi: "dashboard.metric.unit.bmi",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -615,11 +624,24 @@ async function buildDashboardSummary(
|
||||
// before the per-night reconstruction sums it. v1.17.0 — the baseline
|
||||
// profile rides along (one read) so the sleep-rhythm block resolves the
|
||||
// age-based sleep need the cumulative-debt deficit uses.
|
||||
const [sleepPriorityJson, sleepProfile] = await Promise.all([
|
||||
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
// The mood card reads the SAME canonical daily series the web mood
|
||||
// analytics and the dashboard snapshot read (`buildMoodDailySeries`),
|
||||
// rollup fast-path included — never a second aggregation.
|
||||
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
|
||||
]);
|
||||
|
||||
// BMI is derived, not measured: it needs the profile height loaded above,
|
||||
// so it cannot join the round before it. `computeBmi` is the one canonical
|
||||
// implementation (the same one the web vitals dashboard renders through
|
||||
// the derived-metric dispatch) — the route resolves the figure and the
|
||||
// client consumes it, rather than every client re-deriving weight / m².
|
||||
const bmiDerived = await time("bmi", () =>
|
||||
computeBmi(userId, sleepProfile, { now }),
|
||||
);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
|
||||
@@ -954,6 +976,69 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Mood — the one metric on this payload that is not a `Measurement`. The
|
||||
// daily series is the canonical `buildMoodDailySeries` output (rollup
|
||||
// fast-path, 1..5 scale, one entry per day with an average score), the
|
||||
// same series the web mood analytics and the dashboard snapshot render.
|
||||
// Emitted only when the account has ever logged a mood, matching the
|
||||
// `allTimeCount === 0` gate every optional card above uses.
|
||||
if (moodSeries.entryCount > 0) {
|
||||
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
|
||||
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
|
||||
// The series carries UTC day labels, not timestamps — the entry's day is
|
||||
// the only instant the source actually pins, so it is emitted as the
|
||||
// day's start rather than inventing a reading time.
|
||||
const latestAt = latestEntry
|
||||
? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
|
||||
: null;
|
||||
metrics.push({
|
||||
id: "mood",
|
||||
kind: "mood",
|
||||
titleKey: METRIC_TITLE_KEYS.mood,
|
||||
latestValue: latestEntry?.score ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.mood,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latestAt,
|
||||
allTimeCount: moodSeries.entryCount,
|
||||
lastSeenAt: latestAt,
|
||||
});
|
||||
}
|
||||
|
||||
// BMI — derived from the latest weight at the profile height by
|
||||
// `computeBmi`, the single implementation the web vitals dashboard also
|
||||
// renders. The card is omitted when the derivation is insufficient
|
||||
// (no height on the profile, or no weight inside its window): the card
|
||||
// shape has no "unknown" state to express honestly, and a card with a
|
||||
// null value would read as a logging gap rather than a missing profile
|
||||
// field. Clients that want to prompt for a height should do so from the
|
||||
// profile surface, not from an empty dashboard tile.
|
||||
if (isDerivedOk(bmiDerived)) {
|
||||
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
|
||||
metrics.push({
|
||||
id: "bmi",
|
||||
kind: "bmi",
|
||||
titleKey: METRIC_TITLE_KEYS.bmi,
|
||||
latestValue: bmiDerived.value.bmi,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.bmi,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
// BMI moves exactly when weight does, so the weight metadata is the
|
||||
// honest recency signal — there is no separate BMI reading.
|
||||
updatedAt: metaForType("WEIGHT").lastSeenAt,
|
||||
allTimeCount: metaForType("WEIGHT").allTimeCount,
|
||||
lastSeenAt: metaForType("WEIGHT").lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and
|
||||
// not skipped). The wire shape of `compliance` is locked for iOS.
|
||||
|
||||
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard widgets — preserve-when-absent on PUT", () => {
|
||||
it("keeps the stored comparisonBaseline when the client omits it", async () => {
|
||||
// The regression: a layout save from a client that doesn't know
|
||||
// `comparisonBaseline` (the native client documents the field as
|
||||
// web-only and never sends it) used to fall through to the serializer,
|
||||
// which clamps a missing baseline to "none". The user's web-chosen
|
||||
// comparison silently reset on every tile reorder from the phone.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: false, tileVisible: true, order: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
|
||||
"lastYear",
|
||||
);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { comparisonBaseline: string };
|
||||
};
|
||||
expect(body.data.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
|
||||
// Preserve-when-absent must not become preserve-always: the web
|
||||
// CompareToggle sends the field, including an explicit "none" to clear.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "none",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
|
||||
});
|
||||
|
||||
it("persists the four clinical tiles the native client pins", async () => {
|
||||
// They used to hit the unknown-id filter ahead of Zod and vanish from
|
||||
// the persisted layout, so the placement was lost on every save.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const clinicalIds = [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
];
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: clinicalIds.map((id, i) => ({
|
||||
id,
|
||||
visible: true,
|
||||
tileVisible: true,
|
||||
order: i,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
for (const id of clinicalIds) {
|
||||
expect(persistedIds).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
type ChartOverlayPrefsMap,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
|
||||
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
|
||||
// HealthKit signals became pinnable; the enum still bounds each id to
|
||||
// one of the catalogue ids.
|
||||
.max(40),
|
||||
//
|
||||
// Raised again to 50 when the four clinical signals joined the
|
||||
// catalogue (42 ids). The cap only has to clear the catalogue size —
|
||||
// the enum bounds each entry and the id set is closed — so it carries
|
||||
// headroom rather than tracking the count exactly.
|
||||
.max(50),
|
||||
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
|
||||
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
|
||||
// so v1.4.15 clients that don't know the field can still PUT.
|
||||
@@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// v1.4.18 — preserve any per-chart overlay prefs that the client
|
||||
// didn't send. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order; chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
|
||||
// wiped here on a subsequent layout save. `selectedScoreRings` and
|
||||
// `heroRingOrder` ride the same preserve-when-absent contract — an
|
||||
// older client's layout save must not reset either choice. One
|
||||
// stored-layout read covers all fallbacks.
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedScoreRings: ScoreRingId[] | undefined =
|
||||
parsed.data.selectedScoreRings;
|
||||
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedScoreRings === undefined ||
|
||||
mergedHeroRingOrder === undefined
|
||||
) {
|
||||
// Preserve-when-absent: every field the client didn't send is carried
|
||||
// forward from the stored layout rather than clamped to its default by
|
||||
// the serializer. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order, while chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
|
||||
// Settings, and `comparisonBaseline` is web-only — an omission from any
|
||||
// one surface must not reset a choice made on another.
|
||||
//
|
||||
// The preserve set is not a list maintained here: it is derived from
|
||||
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
|
||||
// every field of `DashboardLayout`. Adding a layout field without a
|
||||
// disposition fails typecheck, so the next field cannot silently miss this
|
||||
// merge the way `comparisonBaseline` did. One stored-layout read covers
|
||||
// every fallback, and the read is skipped entirely when the client sent
|
||||
// the full set.
|
||||
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
if (layoutNeedsPreserveRead(toSerialize)) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
@@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
if (mergedChartOverlayPrefs === undefined) {
|
||||
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
|
||||
}
|
||||
if (mergedScoreRings === undefined) {
|
||||
mergedScoreRings = existingLayout.selectedScoreRings;
|
||||
}
|
||||
if (mergedHeroRingOrder === undefined) {
|
||||
mergedHeroRingOrder = existingLayout.heroRingOrder;
|
||||
}
|
||||
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
|
||||
}
|
||||
const normalized = serializeDashboardLayout({
|
||||
...parsed.data,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
selectedScoreRings: mergedScoreRings,
|
||||
heroRingOrder: mergedHeroRingOrder,
|
||||
} as DashboardLayout);
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
|
||||
it("updates manufacturer + doseStrength when supplied", async () => {
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq({
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBe("Example Pharma");
|
||||
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
|
||||
});
|
||||
|
||||
it("leaves both untouched when the request omits them", async () => {
|
||||
// Absent must mean "I have nothing to say", never "blank it" — a client
|
||||
// that does not know the fields must not wipe what another populated.
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect("manufacturer" in arg.data).toBe(false);
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a value on an explicit null", async () => {
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
id: "i1",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBeNull();
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,8 @@ export const PATCH = apiHandler(
|
||||
markAsUsedUp,
|
||||
printedExpiry,
|
||||
unitsRemaining,
|
||||
manufacturer,
|
||||
doseStrength,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
@@ -151,6 +153,16 @@ export const PATCH = apiHandler(
|
||||
? {}
|
||||
: { notesEncrypted: encryptNote(notes), notes: null };
|
||||
|
||||
// Carton labelling follows the same absent-means-untouched rule as
|
||||
// `notes`: an omitted field must not blank a value the other client
|
||||
// populated. Built field by field, never spread from the parsed body.
|
||||
const labellingData: {
|
||||
manufacturer?: string | null;
|
||||
doseStrength?: string | null;
|
||||
} = {};
|
||||
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
|
||||
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
|
||||
|
||||
const updated = await prisma.medicationInventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
@@ -159,6 +171,7 @@ export const PATCH = apiHandler(
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
...labellingData,
|
||||
...notesData,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
|
||||
it("persists manufacturer + doseStrength when the client sends them", async () => {
|
||||
// The native pen list uses these two as its headline and subhead. With
|
||||
// nowhere on the server to hold them, a container registered on the web
|
||||
// could not be rendered there at all.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
|
||||
id: "inv-new",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
unitsTotal: 4,
|
||||
containerType: "PEN",
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
manufacturer: "Example Pharma",
|
||||
doseStrength: "5 mg/0.5 ml",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("stores null for both when the client omits them", async () => {
|
||||
// A plain supply row (blister, bottle) carries no carton labelling, and
|
||||
// every row that predates the columns is truthfully null.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
|
||||
id: "inv-new",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
unitsTotal: 30,
|
||||
containerType: "BLISTER",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
manufacturer: null,
|
||||
doseStrength: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,8 +121,15 @@ export const POST = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
parsed.data;
|
||||
const {
|
||||
unitsTotal,
|
||||
containerType,
|
||||
printedExpiry,
|
||||
purchasedAt,
|
||||
manufacturer,
|
||||
doseStrength,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
const created = await prisma.medicationInventoryItem.create({
|
||||
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
|
||||
@@ -135,6 +142,8 @@ export const POST = apiHandler(
|
||||
containerType: containerType ?? "OTHER",
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
manufacturer: manufacturer ?? null,
|
||||
doseStrength: doseStrength ?? null,
|
||||
notes: notes ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user