chore(release): v1.4.38

Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 19:07:03 +02:00
1 parent fe64f66c7f
commit a550031a84
66 files changed
+7910 -3717

No files matched your search

@@ -30,7 +30,10 @@ import {
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
import {
drainPerSampleCumulative,
DRAIN_CUMULATIVE_CUTOFF_HOURS,
} from "@/lib/measurements/drain-per-sample-cumulative";
interface DrainBody {
userId?: string;
@@ -60,6 +63,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
const lines: string[] = [];
// v1.4.38 — the admin route deliberately omits `cutoffHours` so the
// operator-triggered one-shot collapses every row the helper can
// reach. `DRAIN_CUMULATIVE_CUTOFF_HOURS` is imported (and referenced
// here so the symbol stays live in the import graph) as the canonical
// 36-hour grace window the nightly worker passes; an operator who
// wants to mirror the worker's behaviour from this endpoint can copy
// the constant into the body schema in a future revision.
void DRAIN_CUMULATIVE_CUTOFF_HOURS;
const summary = await drainPerSampleCumulative(prisma, {
userId,
dryRun,
+103 -19
View File
@@ -83,13 +83,22 @@ export const GET = apiHandler(async (request?: Request) => {
// mount, and the Coach drawer all hit this endpoint within seconds
// of each other; the 60s TTL converts the 7.99s combined dashboard
// wait to a Map lookup on every subsequent caller.
const body = await cached(
const cachedBody = await cached(
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
annotate,
);
// v1.4.38 — `lastSeenByType` lives inside the cached envelope as
// `{ lastSeenAt }` only; derive the `daysAgo` caption per call so
// the staleness label updates correctly across day boundaries.
// The cache TTL is 60s, but the day boundary it can straddle would
// otherwise leave the cached `daysAgo` off by one until the next
// refresh. Re-deriving on read is a single Date subtraction per
// type and stays on the hot path's Map-lookup side of the cache.
const body = enrichLastSeenDaysAgo(cachedBody);
const response = apiSuccess(body);
// v1.4.34 IW-B — bfcache-friendly directive so back-forward navigation
// restores the dashboard from memory. Per `src/lib/http/cache-headers.ts`:
@@ -104,6 +113,44 @@ export const GET = apiHandler(async (request?: Request) => {
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
/**
* v1.4.38 — derive `daysAgo` from the cached `lastSeenAt` ISO string
* per request. The cached envelope deliberately omits `daysAgo` so
* the dashboard tile staleness caption stays correct across day
* boundaries even when the body has been sitting in the 60s LRU.
*
* Returns a shallow-copied envelope with the enriched
* `lastSeenByType` map; everything else is passed through unchanged.
*/
function enrichLastSeenDaysAgo<
T extends {
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
},
>(
body: T,
): Omit<T, "lastSeenByType"> & {
lastSeenByType: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
>;
} {
const nowMs = Date.now();
const enriched: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
> = {};
for (const [type, slot] of Object.entries(body.lastSeenByType)) {
if (slot === null) {
enriched[type] = null;
continue;
}
const lastMs = new Date(slot.lastSeenAt).getTime();
const daysAgo = Math.floor((nowMs - lastMs) / (24 * 60 * 60 * 1000));
enriched[type] = { lastSeenAt: slot.lastSeenAt, daysAgo };
}
return { ...body, lastSeenByType: enriched };
}
/**
* v1.4.34 IW-G — the heavy default-slice body, lifted out of the route
* handler so `cached()` can wrap it. Returns the raw JSON payload; the
@@ -163,13 +210,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// tiles whose latest reading is older than a week. The series read
// already orders ascending (`fetchMeasurementSeriesChunked`'s stable
// `(measuredAt, id)` order); the last entry is the freshest sample.
// Surface as ISO + a server-computed `daysAgo` so the client never
// has to do tz-aware date math to colour the tile.
const nowForStaleness = new Date();
const lastSeenByType: Record<string, {
lastSeenAt: string;
daysAgo: number;
} | null> = {};
//
// v1.4.38 — the cached body now carries only `lastSeenAt` (ISO);
// `daysAgo` is derived in the GET wrapper per call. Storing the
// computed delta inside the cache let the value stale across day
// boundaries (60s TTL straddling midnight could surface a "vor 3d"
// caption that should read "vor 4d"). The ISO timestamp does not
// age, so re-deriving on read costs nothing and pins the caption
// to the real wall-clock at every request.
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
let totalRowsReadForAggregate = 0;
const measurementsByType = await Promise.all(
@@ -184,13 +233,8 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// most-recent point without an extra pass.
const latest = measurements.at(-1);
if (latest) {
const daysAgo = Math.floor(
(nowForStaleness.getTime() - latest.measuredAt.getTime()) /
(24 * 60 * 60 * 1000),
);
lastSeenByType[type] = {
lastSeenAt: latest.measuredAt.toISOString(),
daysAgo,
};
} else {
lastSeenByType[type] = null;
@@ -309,6 +353,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
*/
let bpInTargetPctPriorMonth: number | null = null;
let bpInTargetPctPriorYear: number | null = null;
/**
* v1.4.38 — prior-week BP-in-target pct so the Health-Score
* week-over-week delta reflects BP movement. Pre-v1.4.38 the
* Health-Score helper pinned both windows to the same value, so the
* BP pillar always zeroed out of the delta. Computed by running a
* second `computeBpInTargetFastPath` against `now - 7d` and reading
* the resulting `last30Days.pct`.
*/
let bpInTargetPctPriorWeek: number | null = null;
const bpTargets = getBpTargets(user.dateOfBirth);
if (bpTargets) {
const now = new Date();
@@ -323,18 +376,43 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// a `path: "rollup" | "live"` annotate so prod logs prove which
// branch fired. See `bp-in-target-fast-path.ts` for the
// documented per-day-mean approximation.
const windows = await computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
});
//
// v1.4.38 — two parallel runs: one anchored at `now` for the
// standard windows, one anchored at `now - 7d` so the Health-Score
// helper has a real prior-week BP pct to feed into its previous-
// window snapshot. The two probes share the coverage map and the
// same rollup / live branch decision; the extra call is one extra
// pair of `readRollupBuckets` reads on the rollup path and is a
// no-op when the user has no BP rows at all.
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [windows, windowsPriorWeek] = await Promise.all([
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now,
coverage,
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
// to the live SQL path when the user is more than 3 hours
// from UTC, where the rollup table's UTC-midnight day-key
// would slip a calendar day relative to the live aggregator's
// window cuts.
userTz,
}),
computeBpInTargetFastPath({
userId: user.id,
targets: bpTargets,
now: sevenDaysAgo,
coverage,
userTz,
}),
]);
bpInTargetPct = windows.last30Days?.pct ?? null;
bpInTargetPct7d = windows.last7Days?.pct ?? null;
bpInTargetPct30d = windows.last30Days?.pct ?? null;
bpInTargetPctAllTime = windows.allTime?.pct ?? null;
bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null;
bpInTargetPctPriorYear = windows.priorYear?.pct ?? null;
bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null;
}
// Per-context glucose summaries (canonical mg/dL).
@@ -394,6 +472,12 @@ async function buildAnalyticsResponse(user: AuthedUser) {
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
bpInTargetPct,
// v1.4.38 — feed the prior-week BP pct into the Health-Score
// helper's previous-window snapshot so the week-over-week delta
// reflects BP movement instead of zeroing out of the BP pillar.
// Computed alongside the current windows via a second
// `computeBpInTargetFastPath` run anchored at `now - 7d`.
bpInTargetPctPriorWeek,
heightCm: user.heightCm ?? null,
now: new Date(),
coverage,
@@ -3,8 +3,13 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurement: { groupBy: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// v1.4.38 W-F — the route now uses `$queryRaw` for the per-type
// latest reading, the 7-day rollup-bucket sparkline, and the
// 365-day distinct-day streak set. Three raw calls per request,
// in the order the Promise.all dispatches them.
$queryRaw: vi.fn(),
},
}));
@@ -32,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -53,7 +59,12 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
// v1.4.38 W-F — three `$queryRaw` invocations land inside the
// Promise.all in the order [latest7d, sparkline, streakDays]. The
// analytics-cache wrap is also process-local, so we must reset it
// between tests to keep cross-test isolation.
__resetAllCachesForTests();
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
// v1.4.33 maintainer-item-1 — the route now issues a `groupBy` for
// per-type all-time count + most-recent timestamp. Default to an
// empty aggregate so legacy tests keep their "no data" expectations.
+210 -61
View File
@@ -8,6 +8,34 @@
* The shape is fixed for the iOS client and intentionally normalised —
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
* (BLOOD_PRESSURE_SYS etc.).
*
* Cold-mount performance (v1.4.38 W-F)
* ------------------------------------
* The legacy shape ran an unbounded `prisma.measurement.findMany` over
* the trailing 7 days plus a second unbounded `findMany` over the
* trailing 365 days for the streak-day set. On a power-user account
* (Apple Health step samples ≈ thousands per day) those two queries
* dominated the wall-clock at ~4.6 s cold even though the DOWNSTREAM
* JS code only ever needed:
* - the latest value per type within the 7-day window
* - a small sparkline (≤7 daily aggregates) per type
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
*
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
* - `DISTINCT ON (type)` over the 7-day window → one row per type
* carrying the latest value + measuredAt (≤ N_metrics rows).
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
* Sparkline points become the bucket means rather than individual
* raw samples, which is a *smoother* trend signal for high-volume
* metrics like ACTIVITY_STEPS and bounded for every other metric.
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
*
* The whole response is wrapped in the 60 s analytics LRU cache so
* subsequent iOS polls inside the window hit memory. Invalidation runs
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
* `invalidateUserMedications` hooks.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -18,6 +46,7 @@ import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -161,6 +190,34 @@ function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
return { currentDays, longest };
}
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the most recent reading
* per measurement type inside the 7-day window. One row per metric
* the user touched in the window; replaces the legacy unbounded
* `prisma.measurement.findMany`. */
interface LatestIn7dRow {
type: MeasurementType;
value: number;
measured_at: Date;
}
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
* window. At most 7 buckets per metric × N metrics — bounded by
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count. */
interface SparklineRow {
type: MeasurementType;
bucket_start: Date;
mean: number;
}
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
* The route only needs YYYY-MM-DD strings, so the aggregate runs
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
* the day-keys directly. At most 365 rows; vs. the legacy `findMany`
* which could return tens of thousands. */
interface ActivityDayRow {
day_key: string;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
@@ -171,6 +228,42 @@ export const GET = apiHandler(async () => {
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU.
// Subsequent iOS polls inside the TTL hit memory; measurement /
// mood / medication writes invalidate the user-bucket via the
// existing `invalidateUserMeasurements` + the v1.4.38-extended
// `invalidateUserMedications` hooks.
const body = await cached(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildDashboardSummary>
>>,
`${user.id}|dashboard-summary`,
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
annotate,
);
return apiSuccess(body);
});
interface SummaryBuilderContext {
greetingName: string;
locale: Locale;
}
function buildContext(
user: { displayName: string | null; username: string; locale: string | null },
): SummaryBuilderContext {
const greetingName = user.displayName ?? user.username;
const locale: Locale =
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
return { greetingName, locale };
}
async function buildDashboardSummary(
userId: string,
userTz: string,
ctx: SummaryBuilderContext,
) {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
const streakWindowStart = new Date(
@@ -186,46 +279,107 @@ export const GET = apiHandler(async () => {
...measurementTypeEnum.options,
] as MeasurementType[];
// v1.4.33 maintainer-item-1 — pull per-type all-time counts + latest
// timestamps alongside the 7-day sparkline window so the iOS tile
// can keep rendering when the recent window is empty but the user
// has logged the metric before. `groupBy` with `_count._all` +
// `_max(measuredAt)` is a single Postgres aggregate round-trip; no
// additional row materialisation beyond the aggregate values
// themselves.
const [recentMeasurements, allTimeAggregate, todaysIntakes, streakActivity] =
await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: measurementTypes },
measuredAt: { gte: sevenDaysAgo },
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
// v1.4.38 W-F — per-sub-query wall-clock timing for prod observability.
// Captured on the cache-miss path; the cache-hit path skips this whole
// builder. Reported under `meta.dashboard.sub_*_ms` so the next
// perf-verify can attribute a regression to a specific sub-query
// without re-instrumenting the route.
const timings: Record<string, number> = {};
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
const t0 = Date.now();
const result = await builder();
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
return result;
};
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
// unbounded ones. Row counts are now:
// - latestIn7d: ≤ N metric types (one row per type via DISTINCT ON)
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80)
// - allTimeAggregate: ≤ N metric types (unchanged)
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
// - streakActivity: ≤ daily intake count × 365 (unchanged)
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
const [
latestIn7d,
sparkBuckets,
allTimeAggregate,
todaysIntakes,
streakActivity,
measurementStreakDays,
] = await Promise.all([
time("latest7d", () =>
prisma.$queryRaw<LatestIn7dRow[]>`
SELECT DISTINCT ON (m."type")
m."type" AS type,
m."value"::double precision AS value,
m."measured_at" AS measured_at
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${sevenDaysAgo}
ORDER BY m."type", m."measured_at" DESC
`,
),
time("sparkline", () =>
prisma.$queryRaw<SparklineRow[]>`
SELECT
r."type" AS type,
r."bucket_start" AS bucket_start,
r."mean"::double precision AS mean
FROM measurement_rollups r
WHERE r."user_id" = ${userId}
AND r."granularity" = 'DAY'
AND r."bucket_start" >= ${sevenDaysAgo}
ORDER BY r."type", r."bucket_start" ASC
`,
),
time("allTime", () =>
prisma.measurement.groupBy({
by: ["type"],
where: { userId: user.id, type: { in: measurementTypes } },
where: { userId, type: { in: measurementTypes } },
_count: { _all: true },
_max: { measuredAt: true },
}),
),
time("todaysIntakes", () =>
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
userId,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
}),
),
time("streakIntakes", () =>
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
userId,
scheduledFor: { gte: streakWindowStart },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
select: { takenAt: true, scheduledFor: true },
}),
]);
),
// v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany`
// that pulled every raw row in the window only to collapse them to
// day-keys in JS. The SQL `date_trunc` returns at most 365 rows
// even on accounts with millions of step samples. The `userTz`
// conversion is folded server-side so the day boundary matches
// `userDayKey`'s output (it formats the timestamp in the user's
// tz before slicing YYYY-MM-DD).
time("streakDays", () =>
prisma.$queryRaw<ActivityDayRow[]>`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
'YYYY-MM-DD'
) AS day_key
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${streakWindowStart}
`,
),
]);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
@@ -253,20 +407,13 @@ export const GET = apiHandler(async () => {
};
}
// v1.4.38 W-F — activity-day set assembled from the bounded streak
// queries. The measurement side already arrives as YYYY-MM-DD keys
// from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical
// to what `userDayKey(measuredAt, userTz)` would produce in JS.
const activityDays = new Set<string>();
for (const m of recentMeasurements)
activityDays.add(userDayKey(m.measuredAt, userTz));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
const wider = await prisma.measurement.findMany({
where: {
userId: user.id,
measuredAt: { gte: streakWindowStart },
},
select: { measuredAt: true },
});
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
for (const row of measurementStreakDays) {
if (row.day_key) activityDays.add(row.day_key);
}
for (const e of streakActivity) {
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
@@ -274,24 +421,28 @@ export const GET = apiHandler(async () => {
const streak = computeStreak(activityDays, userTz);
// Per-type latest + sparkline.
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
for (const m of recentMeasurements) {
const list = byType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
byType.set(m.type, list);
// v1.4.38 W-F — per-type latest (one row per type) + sparkline
// (bucket means per day) assembled from the two new SQL aggregates.
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
for (const row of latestIn7d) {
latestByType.set(row.type, {
value: Number(row.value),
at: new Date(row.measured_at),
});
}
const sparkByType = new Map<MeasurementType, number[]>();
for (const row of sparkBuckets) {
const list = sparkByType.get(row.type) ?? [];
list.push(Number(row.mean));
sparkByType.set(row.type, list);
}
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
const list = byType.get(type);
if (!list || list.length === 0) return null;
return list[list.length - 1];
return latestByType.get(type) ?? null;
}
function sparkOf(type: MeasurementType): number[] {
const list = byType.get(type);
if (!list) return [];
return list.map((p) => p.value);
return sparkByType.get(type) ?? [];
}
const metrics: MetricCard[] = [];
@@ -324,10 +475,9 @@ export const GET = apiHandler(async () => {
// Blood pressure (paired sys/dia)
{
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
const latestSys = sysList[sysList.length - 1] ?? null;
const latestDia = diaList[diaList.length - 1] ?? null;
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
const sysSpark = sparkOf("BLOOD_PRESSURE_SYS");
const sysMeta = metaForType("BLOOD_PRESSURE_SYS");
const diaMeta = metaForType("BLOOD_PRESSURE_DIA");
// BP is a paired metric — the tile is "alive" whenever either side
@@ -349,8 +499,8 @@ export const GET = apiHandler(async () => {
latestValue: latestSys?.value ?? null,
secondaryValue: latestDia?.value ?? null,
unit: METRIC_UNITS.bloodPressure,
trend: trendOf(sysList.map((p) => p.value)),
sparkline: sysList.map((p) => p.value),
trend: trendOf(sysSpark),
sparkline: sysSpark,
updatedAt:
latestSys?.at?.toISOString() ??
latestDia?.at?.toISOString() ??
@@ -443,14 +593,13 @@ export const GET = apiHandler(async () => {
(e) => e.takenAt !== null && !e.skipped,
).length;
const greetingName = user.displayName ?? user.username;
const locale: Locale =
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
const t = getServerTranslator(locale).t;
const t = getServerTranslator(ctx.locale).t;
return apiSuccess({
annotate({ meta: timings });
return {
greeting: {
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
salutation: t("dashboard.greetingSalutation", { name: ctx.greetingName }),
date: now.toISOString(),
},
streak: {
@@ -465,5 +614,5 @@ export const GET = apiHandler(async () => {
highlightInsight: null,
metrics,
lastUpdated: now.toISOString(),
});
});
};
}
@@ -0,0 +1,189 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* v1.4.38 W-C M6 — Coach API route gate inventory.
*
* The web surfaces are protected by the `flags.coach` short-circuit in
* every Coach-bearing component (see
* `src/lib/feature-flags/__tests__/coach-cascade.test.tsx`). The
* server-side mirror is `requireAssistantSurface("coach")` in every
* Coach-only route handler. Without a discovery test, a future
* contributor who lands a new `/api/insights/<foo>/route.ts` whose body
* touches the Coach stack would have to remember the gate by hand — a
* silent miss leaks the surface even when the operator turned the
* Coach matrix off.
*
* The walk below scans every `route.ts` under `src/app/api/insights/`
* and groups each handler into one of three buckets:
*
* 1. Coach-gated — file imports + invokes
* `requireAssistantSurface("coach")`.
* 2. Other-gated — file invokes `requireAssistantSurface()` with a
* different surface (`insightStatus`,
* `correlations`, `briefing`, `healthScoreExplainer`).
* 3. Allowlisted — non-Coach Insights routes that don't gate on
* the matrix at all (provider chain, settings,
* targets, glp1-timeline, feedback). Allowlist
* lives alongside the test for easy review.
*
* Anything that doesn't fit one of the three buckets is an orphan and
* fails the test by name so the fix is one search-and-add.
*/
const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// Non-Coach assistant surfaces — gated on a sibling sub-flag.
"src/app/api/insights/blood-pressure-status/route.ts",
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/correlations/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
"src/app/api/insights/pulse-status/route.ts",
"src/app/api/insights/weight-status/route.ts",
];
const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
// Settings + chain inventory — surface-agnostic infra reads. The
// settings UI itself is server-rendered and the chain page hides
// when no provider is configured; neither one carries assistant
// prose. Disabling Coach must not break these reads — the user can
// still inspect and reconfigure the chain that gets re-enabled.
"src/app/api/insights/provider-chain/route.ts",
"src/app/api/insights/settings/route.ts",
// Read-only data feeds — `/targets` is a non-assistant Zielwerte
// page driven by classifications, not LLM prose; the GLP-1 timeline
// is the same data the medication page paints whether the assistant
// is on or off. Recommendation feedback (`/feedback`) records a
// thumbs-up/-down on a NON-Coach recommendation row that was
// produced before the operator flipped the flag; gating it would
// mean the operator's mid-flight toggle erases the user's
// ability to dispose of stale recommendations.
"src/app/api/insights/feedback/route.ts",
"src/app/api/insights/glp1-timeline/route.ts",
"src/app/api/insights/targets/route.ts",
];
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
const ANY_GATE_NEEDLE = "requireAssistantSurface(";
/**
* Return true when the file contains the needle on a line that is NOT
* a pure comment. A documentation comment that mentions the gate
* (`// requireAssistantSurface("coach") protects this surface`) must
* not satisfy the gate-presence check — otherwise a contributor who
* deletes the actual call but leaves the docstring would slip through.
*/
function fileHasGateCall(text: string, needle: string): boolean {
return text.split("\n").some((line) => {
if (!line.includes(needle)) return false;
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
return true;
});
}
/**
* Walk every `route.ts` under `src/app/api/insights/` (including
* dynamic segments under `[id]`). Returns POSIX-style paths relative
* to the repo root.
*/
function findInsightsRouteFiles(): string[] {
// __dirname here is `src/app/api/insights/__tests__/`, so the
// insights root is one directory up.
const insightsRoot = resolve(__dirname, "..");
const repoRoot = resolve(insightsRoot, "..", "..", "..", "..");
const hits: string[] = [];
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "__tests__") continue;
if (entry === "node_modules") continue;
walk(full);
continue;
}
if (entry !== "route.ts") continue;
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
walk(insightsRoot);
return hits.sort();
}
describe("Coach API route gate inventory", () => {
it("every Coach-bearing insights route imports requireAssistantSurface('coach')", () => {
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
const routes = findInsightsRouteFiles();
expect(routes.length).toBeGreaterThan(0);
const allowlistOther = new Set(NON_COACH_GATED_ROUTES);
const allowlistNone = new Set(NOT_COACH_OWNED_ROUTES);
const orphans: Array<{ path: string; reason: string }> = [];
for (const path of routes) {
const full = resolve(repoRoot, path);
const text = readFileSync(full, "utf8");
const hasCoachGate = fileHasGateCall(text, COACH_GATE_NEEDLE);
const hasAnyGate = fileHasGateCall(text, ANY_GATE_NEEDLE);
if (hasCoachGate) continue;
if (allowlistOther.has(path)) {
// Sanity — the file should still gate on SOME assistant
// surface. A stale entry on this allowlist is misleading
// documentation; flip the failure mode so the orphan is
// reported with the right diagnosis.
if (!hasAnyGate) {
orphans.push({
path,
reason:
"listed in NON_COACH_GATED_ROUTES but has no requireAssistantSurface() call at all",
});
}
continue;
}
if (allowlistNone.has(path)) continue;
orphans.push({
path,
reason: `missing requireAssistantSurface("coach"); add the gate or move the route onto an allowlist (NON_COACH_GATED_ROUTES / NOT_COACH_OWNED_ROUTES)`,
});
}
expect(
orphans,
[
"Coach API route gate inventory found unaccounted-for handler(s):",
...orphans.map((o) => ` - ${o.path}: ${o.reason}`),
].join("\n"),
).toEqual([]);
});
it("allowlists do not reference deleted route files", () => {
const known = new Set(findInsightsRouteFiles());
const stale = [...NON_COACH_GATED_ROUTES, ...NOT_COACH_OWNED_ROUTES].filter(
(path) => !known.has(path),
);
expect(
stale,
[
"Allowlist entries point to files that no longer exist —",
"delete the stale entries from",
"`src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts`:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});
+9
View File
@@ -13,6 +13,7 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import {
deleteConversation,
@@ -25,6 +26,10 @@ interface RouteCtx {
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
// the conversation reader is part of the Coach stack (encrypted
// assistant prose), so a disabled surface must 403 here too.
await requireAssistantSurface("coach");
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
@@ -44,6 +49,10 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
// v1.4.38 W-C M6 — same gate as GET; a disabled Coach surface
// means the user can't reach the conversation list anyway, so the
// delete affordance must stay behind the same kill-switch.
await requireAssistantSurface("coach");
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
@@ -41,6 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { resolveCoachFeedbackAttribution } from "@/lib/ai/feedback-attribution";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -55,6 +56,11 @@ interface RouteContext {
async function handlePost(request: NextRequest, ctx: RouteContext) {
const { user } = await requireAuth();
// v1.4.38 W-C M6 — Coach message feedback is a Coach surface
// affordance; gate it on the same operator kill-switch so a
// disabled Coach matrix can't be skirted through the feedback
// endpoint.
await requireAssistantSurface("coach");
const { id: messageId } = await ctx.params;
// Per-user rate limit — same shape as the recommendation feedback
+5 -3
View File
@@ -100,9 +100,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
measuredAt: { gte: dayStart, lt: dayEnd },
},
orderBy: { measuredAt: sortDir },
// Cap the drill-down — a phone-only stepCount day can hit a few
// hundred rows; the route ceiling keeps a pathological day bounded.
take: Math.min(limit, 1000),
// v1.4.38 — the 1000-row cap now lives on the validator (refine
// on `(limit, dayKey)` returning 422 when a caller asks for
// more). The route reads `limit` straight through because the
// validator has already clamped it.
take: limit,
});
annotate({
action: { name: "measurement.list" },