mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(analytics): replace health score with reference composite
This commit is contained in:
1 parent
666a85d311
commit
c91558f18f
87 files changed
+6033
-5868
No files matched your search
+111
-89
@@ -6,7 +6,8 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
|
||||
import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
@@ -16,14 +17,9 @@ import {
|
||||
computeBpInTargetFastPath,
|
||||
type BpInTargetEnvelope,
|
||||
} from "@/lib/analytics/bp-in-target-fast-path";
|
||||
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
|
||||
import { computeUserHealthScore } from "@/lib/analytics/score/reader";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import {
|
||||
buildHealthScoreBpInputs,
|
||||
buildHealthScoreWeightTarget,
|
||||
} from "@/lib/analytics/health-score-inputs";
|
||||
import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -298,7 +294,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// these envelopes inside `buildHealthScoreBpInputs`.
|
||||
let bpEnvelope: BpInTargetEnvelope | null = null;
|
||||
let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||
let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null;
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
{
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
},
|
||||
user.thresholdsJson,
|
||||
);
|
||||
const clinicalBpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const now = new Date();
|
||||
// v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked
|
||||
@@ -321,29 +326,67 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// pair of `readRollupBuckets` reads on the rollup path and is a
|
||||
// no-op when the user has no BP rows at all.
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek] = await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
|
||||
const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all(
|
||||
[
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now,
|
||||
coverage,
|
||||
// v1.4.38 W-A — cross-tz runtime guard. The helper falls back
|
||||
// to the live SQL path when the user is more than 3 hours
|
||||
// from UTC, where the rollup table's UTC-midnight day-key
|
||||
// would slip a calendar day relative to the live aggregator's
|
||||
// window cuts.
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: bpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
],
|
||||
);
|
||||
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
|
||||
bpEnvelope = windows;
|
||||
bpEnvelopePriorWeek = windowsPriorWeek;
|
||||
bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks;
|
||||
} else if (clinicalBpTargets) {
|
||||
[bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] =
|
||||
await Promise.all([
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: sevenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
computeBpInTargetFastPath({
|
||||
userId: user.id,
|
||||
targets: clinicalBpTargets,
|
||||
now: fourteenDaysAgo,
|
||||
coverage,
|
||||
userTz,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// v1.17 W1d — the BD-Zielbereich headline standardises on the
|
||||
// trailing-90-day window (labelled "· 90 T" in the tile). All-time
|
||||
// remains carried below for the BP detail page's long view only.
|
||||
@@ -476,64 +519,48 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
: result;
|
||||
}
|
||||
|
||||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||||
// composite of BP-in-target % + weight-trend alignment + mood
|
||||
// stability + medication compliance. The "vs last week" delta
|
||||
// re-runs the same compute against a 7-day-shifted snapshot.
|
||||
//
|
||||
// v1.4.37 W2 — probe-gated helper. The weight pillar derives from
|
||||
// DAY-bucket means on `measurement_rollups` when the user has full
|
||||
// coverage; the source-attribution accordion still pulls a narrow
|
||||
// 2-column projection from `measurements` for the ingest-path
|
||||
// pills. Falls back to the legacy 37-day raw read on partial /
|
||||
// missing coverage. Path annotate sits on `meta.healthScore.path`.
|
||||
//
|
||||
// v1.17 W1b — the BP-pillar inputs come from the ONE shared
|
||||
// `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so
|
||||
// the ring and the insights card grade the pillar off identical inputs
|
||||
// (same 90-day window via W1d, same all-time fallback, same graded score,
|
||||
// same prior-week delta values). The hand-rolled per-surface assembly that
|
||||
// let the two diverge is gone.
|
||||
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
|
||||
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
|
||||
// account drops the mood-stability pillar from the ring instead of
|
||||
// being silently scored on data hidden everywhere else. The dashboard
|
||||
// already blanks the mood tile + the coach snapshot unions mood out;
|
||||
// this closes the matching gap on the Health Score.
|
||||
const moodEnabled = await isModuleEnabled(user.id, "mood");
|
||||
// v1.18.0 B1 — resolve the `glucose` module server-side. The analytics
|
||||
// route serves core metrics too, so a disabled-glucose account must NOT
|
||||
// 403 the whole payload; instead the glucose-only surfaces (per-context
|
||||
// summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they
|
||||
// never reach a hidden surface. Mirrors the mood-pillar gate above.
|
||||
const glucoseEnabled = await isModuleEnabled(user.id, "glucose");
|
||||
// v1.34 — the versioned cardiometabolic reference report. The shared reader
|
||||
// admits only evidence-backed pillars with their own windows and coverage
|
||||
// floors, then applies the three-domain breadth rule. Its BP envelopes come
|
||||
// from the same rollup/live fast path the dashboard uses; every other domain
|
||||
// resolves through the canonical reader and fails independently.
|
||||
const [
|
||||
glucoseEnabled,
|
||||
labsEnabled,
|
||||
sleepEnabled,
|
||||
mentalHealthEnabled,
|
||||
scoreSourcePriority,
|
||||
] = await Promise.all([
|
||||
isModuleEnabled(user.id, "glucose"),
|
||||
isModuleEnabled(user.id, "labs"),
|
||||
isModuleEnabled(user.id, "sleep"),
|
||||
isModuleEnabled(user.id, "mentalHealth"),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null;
|
||||
const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null;
|
||||
// The reference instant the score is graded as-of. Reused below so the Rest
|
||||
// Mode annotation resolves against the SAME window the score covers, not a
|
||||
// fresh "now" that could fall outside it.
|
||||
const scoredAt = new Date();
|
||||
// v1.34 — the weight yardstick comes from the ONE shared
|
||||
// `buildHealthScoreWeightTarget` builder the dashboard snapshot also uses,
|
||||
// so the ring and the card grade weight against the identical band. It hands
|
||||
// over a target ONLY when the account carries an explicit override; every
|
||||
// other account scores the bare trend. The fabricated BMI-22 band that stood
|
||||
// in here is gone.
|
||||
const weightTargetInputs = buildHealthScoreWeightTarget(
|
||||
{
|
||||
heightCm: user.heightCm ?? null,
|
||||
const healthScore = await computeUserHealthScore({
|
||||
userId: user.id,
|
||||
now: scoredAt,
|
||||
profile: {
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
timezone: userTz,
|
||||
sourcePriorityJson: scoreSourcePriority,
|
||||
thresholdsJson: user.thresholdsJson ?? null,
|
||||
},
|
||||
(user.thresholdsJson ?? null) as ThresholdOverridesJson | null,
|
||||
);
|
||||
const healthScore = await computeUserHealthScoreFastPath({
|
||||
userId: user.id,
|
||||
...bpInputs,
|
||||
...weightTargetInputs,
|
||||
now: scoredAt,
|
||||
coverage,
|
||||
moodEnabled,
|
||||
modules: {
|
||||
glucose: glucoseEnabled,
|
||||
labs: labsEnabled,
|
||||
sleep: sleepEnabled,
|
||||
mentalHealth: mentalHealthEnabled,
|
||||
},
|
||||
bpTargets: clinicalBpTargets,
|
||||
bpEnvelope,
|
||||
bpEnvelopePriorWeek,
|
||||
bpEnvelopePriorTwoWeeks,
|
||||
});
|
||||
|
||||
// v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
|
||||
@@ -648,6 +675,7 @@ async function computeSleepStageBreakdown(
|
||||
.map((n) => {
|
||||
const nightStages: Record<string, number> = {};
|
||||
for (const [stage, mins] of Object.entries(n.stages)) {
|
||||
if (mins == null) continue;
|
||||
nightStages[stage] = mins;
|
||||
stages[stage] = (stages[stage] ?? 0) + mins;
|
||||
totalMinutes += mins;
|
||||
@@ -687,9 +715,3 @@ async function computeSleepStageBreakdown(
|
||||
// `src/lib/analytics/correlations-fast-path.ts`. They were only ever
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
|
||||
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
|
||||
// and the inline `computeUserHealthScore` body relocated to
|
||||
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
|
||||
// rollup / live dispatcher can be unit-tested independently of the
|
||||
// route. The route now delegates through `computeUserHealthScoreFastPath`.
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
@@ -169,6 +170,7 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
const updated = await prisma.biomarker.update({ where: { id }, data });
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("biomarker.update", {
|
||||
userId: user.id,
|
||||
@@ -206,6 +208,7 @@ export const DELETE = apiHandler(
|
||||
}),
|
||||
prisma.biomarker.delete({ where: { id } }),
|
||||
]);
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("biomarker.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -58,7 +58,14 @@ const DIGEST: DailyDigest = {
|
||||
generatedAt: "2026-07-16T09:00:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "good", delta: 3 },
|
||||
score: {
|
||||
value: 82,
|
||||
band: "good",
|
||||
delta: 3,
|
||||
deltaReason: null,
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
topSignal: null,
|
||||
briefingLead: "Blood pressure is holding steady.",
|
||||
line: "Blood pressure is holding steady.",
|
||||
|
||||
@@ -30,6 +30,10 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserHealthScore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -56,6 +60,7 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -129,11 +134,13 @@ describe("POST /api/daily/digest/dismiss", () => {
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => {
|
||||
it("upserts each approved observational or notice dismissal for the caller", async () => {
|
||||
for (const itemKey of [
|
||||
"milestone:record_first:WEIGHT:2026-07-16",
|
||||
"ecg_new_recording:2026-07-16T08:00:00.000Z",
|
||||
"tension_window:2026-07-16:afternoon",
|
||||
"same_time_baseline:2026-07-16:ACTIVITY_STEPS",
|
||||
"health_score_algorithm:2",
|
||||
]) {
|
||||
vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear();
|
||||
const res = await callPost(makeReq({ itemKey }));
|
||||
@@ -161,4 +168,12 @@ describe("POST /api/daily/digest/dismiss", () => {
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("evicts score-bearing server caches after the algorithm notice write", async () => {
|
||||
const res = await callPost(
|
||||
makeReq({ itemKey: "health_score_algorithm:2" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* POST /api/daily/digest/dismiss
|
||||
*
|
||||
* "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem`
|
||||
* kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The
|
||||
* ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`,
|
||||
* `coach_checkin`) are never reachable here: `dismissPriorityItemSchema`
|
||||
* rejects any `itemKey` that doesn't carry one of the three dismissible
|
||||
* kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`).
|
||||
* "Dismiss / mark seen" for observational Today rail items and versioned
|
||||
* informational notices. Actionable kinds (`dose_window`, `sync_issue`,
|
||||
* `preventive_care`, `coach_checkin`) are never reachable here:
|
||||
* `dismissPriorityItemSchema` rejects any `itemKey` without an approved
|
||||
* observational or notice prefix before a lookup runs.
|
||||
*
|
||||
* Persisted server-side (`DismissedPriorityItem`, composite-unique on
|
||||
* `userId` + `itemKey`) so the dismissal survives reload / a second device —
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { dismissPriorityItemSchema } from "@/lib/validations/daily";
|
||||
@@ -72,6 +72,9 @@ export const POST = apiHandler(async (req: NextRequest) => {
|
||||
update: {},
|
||||
create: { userId: user.id, itemKey },
|
||||
});
|
||||
if (itemKey.startsWith("health_score_algorithm:")) {
|
||||
invalidateUserHealthScore(user.id);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "daily.digest.item.dismissed" },
|
||||
|
||||
@@ -104,11 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!gate.enabled) return gate.response;
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader (the same one the batch route
|
||||
// and the nightly score jobs use), passed into the pure compute function
|
||||
// — never re-fetched per metric.
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
|
||||
const derived = await computeDerivedMetric({
|
||||
metric,
|
||||
userId: user.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
@@ -217,6 +218,7 @@ export const PUT = apiHandler(
|
||||
data,
|
||||
include: { biomarker: { select: biomarkerSelect } },
|
||||
});
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("labResult.update", {
|
||||
userId: user.id,
|
||||
@@ -250,6 +252,7 @@ export const DELETE = apiHandler(
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("labResult.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
@@ -191,6 +192,7 @@ async function commitOcrRows(request: NextRequest) {
|
||||
takenAt: created.takenAt,
|
||||
});
|
||||
}
|
||||
if (inserted.length > 0) invalidateUserHealthScore(user.id);
|
||||
|
||||
// S9 — cross-link the freshly inserted labs to the vault document the client
|
||||
// filed the scanned bytes into. Best-effort: the labs are the authoritative
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -66,6 +67,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
if (count > 0) invalidateUserHealthScore(user.id);
|
||||
|
||||
annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
@@ -254,6 +255,7 @@ async function postLabResult(request: NextRequest) {
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
},
|
||||
});
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
await auditLog("labResult.create", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -338,6 +339,7 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
externalId: `assessment:${assessment.id}`,
|
||||
},
|
||||
});
|
||||
invalidateUserHealthScore(user.id);
|
||||
|
||||
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
|
||||
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
|
||||
|
||||
Reference in new issue
Block a user