mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user