mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can re-stamp them, the comprehensive fingerprint folds in the comparison baseline and generation language so toggling either regenerates once, and profile, threshold and source-priority writes evict the analytics, targets and derived caches they feed. CSV export neutralises spreadsheet formula prefixes on free-text cells while numeric columns stay typed, the derived batch validates its type token against the measurement enum and carries a per-user rate limit, and a dose synced from the phone app counts as the interactive write it is. Background cache refresh failures log a structured event instead of vanishing, the warm queue runs visit-triggered jobs alongside the nightly walk, the formatter memo evicts one entry instead of flushing wholesale with its option objects frozen, the targets cold build bounds its fan-out, and time in bed unions every writer's envelope while stages stay with the richest recorder.
This commit is contained in:
1 parent
95f21bfa4d
commit
f790e14edd
40 files changed
+1141
-287
No files matched your search
@@ -4224,11 +4224,11 @@ paths:
|
||||
maxLength: 1024
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id
|
||||
422s; at most 24 tokens; duplicates collapse.
|
||||
422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
required: true
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s;
|
||||
at most 24 tokens; duplicates collapse.
|
||||
a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
responses:
|
||||
"200":
|
||||
description: The map of derived-metric values, keyed by token.
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The analytics aggregator reads this row on every call and
|
||||
* the read path is cheap (one column) — no caching layer to flush.
|
||||
* the read path is cheap (one column). The PUT flushes the per-user
|
||||
* profile-derived caches (`invalidateUserProfile`) because the ladder
|
||||
* decides which source's rows the cached targets / derived / analytics
|
||||
* payloads were built from.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
@@ -77,6 +81,12 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
data: { sourcePriorityJson: parsed.data },
|
||||
});
|
||||
|
||||
// The priority ladder drives every aggregator's source pick (the
|
||||
// analytics rank SQL, the rollup read-swap, the targets grid's sleep
|
||||
// dedup) — drop the cached payloads so the reorder reflects on the
|
||||
// next read instead of after the TTL.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("user.source-priority.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -40,15 +44,26 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({
|
||||
status: "ok",
|
||||
value: { metric: args.metric, n: 1 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -59,7 +74,12 @@ import {
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -74,6 +94,7 @@ beforeEach(() => {
|
||||
// v1.16.8 — the route reads through the module-scope `insightsDerived`
|
||||
// server cache; reset it so every test starts on a cold cell.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
@@ -85,9 +106,7 @@ beforeEach(() => {
|
||||
describe("GET /api/insights/derived/batch", () => {
|
||||
it("resolves several metrics into one keyed map", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(
|
||||
makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"),
|
||||
);
|
||||
const res = await callGet(makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Record<string, { metric: string; status: string }> };
|
||||
@@ -132,6 +151,38 @@ describe("GET /api/insights/derived/batch", () => {
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when a sub-target type is outside the MeasurementType enum", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// The colon sub-target must be a closed-enum member BEFORE it joins
|
||||
// the compute dispatch and the cache key — an arbitrary token would
|
||||
// mint unbounded cache cells and echo through coverage / provenance.
|
||||
const res = await callGet(makeReq("VITALS_BASELINE:NOT_A_TYPE"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid MeasurementType sub-target", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE:RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(computeDerivedMetric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "RESTING_HEART_RATE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("429s when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await callGet(makeReq("BMI"));
|
||||
expect(res.status).toBe(429);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"insights-derived-batch:user-1",
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on a missing metrics param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
@@ -169,10 +220,7 @@ describe("GET /api/insights/derived/batch — server cache (v1.16.8)", () => {
|
||||
const body = (await second.json()) as {
|
||||
data: { metrics: Record<string, { status: string }> };
|
||||
};
|
||||
expect(Object.keys(body.data.metrics).sort()).toEqual([
|
||||
"BMI",
|
||||
"READINESS",
|
||||
]);
|
||||
expect(Object.keys(body.data.metrics).sort()).toEqual(["BMI", "READINESS"]);
|
||||
});
|
||||
|
||||
it("hits the same cell regardless of token order", async () => {
|
||||
|
||||
@@ -25,14 +25,12 @@ import { z } from "zod/v4";
|
||||
import pLimit from "p-limit";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
@@ -77,6 +75,10 @@ const batchQuerySchema = z.object({
|
||||
* sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type.
|
||||
* Unknown ids are returned as `invalid` so the route 422s the whole
|
||||
* request rather than silently dropping a metric (the closed-enum rule).
|
||||
* The `type` part is held to the SAME closed-enum rule: it must be a
|
||||
* member of the `MeasurementType` enum BEFORE it joins the compute
|
||||
* dispatch and the cache key — an arbitrary token would otherwise mint
|
||||
* unbounded cache cells and be echoed through coverage / provenance.
|
||||
* Duplicate keys collapse to one item (last write wins) so a repeated
|
||||
* token can't inflate the fan-out.
|
||||
*/
|
||||
@@ -96,6 +98,13 @@ function parseBatchItems(csv: string): {
|
||||
invalid.push(metric);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
type !== null &&
|
||||
!Object.prototype.hasOwnProperty.call(MeasurementType, type)
|
||||
) {
|
||||
invalid.push(token);
|
||||
continue;
|
||||
}
|
||||
const key = type ? `${metric}:${type}` : metric;
|
||||
seen.set(key, { metric, type, key });
|
||||
}
|
||||
@@ -106,6 +115,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
// Per-user limiter, same posture as the compliance routes: the cold
|
||||
// build fans out up to 24 rollup walks, so an unthrottled caller could
|
||||
// hammer the pool past the cache. 30/min covers the dashboard's one
|
||||
// canonical read plus retries with a wide margin.
|
||||
const rl = await checkRateLimit(
|
||||
`insights-derived-batch:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Too many derived-metric requests. Please retry later.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = batchQuerySchema.safeParse({
|
||||
metrics: request.nextUrl.searchParams.get("metrics"),
|
||||
});
|
||||
@@ -126,10 +151,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "insights.derived.batch.invalid-metric" },
|
||||
meta: { invalid_count: invalid.length },
|
||||
});
|
||||
return apiError(
|
||||
`Unknown derived metric id(s): ${invalid.join(", ")}`,
|
||||
422,
|
||||
);
|
||||
return apiError(`Unknown derived metric id(s): ${invalid.join(", ")}`, 422);
|
||||
}
|
||||
if (items.length === 0 || items.length > MAX_METRICS) {
|
||||
annotate({
|
||||
@@ -255,8 +277,7 @@ async function buildDerivedBatch(input: {
|
||||
coverage: derived.coverage,
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason:
|
||||
derived.status === "insufficient" ? derived.reason : null,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
// Deterministic assessment only (cheap, pure). The AI-warm prose
|
||||
// stays a single-route concern; here every assessable `ok` score
|
||||
// carries its template "why" text, everything else stays null.
|
||||
|
||||
@@ -723,14 +723,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// provider call. The user-initiated POST itself stays un-gated: an
|
||||
// explicit regenerate request is honoured even on unchanged data
|
||||
// (it is already bounded by the hourly rate limit above). The
|
||||
// about-me text joins the fingerprint — it feeds the prompt and can
|
||||
// change with no data change (Coach remember, Settings → AI edit) —
|
||||
// and the shape MUST match the gate in `comprehensive-generate.ts`,
|
||||
// composite shape MUST match the gate in `comprehensive-generate.ts`,
|
||||
// or every off-request warm after a manual regenerate re-pays a
|
||||
// full generation on unchanged data.
|
||||
// full generation on unchanged data. Beyond the features, three
|
||||
// prompt inputs that can change with NO data change join in:
|
||||
// - aboutMe (Coach remember / Settings → AI edit),
|
||||
// - comparisonBaseline (the comparison toggle adds/removes the
|
||||
// prior-period context block; `null` snapshot means "none"),
|
||||
// - generationLocale (the language the cached text renders in —
|
||||
// hashing it makes a locale switch regenerate exactly once, so
|
||||
// the briefing follows the reader; named `generationLocale`
|
||||
// because the canonicaliser strips `locale` keys as volatile).
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
comparisonBaseline: comparisonSnapshot?.baseline ?? "none",
|
||||
generationLocale: locale,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import pLimit from "p-limit";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
@@ -205,7 +206,11 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// wall-clock the production trace showed. All of them are independent
|
||||
// (the intake read depends only on the medication ids, so the pair
|
||||
// chains inside its own slot); fan them out together and the cold
|
||||
// build pays roughly the slowest query instead of the sum.
|
||||
// build pays roughly the slowest query instead of the sum. The
|
||||
// fan-out is bounded by the project's `p-limit(4)` discipline (parity
|
||||
// with the derived batch route) so one cold build never claims eight
|
||||
// connections from the shared Prisma pool at once.
|
||||
const limit = pLimit(4);
|
||||
const [
|
||||
dbUser,
|
||||
recentMeasurements,
|
||||
@@ -217,33 +222,37 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
glucoseRows,
|
||||
] = await Promise.all([
|
||||
// User profile (height / age / gender / thresholds / source priority).
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
// v1.11.5 — needed to collapse a dual-source sleep night to one
|
||||
// canonical source before reconstructing per-night asleep totals.
|
||||
sourcePriorityJson: true,
|
||||
},
|
||||
}),
|
||||
limit(() =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
// v1.11.5 — needed to collapse a dual-source sleep night to one
|
||||
// canonical source before reconstructing per-night asleep totals.
|
||||
sourcePriorityJson: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
// All measurements in the last 30 days for the generic type set.
|
||||
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
|
||||
// must not contribute to tile averages / latest values once iOS sync
|
||||
// starts emitting deletions.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
),
|
||||
// Absolute latest measurement per type within a one-year floor.
|
||||
//
|
||||
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
|
||||
@@ -257,40 +266,44 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// forward — which is the correct behaviour for the consistency strip
|
||||
// (the strip already gates on "fewer than 3 readings in 30 days" →
|
||||
// insufficientData).
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
),
|
||||
// Sleep stage rows for the per-night reconstruction (section 4).
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Active medications + their 30-day intake events (section 8). The
|
||||
// intake read depends on the medication ids, so the pair chains
|
||||
// inside this slot while riding the outer fan-out.
|
||||
(async () => {
|
||||
limit(async () => {
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
@@ -324,31 +337,35 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
},
|
||||
});
|
||||
return { activeMedications, intakeEvents };
|
||||
})(),
|
||||
// Mood day rollups (section 9).
|
||||
readMoodDayRollups(userId, moodSince),
|
||||
// Latest raw mood entry — bounded `take: 1` via the index.
|
||||
prisma.moodEntry.findFirst({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
// Mood day rollups (section 9).
|
||||
limit(() => readMoodDayRollups(userId, moodSince)),
|
||||
// Latest raw mood entry — bounded `take: 1` via the index.
|
||||
limit(() =>
|
||||
prisma.moodEntry.findFirst({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
),
|
||||
// Glucose rows (section 10). Same one-year floor as the
|
||||
// latest-per-type read above — the previous unbounded scan pulled a
|
||||
// heavy logger's full history back to Node on every cold build; a
|
||||
// context whose newest reading is over a year old now reports no
|
||||
// card, matching the per-type tile posture.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
}),
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
|
||||
@@ -893,7 +910,8 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
(sorted.length - mid);
|
||||
const diff = avgSecond - avgFirst;
|
||||
const threshold = avgFirst * 0.02;
|
||||
restingTrend = diff > threshold ? "up" : diff < -threshold ? "down" : "stable";
|
||||
restingTrend =
|
||||
diff > threshold ? "up" : diff < -threshold ? "down" : "stable";
|
||||
}
|
||||
|
||||
targets.push({
|
||||
@@ -1291,7 +1309,11 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// the bound the legacy code was missing.
|
||||
const recentRawMood = await prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: thirtyDaysAgoMood } },
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: thirtyDaysAgoMood },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
@@ -1316,9 +1338,7 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
|
||||
const moodAvg30 =
|
||||
moodDailyEvents.length > 0
|
||||
? Math.round(
|
||||
(moodSumScores / moodDailyEvents.length) * 10,
|
||||
) / 10
|
||||
? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10
|
||||
: null;
|
||||
|
||||
// Mood trend: compare first half to second half of last 30 days.
|
||||
|
||||
@@ -63,6 +63,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -296,6 +297,12 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
// The bulk endpoint is the iOS user's interactive intake path — the
|
||||
// write must HARD-EVICT the SWR buckets so the next read shows the
|
||||
// dose, never the pre-write stale payload.
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => {
|
||||
@@ -686,7 +693,10 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () =
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string; id?: string }> };
|
||||
data: {
|
||||
updated: number;
|
||||
entries: Array<{ status: string; id?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
|
||||
@@ -569,13 +569,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch. v1.16.8 — this is the background sync path (iOS bulk
|
||||
// ingest), so the SWR buckets are marked stale rather than hard-evicted:
|
||||
// the readers keep serving the prior list / compliance payloads while
|
||||
// one coalesced recompute warms each cell, instead of a high-frequency
|
||||
// sync busting every card into an inline cold rebuild.
|
||||
// ingested batch. v1.16.8 — hard-evict: this bulk endpoint is the iOS
|
||||
// user's INTERACTIVE intake path (a dose taken / skipped on the phone
|
||||
// syncs through here), so the very next read must show the user their
|
||||
// own action — a marked-stale SWR cell would hand back the pre-dose
|
||||
// payload. The genuinely background writers (the auto-miss cron in
|
||||
// `intake-auto-skip.ts`, slot dedup in `intake-slot-dedup.ts`) keep
|
||||
// the default mark-stale at their own call sites.
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — refresh one rollup row per distinct
|
||||
|
||||
@@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -99,6 +100,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
data: { thresholdsJson: merged },
|
||||
});
|
||||
|
||||
// Threshold overrides feed the targets grid's effective ranges and the
|
||||
// analytics snapshot — drop the cached payloads so the next read
|
||||
// reflects the new bands instead of serving the pre-edit grid.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("thresholds.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -142,6 +148,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// A reset changes the effective ranges back to the defaults — same
|
||||
// eviction as the PUT above.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("thresholds.reset", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -47,6 +47,40 @@ describe("toCSV", () => {
|
||||
const csv = toCSV(records);
|
||||
expect(csv).toContain("2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
// Spreadsheet formula injection (OWASP CSV injection): text cells
|
||||
// starting with a formula trigger are neutralised with a leading `'`.
|
||||
// Third-party text reaches these cells (mood notes via the moodLog
|
||||
// webhook), so a crafted payload must open as literal text.
|
||||
it("neutralises formula prefixes in text cells", () => {
|
||||
const records = [
|
||||
{ note: '=HYPERLINK("http://evil","x")' },
|
||||
{ note: "+SUM(A1:A9)" },
|
||||
{ note: "-2+3+cmd|' /C calc'!A0" },
|
||||
{ note: "@SUM(A1)" },
|
||||
{ note: "\tleading tab" },
|
||||
];
|
||||
const lines = toCSV(records).split("\n");
|
||||
// RFC 4180 quoting layers on top of the neutralised prefix (the
|
||||
// payload carries commas + quotes).
|
||||
expect(lines[1]).toBe('"\'=HYPERLINK(""http://evil"",""x"")"');
|
||||
expect(lines[2]).toBe("'+SUM(A1:A9)");
|
||||
expect(lines[3]).toBe("'-2+3+cmd|' /C calc'!A0");
|
||||
expect(lines[4]).toBe("'@SUM(A1)");
|
||||
expect(lines[5]).toBe("'\tleading tab");
|
||||
});
|
||||
|
||||
it("does not mangle numeric or boolean cells (negative numbers stay bare)", () => {
|
||||
const records = [{ delta: -5.2, count: 3, flagged: false }];
|
||||
const lines = toCSV(records).split("\n");
|
||||
// The number -5.2 is typed `number`, not text — no quote prefix.
|
||||
expect(lines[1]).toBe("-5.2,3,false");
|
||||
});
|
||||
|
||||
it("leaves benign text cells untouched", () => {
|
||||
const records = [{ note: "felt fine after the walk" }];
|
||||
expect(toCSV(records).split("\n")[1]).toBe("felt fine after the walk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMeasurementsForExport", () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
reconstructSleepSessions,
|
||||
@@ -33,7 +30,12 @@ function srcRow(
|
||||
minutes: number,
|
||||
source: MeasurementSource,
|
||||
): SleepStageRow {
|
||||
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes, source };
|
||||
return {
|
||||
measuredAt: new Date(iso),
|
||||
sleepStage: stage,
|
||||
value: minutes,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reconstructSleepNights", () => {
|
||||
@@ -81,9 +83,7 @@ describe("reconstructSleepNights", () => {
|
||||
});
|
||||
|
||||
it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => {
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T06:00:00.000Z", null, 423),
|
||||
];
|
||||
const rows: SleepStageRow[] = [row("2026-06-04T06:00:00.000Z", null, 423)];
|
||||
const nights = reconstructSleepNights(rows, "UTC");
|
||||
expect(nights).toHaveLength(1);
|
||||
expect(nights[0].asleepMinutes).toBe(423);
|
||||
@@ -574,7 +574,10 @@ describe("summarizeSleepNights", () => {
|
||||
row("2026-06-03T23:30:00.000Z", "DEEP", 150), // 23:00 → 01:30 Jun 4
|
||||
row("2026-06-04T04:00:00.000Z", "REM", 270), // 23:30 → 06:00 Jun 4
|
||||
];
|
||||
const { summary, latestNight } = summarizeSleepNights(rows, "Europe/Berlin");
|
||||
const { summary, latestNight } = summarizeSleepNights(
|
||||
rows,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(summary.count).toBe(2);
|
||||
expect(latestNight?.night).toBe("2026-06-04");
|
||||
// Full last night = 150 + 150 + 270 = 570 (not just a post-midnight slice).
|
||||
@@ -663,9 +666,7 @@ describe("reconstructSleepSessions total-safety (A5)", () => {
|
||||
it("never throws on a stage-less-only session", () => {
|
||||
// Legacy / manual stage-less rows only — no granular partition, so they are
|
||||
// the fallback signal and survive the filter; still must not throw.
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T06:00:00.000Z", null, 480),
|
||||
];
|
||||
const rows: SleepStageRow[] = [row("2026-06-04T06:00:00.000Z", null, 480)];
|
||||
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -712,11 +713,15 @@ describe("per-night writer richness pick", () => {
|
||||
// The winner's own awake minutes survive; the coarse 400 must not.
|
||||
expect(nights[0].awakeMinutes).toBe(35);
|
||||
expect(nights[0].asleepMinutes).toBe(410);
|
||||
// The losing writer's IN_BED envelope survives — "Zeit im Bett" is a
|
||||
// union across writers, never erased by the stage-richness pick.
|
||||
expect(nights[0].inBedMinutes).toBe(250);
|
||||
|
||||
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].source).toBe("WHOOP");
|
||||
expect(sessions[0].stages.AWAKE).toBe(35);
|
||||
expect(sessions[0].inBedMinutes).toBe(250);
|
||||
});
|
||||
|
||||
it("separates writer apps behind ONE source: phone in-bed must not blend into watch stages", () => {
|
||||
@@ -726,13 +731,49 @@ describe("per-night writer richness pick", () => {
|
||||
// both writers and inflate the night's awake total — the device-type
|
||||
// refinement must keep only the stage-bearing writer.
|
||||
const rows: SleepStageRow[] = [
|
||||
writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
|
||||
writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
|
||||
writerRow(
|
||||
"2026-06-04T00:30:00.000Z",
|
||||
"CORE",
|
||||
120,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T02:30:00.000Z",
|
||||
"DEEP",
|
||||
60,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
|
||||
writerRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH", "watch"),
|
||||
writerRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH", "phone"),
|
||||
writerRow("2026-06-04T00:35:00.000Z", "ASLEEP", 200, "APPLE_HEALTH", "phone"),
|
||||
writerRow("2026-06-04T03:00:00.000Z", "AWAKE", 385, "APPLE_HEALTH", "phone"),
|
||||
writerRow(
|
||||
"2026-06-04T04:30:00.000Z",
|
||||
"AWAKE",
|
||||
15,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T04:30:00.000Z",
|
||||
"IN_BED",
|
||||
460,
|
||||
"APPLE_HEALTH",
|
||||
"phone",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T00:35:00.000Z",
|
||||
"ASLEEP",
|
||||
200,
|
||||
"APPLE_HEALTH",
|
||||
"phone",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T03:00:00.000Z",
|
||||
"AWAKE",
|
||||
385,
|
||||
"APPLE_HEALTH",
|
||||
"phone",
|
||||
),
|
||||
];
|
||||
const nights = reconstructSleepNights(rows, "Europe/Berlin");
|
||||
expect(nights).toHaveLength(1);
|
||||
@@ -743,6 +784,54 @@ describe("per-night writer richness pick", () => {
|
||||
expect(nights[0].awakeMinutes).toBe(15);
|
||||
// The phone's bare ASLEEP must not blend into the asleep total either.
|
||||
expect(nights[0].asleepMinutes).toBe(260);
|
||||
// The night keeps the stages AND a sane in-bed figure: the watch wins
|
||||
// the stage views, but "Zeit im Bett" is the union envelope across
|
||||
// writers — the phone's 460-minute IN_BED window must not shrink to
|
||||
// null just because the watch carried no IN_BED row.
|
||||
expect(nights[0].inBedMinutes).toBe(460);
|
||||
|
||||
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
|
||||
expect(sessions).toHaveLength(1);
|
||||
// The session's hypnogram segments stay winner-only (no phone lanes)…
|
||||
expect(sessions[0].segments.some((seg) => seg.stage === "IN_BED")).toBe(
|
||||
false,
|
||||
);
|
||||
// …while the session-level in-bed figure keeps the phone's envelope.
|
||||
expect(sessions[0].inBedMinutes).toBe(460);
|
||||
});
|
||||
|
||||
it("merges overlapping IN_BED spans from two writers without double-counting", () => {
|
||||
// Watch and phone both export an IN_BED window for the same night;
|
||||
// the spans overlap by four hours. The union envelope is 22:00 →
|
||||
// 06:00 (480 min), never the 360 + 360 = 720 sum.
|
||||
const rows: SleepStageRow[] = [
|
||||
writerRow(
|
||||
"2026-06-04T02:00:00.000Z",
|
||||
"CORE",
|
||||
200,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
// Watch IN_BED 22:00 → 04:00 UTC.
|
||||
writerRow(
|
||||
"2026-06-04T04:00:00.000Z",
|
||||
"IN_BED",
|
||||
360,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
// Phone IN_BED 00:00 → 06:00 UTC — overlaps the watch span.
|
||||
writerRow(
|
||||
"2026-06-04T06:00:00.000Z",
|
||||
"IN_BED",
|
||||
360,
|
||||
"APPLE_HEALTH",
|
||||
"phone",
|
||||
),
|
||||
];
|
||||
const nights = reconstructSleepNights(rows, "UTC");
|
||||
expect(nights).toHaveLength(1);
|
||||
expect(nights[0].inBedMinutes).toBe(480);
|
||||
});
|
||||
|
||||
it("rows without a device-type collapse per source exactly as before", () => {
|
||||
@@ -767,8 +856,20 @@ describe("per-night writer richness pick", () => {
|
||||
// richness must pick the watch.
|
||||
const rows: SleepStageRow[] = [
|
||||
srcRow("2026-06-04T04:30:00.000Z", "CORE", 300, "WHOOP"),
|
||||
writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
|
||||
writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
|
||||
writerRow(
|
||||
"2026-06-04T00:30:00.000Z",
|
||||
"CORE",
|
||||
120,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T02:30:00.000Z",
|
||||
"DEEP",
|
||||
60,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
|
||||
];
|
||||
const nights = reconstructSleepNights(rows, "Europe/Berlin");
|
||||
@@ -784,8 +885,20 @@ describe("per-night writer richness pick", () => {
|
||||
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
|
||||
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"),
|
||||
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
|
||||
writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"),
|
||||
writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"),
|
||||
writerRow(
|
||||
"2026-06-04T00:30:00.000Z",
|
||||
"CORE",
|
||||
120,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow(
|
||||
"2026-06-04T02:30:00.000Z",
|
||||
"DEEP",
|
||||
60,
|
||||
"APPLE_HEALTH",
|
||||
"watch",
|
||||
),
|
||||
writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"),
|
||||
];
|
||||
const sessions = reconstructSleepSessions(rows, "Europe/Berlin");
|
||||
|
||||
@@ -86,6 +86,18 @@
|
||||
* session, mirroring the per-(type, day) collapse the rest of the v1.11.x
|
||||
* numeric surfaces apply.
|
||||
*
|
||||
* One exception: `inBedMinutes` ("Zeit im Bett") derives from the UNION
|
||||
* of the IN_BED spans across ALL writers in the session, not from the
|
||||
* winner alone. The common pairing is a watch that knows the stages but
|
||||
* writes no IN_BED row and a phone whose bedtime detection writes only
|
||||
* the long IN_BED window — the watch wins the night on richness, and a
|
||||
* winner-only in-bed figure would shrink to nothing exactly when the
|
||||
* stage data is best. Overlapping spans from two writers are merged
|
||||
* interval-union style so a doubled export never double-counts. The
|
||||
* asleep total, the `stages` map, and `awakeMinutes` stay winner-only —
|
||||
* blending the phone's coarse AWAKE blocks into the watch's night was
|
||||
* the original inflated-awake bug this collapse exists to prevent.
|
||||
*
|
||||
* Unit
|
||||
* ----
|
||||
* Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit
|
||||
@@ -175,6 +187,44 @@ function asleepMinutesOf(rows: readonly SleepStageRow[]): number {
|
||||
return sawGranular ? granular : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total minutes covered by the UNION of the IN_BED spans across EVERY
|
||||
* writer in a session ("Zeit im Bett"). Each IN_BED row resolves to the
|
||||
* absolute span `[measuredAt − value·60_000, measuredAt]`; overlapping
|
||||
* spans (the same window exported by two writers) merge before summing
|
||||
* so a doubled export never double-counts the night. Returns `null`
|
||||
* when no IN_BED row exists anywhere in the session — the caller keeps
|
||||
* the "no in-bed signal" semantics. Deliberately NOT winner-scoped: the
|
||||
* stage-rich watch usually writes no IN_BED row while the phone writes
|
||||
* only the envelope, and the headline must keep both facets.
|
||||
*/
|
||||
function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null {
|
||||
const intervals: Array<{ start: number; end: number }> = [];
|
||||
for (const r of rows) {
|
||||
if (r.sleepStage !== "IN_BED") continue;
|
||||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||||
const end = r.measuredAt.getTime();
|
||||
intervals.push({ start: end - minutes * 60_000, end });
|
||||
}
|
||||
if (intervals.length === 0) return null;
|
||||
intervals.sort((a, b) => a.start - b.start);
|
||||
let total = 0;
|
||||
let curStart = intervals[0].start;
|
||||
let curEnd = intervals[0].end;
|
||||
for (let i = 1; i < intervals.length; i++) {
|
||||
const { start, end } = intervals[i];
|
||||
if (start <= curEnd) {
|
||||
if (end > curEnd) curEnd = end;
|
||||
} else {
|
||||
total += curEnd - curStart;
|
||||
curStart = start;
|
||||
curEnd = end;
|
||||
}
|
||||
}
|
||||
total += curEnd - curStart;
|
||||
return Math.round(total / 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap (ms) between consecutive stage segments that starts a new sleep
|
||||
* session. Stage segments within one night are contiguous (minutes to a
|
||||
@@ -214,11 +264,17 @@ export interface SleepNight {
|
||||
measuredAt: Date;
|
||||
/** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */
|
||||
asleepMinutes: number;
|
||||
/** In-bed minutes when an IN_BED row exists, else null. */
|
||||
/**
|
||||
* In-bed minutes when an IN_BED row exists ANYWHERE in the night's
|
||||
* sessions, else null. Union envelope across ALL writers — not
|
||||
* winner-scoped like `asleepMinutes` / `stages` / `awakeMinutes` —
|
||||
* so a stage-rich watch winning the night never erases the phone's
|
||||
* in-bed window. See the module doc's writer-de-dup exception.
|
||||
*/
|
||||
inBedMinutes: number | null;
|
||||
/** Awake-in-bed minutes when an AWAKE row exists, else null. */
|
||||
awakeMinutes: number | null;
|
||||
/** Per-stage minutes for the night (only stages the device reported). */
|
||||
/** Per-stage minutes for the night (only stages the winning writer reported). */
|
||||
stages: Partial<Record<SleepStage, number>>;
|
||||
}
|
||||
|
||||
@@ -353,7 +409,10 @@ export function reconstructSleepNights(
|
||||
priorityJson: unknown = null,
|
||||
): SleepNight[] {
|
||||
if (rows.length === 0) return [];
|
||||
const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep");
|
||||
const sleepLadder = getSourceLadder(
|
||||
parseSourcePriority(priorityJson),
|
||||
"sleep",
|
||||
);
|
||||
|
||||
// Cluster into sessions by the gap between a segment's START and the latest
|
||||
// END seen so far in the current session. `measuredAt` is the segment END;
|
||||
@@ -364,8 +423,7 @@ export function reconstructSleepNights(
|
||||
// (their gap is ≤ 0). Gaps are absolute-time, so the clustering is DST-immune.
|
||||
// Sort by START so contiguous segments compare end-to-start in order.
|
||||
const startOf = (r: SleepStageRow): number =>
|
||||
r.measuredAt.getTime() -
|
||||
(Number.isFinite(r.value) ? r.value : 0) * 60_000;
|
||||
r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000;
|
||||
const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b));
|
||||
const sessions: SleepStageRow[][] = [];
|
||||
let current: SleepStageRow[] = [];
|
||||
@@ -388,8 +446,14 @@ export function reconstructSleepNights(
|
||||
// wake-day key. The rows are grouped PER SESSION (not flattened) so the
|
||||
// granular-over-bare gate and the asleep total are decided per session — a
|
||||
// bare-only nap keeps its minutes even when the overnight session is
|
||||
// granular (the v1.11.5 merged-night nap under-count fix).
|
||||
const byNight = new Map<string, SleepStageRow[][]>();
|
||||
// granular (the v1.11.5 merged-night nap under-count fix). The FULL
|
||||
// session row set rides along because the in-bed envelope spans every
|
||||
// writer, not just the winner (see the module doc's writer-de-dup
|
||||
// exception).
|
||||
const byNight = new Map<
|
||||
string,
|
||||
Array<{ pool: SleepStageRow[]; all: SleepStageRow[] }>
|
||||
>();
|
||||
for (const session of sessions) {
|
||||
const canonical = pickSessionWriter(session, sleepLadder);
|
||||
const kept = session.filter((r) => writerKeyOf(r) === canonical);
|
||||
@@ -402,7 +466,7 @@ export function reconstructSleepNights(
|
||||
);
|
||||
const key = userDayKey(wakeInstant, tz);
|
||||
const list = byNight.get(key) ?? [];
|
||||
list.push(pool);
|
||||
list.push({ pool, all: session });
|
||||
byNight.set(key, list);
|
||||
}
|
||||
|
||||
@@ -416,12 +480,21 @@ export function reconstructSleepNights(
|
||||
// `nightSessions` and each session pool are non-empty by construction: a
|
||||
// key is only added with a `pool` that has ≥ 1 row (the source-filter falls
|
||||
// back to the full session when the canonical filter empties it), so
|
||||
// `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this
|
||||
// function never applies the granular-over-bare filter to the indexed pool,
|
||||
// so it cannot empty the array here. Keep that invariant on future edits.
|
||||
let latest = nightSessions[0][0].measuredAt;
|
||||
// `nightSessions[0].pool[0]` is safe. Unlike `reconstructSleepSessions`,
|
||||
// this function never applies the granular-over-bare filter to the indexed
|
||||
// pool, so it cannot empty the array here. Keep that invariant on future
|
||||
// edits.
|
||||
let latest = nightSessions[0].pool[0].measuredAt;
|
||||
const stages: Partial<Record<SleepStage, number>> = {};
|
||||
for (const sessionRows of nightSessions) {
|
||||
for (const { pool: sessionRows, all } of nightSessions) {
|
||||
// In-bed: union envelope across ALL writers in the session (sessions
|
||||
// are > 3 h apart, so per-session envelopes never overlap and summing
|
||||
// them per wake day is exact).
|
||||
const envelope = inBedEnvelopeMinutes(all);
|
||||
if (envelope !== null) {
|
||||
inBed += envelope;
|
||||
sawInBed = true;
|
||||
}
|
||||
// v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular
|
||||
// overnight does not strip a bare-only nap's minutes, and the per-stage
|
||||
// `stages` map drops the redundant bare ASLEEP aggregate only for the
|
||||
@@ -441,10 +514,7 @@ export function reconstructSleepNights(
|
||||
if (stage) {
|
||||
stages[stage] = (stages[stage] ?? 0) + minutes;
|
||||
}
|
||||
if (stage === "IN_BED") {
|
||||
inBed += minutes;
|
||||
sawInBed = true;
|
||||
} else if (stage === "AWAKE") {
|
||||
if (stage === "AWAKE") {
|
||||
awake += minutes;
|
||||
sawAwake = true;
|
||||
}
|
||||
@@ -497,7 +567,11 @@ export interface SleepSession {
|
||||
end: Date;
|
||||
/** Time asleep in minutes (granular-over-bare rule). */
|
||||
asleepMinutes: number;
|
||||
/** In-bed minutes when an IN_BED segment exists, else null. */
|
||||
/**
|
||||
* In-bed minutes when an IN_BED row exists anywhere in the session,
|
||||
* else null. Union envelope across ALL writers (not just the
|
||||
* canonical one) — see the module doc's writer-de-dup exception.
|
||||
*/
|
||||
inBedMinutes: number | null;
|
||||
/** Awake-in-bed minutes when an AWAKE segment exists, else null. */
|
||||
awakeMinutes: number | null;
|
||||
@@ -570,11 +644,13 @@ export function reconstructSleepSessions(
|
||||
priorityJson: unknown = null,
|
||||
): SleepSession[] {
|
||||
if (rows.length === 0) return [];
|
||||
const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep");
|
||||
const sleepLadder = getSourceLadder(
|
||||
parseSourcePriority(priorityJson),
|
||||
"sleep",
|
||||
);
|
||||
|
||||
const startOf = (r: SleepStageRow): number =>
|
||||
r.measuredAt.getTime() -
|
||||
(Number.isFinite(r.value) ? r.value : 0) * 60_000;
|
||||
r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000;
|
||||
const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b));
|
||||
const rawSessions: SleepStageRow[][] = [];
|
||||
let current: SleepStageRow[] = [];
|
||||
@@ -618,9 +694,7 @@ export function reconstructSleepSessions(
|
||||
// other consumer) returns a valid empty night, never a 500.
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
let inBed = 0;
|
||||
let awake = 0;
|
||||
let sawInBed = false;
|
||||
let sawAwake = false;
|
||||
const stages: Partial<Record<SleepStage, number>> = {};
|
||||
let earliest = segments[0].start;
|
||||
@@ -630,14 +704,17 @@ export function reconstructSleepSessions(
|
||||
if (seg.end.getTime() > latest.getTime()) latest = seg.end;
|
||||
const stage = seg.stage;
|
||||
if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes;
|
||||
if (stage === "IN_BED") {
|
||||
inBed += seg.minutes;
|
||||
sawInBed = true;
|
||||
} else if (stage === "AWAKE") {
|
||||
if (stage === "AWAKE") {
|
||||
awake += seg.minutes;
|
||||
sawAwake = true;
|
||||
}
|
||||
}
|
||||
// In-bed: union envelope across ALL writers in the RAW session — the
|
||||
// canonical (winning) writer often carries no IN_BED row at all (a
|
||||
// watch's stage export) while the losing writer holds the night's
|
||||
// only in-bed window (the phone's bedtime detection). The hypnogram
|
||||
// `segments` + `stages` stay winner-only.
|
||||
const inBedEnvelope = inBedEnvelopeMinutes(session);
|
||||
const asleep = asleepMinutesOf(pool);
|
||||
const canonicalSource = sourceOfWriterKey(canonical);
|
||||
sessions.push({
|
||||
@@ -649,7 +726,7 @@ export function reconstructSleepSessions(
|
||||
start: earliest,
|
||||
end: latest,
|
||||
asleepMinutes: asleep,
|
||||
inBedMinutes: sawInBed ? inBed : null,
|
||||
inBedMinutes: inBedEnvelope,
|
||||
awakeMinutes: sawAwake ? awake : null,
|
||||
stages,
|
||||
awakenings: countAwakenings(segments),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
@@ -154,6 +155,19 @@ export async function applyProfileUpdate(
|
||||
data: updates,
|
||||
});
|
||||
|
||||
// Anthropometrics feed the cached targets grid (BMI band, BP-by-age
|
||||
// range, body-fat band), the derived-score baseline profile (age /
|
||||
// sex / height) and the analytics snapshot. Only those three fields
|
||||
// matter here — a display-name or email edit must not cold-rebuild
|
||||
// the analytics caches.
|
||||
if (
|
||||
data.heightCm !== undefined ||
|
||||
data.dateOfBirth !== undefined ||
|
||||
data.gender !== undefined
|
||||
) {
|
||||
invalidateUserProfile(userId);
|
||||
}
|
||||
|
||||
await auditLog("profile.update", {
|
||||
userId: updatedUser.id,
|
||||
ipAddress: ipAddress ?? null,
|
||||
|
||||
+55
-15
@@ -6,11 +6,7 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__resetAllCachesForTests,
|
||||
cached,
|
||||
caches,
|
||||
} from "../server-cache";
|
||||
import { __resetAllCachesForTests, cached, caches } from "../server-cache";
|
||||
import {
|
||||
invalidateAppSettings,
|
||||
invalidateUserDashboardWidgets,
|
||||
@@ -19,6 +15,7 @@ import {
|
||||
invalidateUserMeasurements,
|
||||
invalidateUserMedications,
|
||||
invalidateUserMood,
|
||||
invalidateUserProfile,
|
||||
dashboardSnapshotCacheKey,
|
||||
} from "../invalidate";
|
||||
|
||||
@@ -43,12 +40,20 @@ async function primeAllCaches(): Promise<void> {
|
||||
await cached(caches.medications, USER_A, async () => ({ m: 1 }));
|
||||
await cached(caches.medications, USER_B, async () => ({ m: 2 }));
|
||||
|
||||
await cached(caches.medicationsIntake, `${USER_A}|compliance|30`, async () => ({
|
||||
c: 1,
|
||||
}));
|
||||
await cached(caches.medicationsIntake, `${USER_B}|compliance|30`, async () => ({
|
||||
c: 2,
|
||||
}));
|
||||
await cached(
|
||||
caches.medicationsIntake,
|
||||
`${USER_A}|compliance|30`,
|
||||
async () => ({
|
||||
c: 1,
|
||||
}),
|
||||
);
|
||||
await cached(
|
||||
caches.medicationsIntake,
|
||||
`${USER_B}|compliance|30`,
|
||||
async () => ({
|
||||
c: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.15.20 — per-medication compliance payload cache.
|
||||
await cached(
|
||||
@@ -149,9 +154,7 @@ describe("invalidateUserMeasurements", () => {
|
||||
).toBeNull();
|
||||
|
||||
// User B stays warm; the non-analytics buckets evict as before.
|
||||
expect(
|
||||
caches.analytics.getAllowStale(`${USER_B}|default`),
|
||||
).not.toBeNull();
|
||||
expect(caches.analytics.getAllowStale(`${USER_B}|default`)).not.toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
|
||||
});
|
||||
@@ -197,7 +200,9 @@ describe("invalidateUserMedications", () => {
|
||||
// covers the snapshot key.
|
||||
expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull();
|
||||
expect(caches.medications.get(USER_B)).not.toBeNull();
|
||||
expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull();
|
||||
expect(
|
||||
caches.medicationsIntake.get(`${USER_B}|compliance|30`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("evicts the per-medication compliance payload for the target user only", async () => {
|
||||
@@ -308,6 +313,41 @@ describe("invalidateUserInsights", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserProfile", () => {
|
||||
it("hard-evicts targets + derived + analytics for the target user only", async () => {
|
||||
await primeAllCaches();
|
||||
await cached(caches.insightsTargets, USER_A, async () => ({ t: 1 }));
|
||||
await cached(caches.insightsTargets, USER_B, async () => ({ t: 2 }));
|
||||
await cached(caches.insightsDerived, `${USER_A}|batch|x|en`, async () => ({
|
||||
d: 1,
|
||||
}));
|
||||
await cached(caches.insightsDerived, `${USER_B}|batch|x|en`, async () => ({
|
||||
d: 2,
|
||||
}));
|
||||
|
||||
invalidateUserProfile(USER_A);
|
||||
|
||||
// Profile-derived payloads drop — including the SWR stale window
|
||||
// (a Settings save is interactive; the pre-edit grid must not serve).
|
||||
expect(caches.insightsTargets.getAllowStale(USER_A)).toBeNull();
|
||||
expect(
|
||||
caches.insightsDerived.getAllowStale(`${USER_A}|batch|x|en`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
|
||||
).toBeNull();
|
||||
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
|
||||
|
||||
// The other user's cells stay warm.
|
||||
expect(caches.insightsTargets.get(USER_B)).not.toBeNull();
|
||||
expect(caches.insightsDerived.get(`${USER_B}|batch|x|en`)).not.toBeNull();
|
||||
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
|
||||
|
||||
// Buckets a profile edit does not feed stay untouched.
|
||||
expect(caches.medications.get(USER_A)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateAppSettings", () => {
|
||||
it("evicts the entire bug-report status cache (singleton row)", async () => {
|
||||
await primeAllCaches();
|
||||
|
||||
+52
-1
@@ -62,7 +62,13 @@ describe("ServerCache per-key TTL override", () => {
|
||||
it("wrap()/cached() thread the TTL override and stay evictable by prefix", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
await cached(cache, "u1|dashboard-snapshot", async () => "snap", undefined, 180_000);
|
||||
await cached(
|
||||
cache,
|
||||
"u1|dashboard-snapshot",
|
||||
async () => "snap",
|
||||
undefined,
|
||||
180_000,
|
||||
);
|
||||
// Default bucket TTL elapsed; the override keeps the entry warm so
|
||||
// the 120 s client refetch interval lands on a hit, not a miss.
|
||||
vi.advanceTimersByTime(120_000);
|
||||
@@ -308,6 +314,51 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => {
|
||||
expect(builder).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("logs a refresh_failed signal when the detached background rebuild rejects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
const cache = new ServerCache<string>({
|
||||
name: "analytics",
|
||||
maxEntries: 4,
|
||||
ttlMs: 1000,
|
||||
staleTtlMs: 600_000,
|
||||
});
|
||||
let calls = 0;
|
||||
const builder = vi.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return "v1";
|
||||
throw new Error("db gone");
|
||||
});
|
||||
await cache.wrapSwr("k", builder);
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
// Stale serve succeeds for the caller…
|
||||
const stale = await cache.wrapSwr("k", builder);
|
||||
expect(stale.outcome).toBe("stale");
|
||||
expect(stale.value).toBe("v1");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// …but the failed background rebuild is surfaced, not swallowed:
|
||||
// bucket-scoped event name + hashed key + the error message.
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
const [event, payload] = consoleError.mock.calls[0] as [string, string];
|
||||
expect(event).toBe("cache.analytics.refresh_failed");
|
||||
const parsed = JSON.parse(payload) as {
|
||||
key_hash: number;
|
||||
error: string;
|
||||
};
|
||||
expect(parsed.key_hash).toBe(hashCacheKey("k"));
|
||||
expect(parsed.error).toBe("db gone");
|
||||
|
||||
// The pending slot is clear — the next stale read retries the build.
|
||||
const retry = await cache.wrapSwr("k", builder);
|
||||
expect(retry.outcome).toBe("stale");
|
||||
expect(builder).toHaveBeenCalledTimes(3);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("treats an entry past the stale window as a hard miss", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ServerCache<string>({
|
||||
|
||||
Vendored
+32
-5
@@ -138,13 +138,14 @@ export function invalidateUserMood(userId: string): void {
|
||||
* v1.16.8 — `evict` splits the SWR buckets (medications, the
|
||||
* per-medication compliance payload, analytics) by write origin, the
|
||||
* `invalidateUserMeasurements` pattern. Interactive writes (a card take /
|
||||
* skip, a CRUD edit, an intake edit / delete / purge / import) pass
|
||||
* skip, a CRUD edit, an intake edit / delete / purge / import, AND the
|
||||
* iOS bulk-intake endpoint — it carries the phone user's own doses) pass
|
||||
* `{ evict: true }`: the user must see their own action on the very next
|
||||
* read, and a stale-serveable entry would hand back the pre-write body.
|
||||
* Background paths (the iOS bulk-intake sync, the auto-miss cron, slot
|
||||
* dedup) keep the default mark-stale so a high-frequency sync never busts
|
||||
* the buckets into inline cold-rebuild storms — the `cachedSwr` readers
|
||||
* serve the prior payload while one coalesced recompute warms each cell.
|
||||
* Background paths (the auto-miss cron, slot dedup) keep the default
|
||||
* mark-stale so a high-frequency pass never busts the buckets into
|
||||
* inline cold-rebuild storms — the `cachedSwr` readers serve the prior
|
||||
* payload while one coalesced recompute warms each cell.
|
||||
*/
|
||||
export function invalidateUserMedications(
|
||||
userId: string,
|
||||
@@ -179,6 +180,32 @@ export function invalidateUserMedications(
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — invalidate every cache whose payload derives from the user's
|
||||
* PROFILE-level settings rather than from measurement / mood / medication
|
||||
* rows. The measurement-write invalidators never fire on a settings save,
|
||||
* so before this helper a height / birth-date / gender edit, a threshold
|
||||
* override, or a source-priority reorder kept serving the pre-edit
|
||||
* targets grid (BMI band, BP-by-age range, glucose effective ranges),
|
||||
* derived scores (baseline profile: age / sex / height), and analytics
|
||||
* snapshot until the TTL lapsed.
|
||||
*
|
||||
* Hard-evict on every bucket: these are interactive Settings writes, and
|
||||
* a marked-stale SWR cell would hand the user back the pre-edit payload
|
||||
* on the very next read.
|
||||
*
|
||||
* Callers: `/api/user/thresholds` PUT + DELETE, `applyProfileUpdate`
|
||||
* (only when heightCm / dateOfBirth / gender is in the update set), and
|
||||
* `/api/auth/me/source-priority` PUT. The unit-preference toggle is NOT
|
||||
* wired in: it only drives the client-side display transform, no
|
||||
* server-cached payload reads it.
|
||||
*/
|
||||
export function invalidateUserProfile(userId: string): void {
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
caches.insightsDerived.deleteByPrefix(`${userId}|`);
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the dashboard-widget layout cache for a single user.
|
||||
*
|
||||
|
||||
Vendored
+38
-13
@@ -162,12 +162,20 @@ export class ServerCache<T> {
|
||||
/**
|
||||
* v1.12.1 — mark every entry under `prefix` stale without dropping it.
|
||||
* Collapses the fresh TTL to "now" so the next `wrapSwr` read serves
|
||||
* the prior value immediately (within the stale window) while a single
|
||||
* coalesced recompute warms a fresh value. This replaces the
|
||||
* hard-evict on a mood write so an active logger no longer re-pays the
|
||||
* cold compute on every entry. No-op for buckets without `staleTtlMs`
|
||||
* (the entry is already at/over its hard TTL after this, so the next
|
||||
* read is a clean miss — identical to the old evict behaviour).
|
||||
* the prior value immediately while a single coalesced recompute warms
|
||||
* a fresh value. This replaces the hard-evict on a mood write so an
|
||||
* active logger no longer re-pays the cold compute on every entry.
|
||||
*
|
||||
* Stale-serve bound: marking does NOT touch `staleUntil`, which was
|
||||
* fixed at insert time as `insert + ttlMs + staleTtlMs`. A marked
|
||||
* entry can therefore keep serving stale until up to `ttlMs +
|
||||
* staleTtlMs` after it was INSERTED — not `staleTtlMs` after the
|
||||
* mark. (Marking near the end of the fresh window leaves close to
|
||||
* `staleTtlMs` of serveability; marking right after insert leaves
|
||||
* nearly the full `ttlMs + staleTtlMs`.) No-op for buckets without
|
||||
* `staleTtlMs` (the entry is already at/over its hard TTL after this,
|
||||
* so the next read is a clean miss — identical to the old evict
|
||||
* behaviour).
|
||||
*/
|
||||
markStaleByPrefix(prefix: string): number {
|
||||
let marked = 0;
|
||||
@@ -295,9 +303,24 @@ export class ServerCache<T> {
|
||||
});
|
||||
this.pending.set(key, refresh);
|
||||
// Detach: the foreground caller does not await the rebuild.
|
||||
// Swallow the rejection on the detached handle so an
|
||||
// unhandled-rejection warning never fires.
|
||||
refresh.catch(() => {});
|
||||
// Handle the rejection on the detached handle so an
|
||||
// unhandled-rejection warning never fires — but never silently:
|
||||
// a refresh that keeps failing means the bucket serves an
|
||||
// ever-older stale value until the SWR window finally lapses,
|
||||
// so the failure must reach the logs. There is no request
|
||||
// context here (the caller already returned), so a structured
|
||||
// console.error stands in for the wide-event annotation; the
|
||||
// key is logged as its djb2 hash, matching the cache-outcome
|
||||
// meta convention, so the userId segment never reaches stdout.
|
||||
refresh.catch((err) => {
|
||||
console.error(
|
||||
`cache.${this.opts.name ?? "unnamed"}.refresh_failed`,
|
||||
JSON.stringify({
|
||||
key_hash: hashCacheKey(key),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
return { value: cachedEntry.value, outcome: "stale" };
|
||||
}
|
||||
@@ -422,10 +445,12 @@ export const caches = {
|
||||
* detail read and the batched card read) consume via `cachedSwr`:
|
||||
* inside the 10-minute stale window an expired cell serves the prior
|
||||
* payload immediately while one coalesced rebuild warms it. Interactive
|
||||
* intake / medication writes hard-evict (the user must see their own
|
||||
* dose on the next read); background sync paths (iOS bulk intake, the
|
||||
* auto-miss cron, slot dedup) mark stale so a high-frequency sync never
|
||||
* busts every card into an inline cold rebuild.
|
||||
* intake / medication writes — including the iOS bulk-intake endpoint,
|
||||
* which carries the phone user's own taken/skipped doses — hard-evict
|
||||
* (the user must see their own dose on the next read); the genuinely
|
||||
* background writers (the auto-miss cron, slot dedup) mark stale so a
|
||||
* high-frequency pass never busts every card into an inline cold
|
||||
* rebuild.
|
||||
*/
|
||||
medicationCompliance: new ServerCache<unknown>({
|
||||
name: "medicationCompliance",
|
||||
|
||||
+35
-3
@@ -38,6 +38,18 @@ export interface ExportableRecord {
|
||||
* Escapes RFC 4180 special characters: commas, double quotes, line
|
||||
* breaks (`\n` and `\r`). Values containing any of these are wrapped
|
||||
* in double quotes with embedded quotes doubled (`"` → `""`).
|
||||
*
|
||||
* Spreadsheet formula injection (CSV injection): free-TEXT cells whose
|
||||
* value starts with `=`, `+`, `-`, `@`, TAB, or CR get a leading `'`
|
||||
* (the OWASP single-quote neutralisation — Excel / LibreOffice /
|
||||
* Google Sheets treat the cell as text and hide the quote). The guard
|
||||
* is scoped to STRING-typed record values only: numeric columns
|
||||
* (measurement values, mood scores) stay typed `number` all the way to
|
||||
* this function, so a negative reading exports as `-5.2`, never
|
||||
* `'-5.2`. The text columns are where third-party content can land —
|
||||
* mood `note` originates from the moodLog webhook, measurement `notes`
|
||||
* from arbitrary clients — and a crafted `=HYPERLINK(...)` /
|
||||
* `=cmd|...` there must not execute when an operator opens the export.
|
||||
*/
|
||||
export function toCSV(
|
||||
records: ExportableRecord[],
|
||||
@@ -47,7 +59,11 @@ export function toCSV(
|
||||
|
||||
const headers = Object.keys(records[0]);
|
||||
const headerLine = headerLabels
|
||||
? headers.map((h) => escapeCsvCell(headerLabels[h] ?? h)).join(",")
|
||||
? headers
|
||||
.map((h) =>
|
||||
escapeCsvCell(neutraliseFormulaPrefix(headerLabels[h] ?? h)),
|
||||
)
|
||||
.join(",")
|
||||
: headers.map(escapeCsvCell).join(",");
|
||||
const lines = [headerLine];
|
||||
|
||||
@@ -55,8 +71,13 @@ export function toCSV(
|
||||
const values = headers.map((h) => {
|
||||
const val = record[h];
|
||||
if (val === null || val === undefined) return "";
|
||||
const str = val instanceof Date ? val.toISOString() : String(val);
|
||||
return escapeCsvCell(str);
|
||||
if (val instanceof Date) return escapeCsvCell(val.toISOString());
|
||||
// Only genuine TEXT cells get the formula-prefix guard; numbers /
|
||||
// booleans stringify verbatim so numeric columns are never mangled.
|
||||
const str = String(val);
|
||||
return escapeCsvCell(
|
||||
typeof val === "string" ? neutraliseFormulaPrefix(str) : str,
|
||||
);
|
||||
});
|
||||
lines.push(values.join(","));
|
||||
}
|
||||
@@ -64,6 +85,17 @@ export function toCSV(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* OWASP CSV-injection neutralisation: prefix a text cell that starts
|
||||
* with a formula trigger (`=`, `+`, `-`, `@`) or a control character
|
||||
* Excel interprets as a field continuation (TAB, CR) with a single
|
||||
* quote so the spreadsheet renders it as literal text. Applied to
|
||||
* string cells only — see `toCSV`.
|
||||
*/
|
||||
function neutraliseFormulaPrefix(str: string): string {
|
||||
return /^[=+\-@\t\r]/.test(str) ? `'${str}` : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 4180 cell escaping. `\r` is treated the same as `\n` so a
|
||||
* Windows-newline value (`\r\n`) inside a single field doesn't break
|
||||
|
||||
@@ -14,6 +14,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -70,12 +70,16 @@ import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
const FEATURES = {
|
||||
weight: { count: 12, latest: 81.4, mean30: 82.1 },
|
||||
};
|
||||
// The fingerprint covers the compacted features AND the about-me text
|
||||
// (null here — the about-me module is mocked to no text), matching the
|
||||
// The fingerprint covers the compacted features, the about-me text
|
||||
// (null here — the about-me module is mocked to no text), the
|
||||
// comparison-baseline setting ("none" — the mocked user row carries no
|
||||
// dashboardWidgetsJson) and the generation locale, matching the
|
||||
// composite shape both the gate and the POST route hash.
|
||||
const FEATURES_HASH = hashInsightSnapshot({
|
||||
features: compactSections(FEATURES as unknown as Record<string, unknown>),
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "de",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -10,6 +10,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
@@ -74,7 +76,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => {
|
||||
active: true,
|
||||
createdAt: new Date(now.getTime() - 1100 * dayMs),
|
||||
schedules: [
|
||||
{ id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
{
|
||||
id: "s1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -197,7 +204,9 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub"
|
||||
createdAt: now,
|
||||
} as never);
|
||||
|
||||
stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}');
|
||||
stubCompletion(
|
||||
'{"summary":"Fresh compliance assessment.","medications":[]}',
|
||||
);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
@@ -23,9 +25,10 @@ vi.mock("@/lib/insights/metric-correlation-context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tz/resolver", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/tz/resolver")>(
|
||||
"@/lib/tz/resolver",
|
||||
);
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/tz/resolver")>(
|
||||
"@/lib/tz/resolver",
|
||||
);
|
||||
return { ...actual, resolveUserTimezone: vi.fn() };
|
||||
});
|
||||
|
||||
@@ -103,9 +106,24 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
const wake = new Date("2026-06-04T06:30:00.000Z");
|
||||
const m = 60 * 1000;
|
||||
const stageRows = [
|
||||
{ value: 300, measuredAt: new Date(wake.getTime() - 90 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
|
||||
{ value: 90, measuredAt: new Date(wake.getTime() - 30 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
|
||||
{ value: 75, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
|
||||
{
|
||||
value: 300,
|
||||
measuredAt: new Date(wake.getTime() - 90 * m),
|
||||
sleepStage: "CORE",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
measuredAt: new Date(wake.getTime() - 30 * m),
|
||||
sleepStage: "DEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 75,
|
||||
measuredAt: wake,
|
||||
sleepStage: "REM",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(
|
||||
stageRows.length as never,
|
||||
@@ -156,12 +174,42 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
const wake = new Date(Date.now() - 6 * 60 * 60 * 1000);
|
||||
const m = 60 * 1000;
|
||||
const stageRows = [
|
||||
{ value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate
|
||||
{ value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
|
||||
{ value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
|
||||
{ value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
|
||||
{ value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" },
|
||||
{ value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" },
|
||||
{
|
||||
value: 480,
|
||||
measuredAt: wake,
|
||||
sleepStage: "ASLEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
}, // bare aggregate
|
||||
{
|
||||
value: 240,
|
||||
measuredAt: new Date(wake.getTime() - 240 * m),
|
||||
sleepStage: "CORE",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 120,
|
||||
measuredAt: new Date(wake.getTime() - 120 * m),
|
||||
sleepStage: "DEEP",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 120,
|
||||
measuredAt: wake,
|
||||
sleepStage: "REM",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 470,
|
||||
measuredAt: wake,
|
||||
sleepStage: "IN_BED",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
{
|
||||
value: 20,
|
||||
measuredAt: new Date(wake.getTime() - 200 * m),
|
||||
sleepStage: "AWAKE",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(
|
||||
stageRows.length as never,
|
||||
@@ -254,9 +302,9 @@ describe("metric-status registry", () => {
|
||||
});
|
||||
|
||||
it("anchors a six-minute-walk normal range from the population reference", () => {
|
||||
expect(getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange).toEqual(
|
||||
{ low: 400, high: 700 },
|
||||
);
|
||||
expect(
|
||||
getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange,
|
||||
).toEqual({ low: 400, high: 700 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,9 +321,9 @@ describe("archetype prompt templates", () => {
|
||||
const meta = getMetricStatusMeta("SLEEP_DURATION")!;
|
||||
const sys = getMetricArchetypeSystemPrompt(meta, "en");
|
||||
expect(sys).toContain("SLEEP");
|
||||
expect(getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en")).toContain(
|
||||
"sleep duration",
|
||||
);
|
||||
expect(
|
||||
getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en"),
|
||||
).toContain("sleep duration");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +373,10 @@ describe("generateMetricStatus — generation path", () => {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion('{"summary":"Your resting heart rate is steady."}', captured);
|
||||
stubCompletion(
|
||||
'{"summary":"Your resting heart rate is steady."}',
|
||||
captured,
|
||||
);
|
||||
|
||||
const result = await generateMetricStatus({
|
||||
metric: "RESTING_HEART_RATE",
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
@@ -108,7 +110,12 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => {
|
||||
const t = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
|
||||
{
|
||||
date: t.toISOString().slice(0, 10),
|
||||
score: 4,
|
||||
tags: [],
|
||||
moodLoggedAt: t,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
@@ -157,7 +164,12 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => {
|
||||
}),
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now },
|
||||
{
|
||||
date: now.toISOString().slice(0, 10),
|
||||
score: 4,
|
||||
tags: [],
|
||||
moodLoggedAt: now,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
@@ -179,7 +191,12 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", ()
|
||||
const t = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
|
||||
{
|
||||
date: t.toISOString().slice(0, 10),
|
||||
score: 4,
|
||||
tags: [],
|
||||
moodLoggedAt: t,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
|
||||
@@ -52,6 +52,35 @@ describe("hashInsightSnapshot", () => {
|
||||
expect(de).toBe(en);
|
||||
});
|
||||
|
||||
it("does NOT ignore generationLocale or comparisonBaseline (the comprehensive composite)", () => {
|
||||
// The comprehensive fingerprint carries the generation language and
|
||||
// the comparison toggle under non-volatile names so a reader-facing
|
||||
// change regenerates the briefing exactly once. `locale` itself
|
||||
// stays volatile (per-locale-keyed status caches rely on that).
|
||||
const base = hashInsightSnapshot({
|
||||
features: { weight: { mean: 80 } },
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "de",
|
||||
});
|
||||
expect(
|
||||
hashInsightSnapshot({
|
||||
features: { weight: { mean: 80 } },
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "none",
|
||||
generationLocale: "en",
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
hashInsightSnapshot({
|
||||
features: { weight: { mean: 80 } },
|
||||
aboutMe: null,
|
||||
comparisonBaseline: "lastMonth",
|
||||
generationLocale: "de",
|
||||
}),
|
||||
).not.toBe(base);
|
||||
});
|
||||
|
||||
it("ignores positional offsets (dayOffset) but not the values", () => {
|
||||
const yesterday = hashInsightSnapshot({
|
||||
dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 },
|
||||
@@ -149,8 +178,12 @@ describe("hashInsightSnapshot", () => {
|
||||
});
|
||||
|
||||
it("keeps absolute day keys in the fingerprint", () => {
|
||||
const a = hashInsightSnapshot({ recent: [{ date: "2026-06-09", mean: 80 }] });
|
||||
const b = hashInsightSnapshot({ recent: [{ date: "2026-06-10", mean: 80 }] });
|
||||
const a = hashInsightSnapshot({
|
||||
recent: [{ date: "2026-06-09", mean: 80 }],
|
||||
});
|
||||
const b = hashInsightSnapshot({
|
||||
recent: [{ date: "2026-06-10", mean: 80 }],
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
const hasUsableStatusProvider = vi.fn();
|
||||
const statusConsentBlocksGeneration = vi.fn();
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a),
|
||||
statusConsentBlocksGeneration: (...a: unknown[]) =>
|
||||
statusConsentBlocksGeneration(...a),
|
||||
}));
|
||||
|
||||
const enqueueStatusGeneration = vi.fn();
|
||||
@@ -184,7 +187,11 @@ describe("resolveReadOnlyStatusMiss", () => {
|
||||
// A prior (e.g. yesterday's) real assessment is on record.
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([
|
||||
cacheRow(
|
||||
{ dateKey: "2026-05-30", text: "Steady upward trend.", model: "gpt-4o-mini" },
|
||||
{
|
||||
dateKey: "2026-05-30",
|
||||
text: "Steady upward trend.",
|
||||
model: "gpt-4o-mini",
|
||||
},
|
||||
new Date("2026-05-30T04:30:00.000Z"),
|
||||
),
|
||||
] as never);
|
||||
@@ -248,6 +255,30 @@ describe("resolveReadOnlyStatusMiss", () => {
|
||||
describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
// Default: consent does not block (BYOK / consented chains).
|
||||
statusConsentBlocksGeneration.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => {
|
||||
statusConsentBlocksGeneration.mockResolvedValue(true);
|
||||
const hit = await refreshUnchangedStatusInsight({
|
||||
userId: "u1",
|
||||
cacheAction: "insights.weight-status.en",
|
||||
todayKey: TODAY,
|
||||
snapshotHash: HASH,
|
||||
});
|
||||
expect(hit).toBeNull();
|
||||
// The gate must not even read the cache row — a revoked consent can
|
||||
// never re-stamp old AI text as today's assessment.
|
||||
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(statusConsentBlocksGeneration).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"insights",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-persists the row under today's dateKey and returns the text on a hash match", async () => {
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
|
||||
cacheRow({
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/insights/status-provider", () => ({
|
||||
runStatusCompletion: vi.fn(),
|
||||
// Consent never blocks in these fixtures — the gate has its own tests.
|
||||
statusConsentBlocksGeneration: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/memory", () => ({
|
||||
@@ -192,14 +194,21 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const records = [
|
||||
{ type: "WEIGHT", value: 82, measuredAt: new Date(now.getTime() - dayMs) },
|
||||
{ type: "WEIGHT", value: 81.6, measuredAt: new Date(now.getTime() - 2 * dayMs) },
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 82,
|
||||
measuredAt: new Date(now.getTime() - dayMs),
|
||||
},
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 81.6,
|
||||
measuredAt: new Date(now.getTime() - 2 * dayMs),
|
||||
},
|
||||
];
|
||||
// Fresh copy per call — the generator reverses the result array in
|
||||
// place, and a shared fixture would flip order between the two runs.
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async () => records.map((r) => ({ ...r }))) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async () =>
|
||||
records.map((r) => ({ ...r }))) as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
|
||||
@@ -57,20 +57,17 @@ import {
|
||||
AllProvidersFailedError,
|
||||
runRawCompletionWithFallback,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { chainRequiresServerManagedConsent, hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
enqueueStatusGeneration,
|
||||
type InsightStatusScope,
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
import {
|
||||
normalizeLocale,
|
||||
stripJsonFences,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import {
|
||||
isTimeoutStub,
|
||||
statusCacheAction,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { normalizeLocale, stripJsonFences } from "@/lib/insights/status-shared";
|
||||
import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import {
|
||||
metricIdForMeasurementType,
|
||||
@@ -273,7 +270,9 @@ export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
|
||||
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
|
||||
* new weight reading invalidates both the weight and the BMI card.
|
||||
*/
|
||||
function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] {
|
||||
function statusScopesForMeasurementType(
|
||||
type: MeasurementType,
|
||||
): PerStatusScope[] {
|
||||
switch (type) {
|
||||
case "WEIGHT":
|
||||
return ["weight", "bmi", "general"];
|
||||
@@ -532,6 +531,10 @@ export async function generateComprehensiveInsight(
|
||||
insightsCachedText: true,
|
||||
insightsExcludeMetrics: true,
|
||||
insightsSnapshotHash: true,
|
||||
// The comparison-baseline setting joins the snapshot fingerprint
|
||||
// (see the hash construction below), so it must be readable BEFORE
|
||||
// the gate — the comparison snapshot itself is only built after.
|
||||
dashboardWidgetsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,13 +614,33 @@ export async function generateComprehensiveInsight(
|
||||
// it is the one prompt input that can change with NO data change (the
|
||||
// Coach remember action, a Settings → AI edit), and excluding it would
|
||||
// pin the briefing to the pre-edit self-context until a measurement
|
||||
// happens to move. The comparison + plateau blocks stay out — both are
|
||||
// derived from the same measurement rows the feature snapshot already
|
||||
// fingerprints. Fetched once here and reused for the prompt below.
|
||||
// happens to move. The plateau block stays out — it derives from the
|
||||
// same measurement rows the feature snapshot already fingerprints.
|
||||
// Fetched once here and reused for the prompt below.
|
||||
//
|
||||
// Two more prompt inputs that can flip with no data change join the
|
||||
// composite (the shape MUST stay identical to the POST route's hash
|
||||
// write in `generate/route.ts`, or every nightly force-regenerates):
|
||||
// - `comparisonBaseline`: the user's comparison toggle. Switching it
|
||||
// adds/removes the prior-period context block, so the cached text
|
||||
// no longer matches what the prompt would produce.
|
||||
// - `generationLocale`: the locale the briefing renders in. Hashing
|
||||
// it means a locale switch regenerates ONCE — intended, the
|
||||
// briefing language must follow the reader; afterwards the new
|
||||
// locale's hash is the stored baseline and the gate holds again.
|
||||
// The key is named `generationLocale` (not `locale`) on purpose:
|
||||
// the canonicaliser in `snapshot-hash.ts` strips `locale` keys as
|
||||
// volatile, which is right for the per-locale-keyed status caches
|
||||
// but would silently drop the field from THIS composite.
|
||||
const aboutMe = await getSelfContextTextForUser(userId, locale);
|
||||
const comparisonBaseline: ComparisonBaseline =
|
||||
resolveDashboardLayout(dbUser?.dashboardWidgetsJson).comparisonBaseline ??
|
||||
"none";
|
||||
const snapshotHash = hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
comparisonBaseline,
|
||||
generationLocale: locale,
|
||||
});
|
||||
if (
|
||||
dbUser?.insightsCachedText &&
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
hasUsableStatusProvider,
|
||||
statusConsentBlocksGeneration,
|
||||
} from "@/lib/insights/status-provider";
|
||||
import {
|
||||
enqueueStatusGeneration,
|
||||
type InsightStatusScope,
|
||||
@@ -142,6 +145,14 @@ export async function readFreshStatusText(args: {
|
||||
* the ingest-driven regeneration into no-ops on unchanged data: each of
|
||||
* those paths forces past the same-day cache read, gathers the
|
||||
* snapshot, and lands here.
|
||||
*
|
||||
* Consent comes BEFORE the hash compare (mirroring the comprehensive
|
||||
* pipeline's consent-before-gate order): re-stamping a cached text
|
||||
* under today's `dateKey` presents it as a current AI assessment, so a
|
||||
* user who revoked the server-managed AI consent must not have old
|
||||
* text re-dated by an unchanged-data refresh. On a blocked consent the
|
||||
* gate misses, the generator proceeds to `runStatusCompletion`, and
|
||||
* that gate returns the no-key fallback without persisting anything.
|
||||
*/
|
||||
export async function refreshUnchangedStatusInsight(args: {
|
||||
userId: string;
|
||||
@@ -149,6 +160,14 @@ export async function refreshUnchangedStatusInsight(args: {
|
||||
todayKey: string;
|
||||
snapshotHash: string;
|
||||
}): Promise<FreshStatusCacheHit | null> {
|
||||
if (await statusConsentBlocksGeneration(args.userId, "insights")) {
|
||||
annotate({
|
||||
action: { name: "insights.status.consent_required" },
|
||||
meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const latest = await prisma.auditLog.findFirst({
|
||||
where: { userId: args.userId, action: args.cacheAction },
|
||||
orderBy: { createdAt: "desc" },
|
||||
|
||||
@@ -95,6 +95,29 @@ export async function hasUsableStatusProvider(
|
||||
return (await resolveStatusChain(userId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — true when a status generation for `userId` would be blocked
|
||||
* by the server-managed consent gate (the same check `runStatusCompletion`
|
||||
* applies before egress). The content-hash gate
|
||||
* (`refreshUnchangedStatusInsight`) consults this BEFORE re-stamping a
|
||||
* cached assessment as current: after a consent revocation the unchanged-
|
||||
* data refresh must fall through to the generator, whose own gate then
|
||||
* serves the no-key fallback instead of presenting old AI text as fresh.
|
||||
* `false` when no provider is configured at all — that path already
|
||||
* resolves to the fallback via `{ kind: "none" }`.
|
||||
*/
|
||||
export async function statusConsentBlocksGeneration(
|
||||
userId: string,
|
||||
surface: ConsentSurface,
|
||||
): Promise<boolean> {
|
||||
const chain = await resolveStatusChain(userId);
|
||||
if (chain === null) return false;
|
||||
return (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, surface))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a status generation across the user's provider chain, bounded by
|
||||
* `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between
|
||||
|
||||
@@ -1146,13 +1146,20 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleMedicationInventoryExpire,
|
||||
);
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation. Single-flight
|
||||
// so two ticks can't double-generate the same user; the per-user
|
||||
// budget gate inside the handler also covers the race, but
|
||||
// serialising here avoids wasted chain-resolves.
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation.
|
||||
//
|
||||
// v1.16.8 — localConcurrency 2 (was 1). The queue carries BOTH the
|
||||
// scheduled 04:30 cohort walk AND the visit-triggered per-user force
|
||||
// warms; with a single slot a force warm enqueued during the nightly
|
||||
// batch sat behind the entire cohort and the visiting user stared at
|
||||
// a cold dashboard for the duration. Two slots let one force warm run
|
||||
// alongside the batch while still bounding provider-level concurrency
|
||||
// (the cohort walk is itself sequential per user, and the content-hash
|
||||
// gate + per-user budget gate inside the handler cover the rare
|
||||
// double-tick overlap the old single slot serialised away).
|
||||
await boss.work<InsightPregeneratePayload>(
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
{ localConcurrency: 2 },
|
||||
handleInsightPregenerateJob,
|
||||
);
|
||||
// v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute +
|
||||
|
||||
@@ -252,7 +252,7 @@ const derivedBatchQuery = z
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.describe(
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.",
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedBatchQuery" });
|
||||
|
||||
@@ -128,7 +128,10 @@ describe("getDateTimeFormat", () => {
|
||||
describe("memoised consumers stay output-identical to the pre-memo helpers", () => {
|
||||
it("wallClockInTz decomposes DST-transition instants correctly", () => {
|
||||
// 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning).
|
||||
const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin");
|
||||
const parts = wallClockInTz(
|
||||
new Date("2026-03-29T01:30:00Z"),
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(parts).toEqual({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
@@ -183,3 +186,57 @@ describe("memoised consumers stay output-identical to the pre-memo helpers", ()
|
||||
expect(isValidTimezone("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overflow eviction", () => {
|
||||
it("evicts only the single oldest entry on overflow, never the whole map", () => {
|
||||
// The memo caps at 1000 entries. Fill it to the cap with real
|
||||
// signatures (IANA zones × option shapes), then overflow by one and
|
||||
// assert the population stays at the cap with only the OLDEST
|
||||
// signature gone — a full reset would re-pay construction for every
|
||||
// hot signature after one burst of unusual zones.
|
||||
const zones = (
|
||||
Intl as unknown as { supportedValuesOf: (k: string) => string[] }
|
||||
).supportedValuesOf("timeZone");
|
||||
const shapes: Array<Omit<Intl.DateTimeFormatOptions, "timeZone">> = [
|
||||
{ hour: "2-digit" },
|
||||
{ minute: "2-digit" },
|
||||
{ second: "2-digit" },
|
||||
];
|
||||
|
||||
const CAP = 1000;
|
||||
const inserted: Array<{
|
||||
zone: string;
|
||||
opts: Omit<Intl.DateTimeFormatOptions, "timeZone">;
|
||||
formatter: Intl.DateTimeFormat;
|
||||
}> = [];
|
||||
outer: for (const opts of shapes) {
|
||||
for (const zone of zones) {
|
||||
inserted.push({
|
||||
zone,
|
||||
opts,
|
||||
formatter: getDateTimeFormat("en-US", zone, opts),
|
||||
});
|
||||
if (inserted.length === CAP) break outer;
|
||||
}
|
||||
}
|
||||
expect(__intlCacheSizeForTests()).toBe(CAP);
|
||||
|
||||
// One more unique signature → exactly one eviction.
|
||||
const overflowOpts: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
year: "numeric",
|
||||
};
|
||||
getDateTimeFormat("en-US", "UTC", overflowOpts);
|
||||
expect(__intlCacheSizeForTests()).toBe(CAP);
|
||||
|
||||
// The SECOND-oldest entry survived (same memoised instance)…
|
||||
const second = inserted[1];
|
||||
expect(getDateTimeFormat("en-US", second.zone, second.opts)).toBe(
|
||||
second.formatter,
|
||||
);
|
||||
// …while the oldest was evicted and re-constructs to a NEW instance.
|
||||
const first = inserted[0];
|
||||
expect(getDateTimeFormat("en-US", first.zone, first.opts)).not.toBe(
|
||||
first.formatter,
|
||||
);
|
||||
});
|
||||
});
|
||||
+22
-16
@@ -21,25 +21,31 @@ export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
* lookup hits the same object identity on every call — see
|
||||
* `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by
|
||||
* `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME
|
||||
* memoised formatter per timezone.
|
||||
* memoised formatter per timezone. Frozen: the memo computes each
|
||||
* constant's serialised signature ONCE per object identity, so a later
|
||||
* mutation would leave the cached signature stale and silently hand
|
||||
* callers a formatter built from the pre-mutation options.
|
||||
*/
|
||||
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {};
|
||||
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
Object.freeze({});
|
||||
|
||||
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
};
|
||||
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
Object.freeze({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
};
|
||||
const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
Object.freeze({
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
|
||||
|
||||
@@ -21,7 +21,10 @@ const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
* Hard cap. The realistic population is bounded (valid IANA zones × a
|
||||
* handful of option shapes), but the timezone segment is user-supplied
|
||||
* upstream of validation in `isValidTimezone`, so the memo carries a
|
||||
* safety valve: on overflow the whole map resets and re-warms.
|
||||
* safety valve: on overflow the single OLDEST entry is evicted (Map
|
||||
* iteration order is insertion order, so the first key is the oldest).
|
||||
* A full-map reset on every insert past the cap would let one burst of
|
||||
* unusual zones re-pay formatter construction for every hot signature.
|
||||
*/
|
||||
const MAX_FORMATTERS = 1000;
|
||||
|
||||
@@ -61,7 +64,10 @@ export function getDateTimeFormat(
|
||||
locale,
|
||||
timeZone === undefined ? options : { ...options, timeZone },
|
||||
);
|
||||
if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear();
|
||||
if (formatterCache.size >= MAX_FORMATTERS) {
|
||||
const oldest = formatterCache.keys().next().value;
|
||||
if (oldest !== undefined) formatterCache.delete(oldest);
|
||||
}
|
||||
formatterCache.set(key, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
+19
-14
@@ -48,18 +48,21 @@ const WEEKDAY_MAP: Record<string, number> = {
|
||||
* Hoisted so the formatter memo's WeakMap signature lookup hits the same
|
||||
* object identity on every call — a warm `wallClockInTz` no longer pays
|
||||
* `Intl.DateTimeFormat` construction (the dominant cost of the band
|
||||
* expansion + rollup paths that call this in a tight loop).
|
||||
* expansion + rollup paths that call this in a tight loop). Frozen: the
|
||||
* memo serialises the signature ONCE per object identity, so a later
|
||||
* mutation would leave the cached signature stale.
|
||||
*/
|
||||
const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
};
|
||||
const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
|
||||
Object.freeze({
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
export function wallClockInTz(
|
||||
date: Date,
|
||||
@@ -76,9 +79,11 @@ export function wallClockInTz(
|
||||
weekday: date.getDay(),
|
||||
};
|
||||
}
|
||||
const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts(
|
||||
date,
|
||||
);
|
||||
const parts = getDateTimeFormat(
|
||||
"en-US",
|
||||
tz,
|
||||
WALL_CLOCK_OPTIONS,
|
||||
).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "0";
|
||||
let hour = Number(get("hour"));
|
||||
|
||||
Reference in new issue
Block a user