mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(insights): persist correlation patterns
This commit is contained in:
1 parent
8ece6f39cc
commit
666a85d311
53 files changed
+2129
-55
No files matched your search
@@ -212,6 +212,7 @@ function sourceClient() {
|
||||
targetHigh: null,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
correlationEnabled: true,
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
@@ -228,6 +229,32 @@ function sourceClient() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
correlationPattern: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "pattern-1",
|
||||
userId: OWNER,
|
||||
canonicalKey: `p1:${"a".repeat(64)}`,
|
||||
family: "DISCOVERY_RETROSPECTIVE",
|
||||
factorKey: "CUSTOM_METRIC:metric-1",
|
||||
outcomeKey: "RESTING_HEART_RATE",
|
||||
lagDays: 1,
|
||||
sampleSize: 42,
|
||||
effectSize: 0.34,
|
||||
pValue: 0.01,
|
||||
qValue: 0.04,
|
||||
evidenceHash: "b".repeat(64),
|
||||
isCurrent: true,
|
||||
lastComputedAt: new Date("2026-07-19T08:00:00.000Z"),
|
||||
dismissedAt: new Date("2026-07-19T09:00:00.000Z"),
|
||||
dismissedEvidenceHash: "b".repeat(64),
|
||||
dismissedEffectSize: 0.34,
|
||||
dismissedSampleSize: 42,
|
||||
createdAt: new Date("2026-07-19T08:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T09:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,6 +389,7 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
targetLow: 40,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
correlationEnabled: true,
|
||||
});
|
||||
|
||||
// The readings are the half that would go missing quietly: a metric with
|
||||
@@ -377,6 +405,25 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes persisted pattern identity and dismissal evidence back", async () => {
|
||||
const { written } = await roundTrip();
|
||||
const pattern = written.find((w) => w.model === "correlationPattern");
|
||||
expect(pattern).toBeDefined();
|
||||
expect(pattern!.data).toMatchObject({
|
||||
userId: OWNER,
|
||||
canonicalKey: `p1:${"a".repeat(64)}`,
|
||||
family: "DISCOVERY_RETROSPECTIVE",
|
||||
factorKey: "CUSTOM_METRIC:metric-1",
|
||||
outcomeKey: "RESTING_HEART_RATE",
|
||||
dismissedEvidenceHash: "b".repeat(64),
|
||||
dismissedEffectSize: 0.34,
|
||||
dismissedSampleSize: 42,
|
||||
});
|
||||
expect(pattern!.data.dismissedAt).toEqual(
|
||||
new Date("2026-07-19T09:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the day's cumulative shape back, every hour of it", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ interface RestoreResponse {
|
||||
documents: number;
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
};
|
||||
}
|
||||
@@ -1121,6 +1122,7 @@ const handler = apiHandler(
|
||||
documents: documents.count,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
};
|
||||
},
|
||||
@@ -1249,6 +1251,7 @@ const handler = apiHandler(
|
||||
healthProfile: summary.healthProfile,
|
||||
customMetrics: summary.customMetrics,
|
||||
customMetricEntries: summary.customMetricEntries,
|
||||
correlationPatterns: summary.correlationPatterns,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,6 +69,12 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/correlation-patterns", () => ({
|
||||
PATTERN_FAMILIES: { fixed: "FIXED" },
|
||||
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
|
||||
decisionForEvidence: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
type GlucoseClinicalMetrics,
|
||||
} from "@/lib/analytics/glucose-metrics";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -419,6 +424,57 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
const fixedDefinitions = [
|
||||
{
|
||||
key: "bpCompliance" as const,
|
||||
factorKey: "HYPOTHESIS:MEDICATION_COMPLIANCE",
|
||||
outcomeKey: "BLOOD_PRESSURE_SYS",
|
||||
},
|
||||
{
|
||||
key: "moodPulse" as const,
|
||||
factorKey: "HYPOTHESIS:MOOD",
|
||||
outcomeKey: "RESTING_PULSE",
|
||||
},
|
||||
{
|
||||
key: "weightWeekday" as const,
|
||||
factorKey: "HYPOTHESIS:WEEKDAY",
|
||||
outcomeKey: "WEIGHT",
|
||||
},
|
||||
];
|
||||
const acceptedFixed = fixedDefinitions.flatMap((definition) => {
|
||||
const result = correlations[definition.key];
|
||||
return result.status === "ok"
|
||||
? [
|
||||
{
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
sampleSize: result.n,
|
||||
effectSize: result.statistic,
|
||||
pValue: result.pValue,
|
||||
qValue: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const fixedDecisions = await syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
family: PATTERN_FAMILIES.fixed,
|
||||
accepted: acceptedFixed,
|
||||
});
|
||||
const correlationsWithPatterns = { ...correlations };
|
||||
for (const definition of fixedDefinitions) {
|
||||
const result = correlations[definition.key];
|
||||
if (result.status !== "ok") continue;
|
||||
const decision = decisionForEvidence(fixedDecisions, {
|
||||
factorKey: definition.factorKey,
|
||||
outcomeKey: definition.outcomeKey,
|
||||
lagDays: 0,
|
||||
});
|
||||
correlationsWithPatterns[definition.key] = decision
|
||||
? { ...result, ...decision }
|
||||
: result;
|
||||
}
|
||||
|
||||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||||
// composite of BP-in-target % + weight-trend alignment + mood
|
||||
@@ -509,7 +565,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
bpInTargetSpanDays90,
|
||||
glucoseByContext: glucoseByContextOut,
|
||||
glucoseClinical: glucoseClinicalOut,
|
||||
correlations,
|
||||
correlations: correlationsWithPatterns,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// v1.28.x — the latest night's source-discrepancy annotation, computed
|
||||
|
||||
@@ -47,6 +47,7 @@ const EXISTING = {
|
||||
targetHigh: 60,
|
||||
decimals: 1,
|
||||
description: null,
|
||||
correlationEnabled: false,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
@@ -127,6 +128,26 @@ describe("PATCH /api/custom-metrics/[id]", () => {
|
||||
expect(updateCall.data.targetHigh).toBe(50);
|
||||
});
|
||||
|
||||
it("persists only the explicit correlation opt-in field", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
|
||||
EXISTING as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.update).mockResolvedValue({
|
||||
...EXISTING,
|
||||
correlationEnabled: true,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
patchReq({ correlationEnabled: true }),
|
||||
params("cm-1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.customMetric.update).mock
|
||||
.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(updateCall.data).toEqual({ correlationEnabled: true });
|
||||
});
|
||||
|
||||
it("422s an inverted partial bound update without updating", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// existing.targetHigh is 60; moving the low bound above it inverts.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -91,6 +92,7 @@ export const PATCH = apiHandler(
|
||||
action: { name: "custom-metric.entry.update" },
|
||||
meta: { customMetricId: id, entryId },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess(serialiseCustomMetricEntry(updated));
|
||||
},
|
||||
@@ -120,6 +122,7 @@ export const DELETE = apiHandler(
|
||||
action: { name: "custom-metric.entry.delete" },
|
||||
meta: { customMetricId: id, entryId },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -152,6 +153,7 @@ async function postCustomMetricEntry(
|
||||
action: { name: "custom-metric.entry.create" },
|
||||
meta: { customMetricId: id, entryId: created.id },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess(serialiseCustomMetricEntry(created), 201);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -121,6 +122,9 @@ export const PATCH = apiHandler(
|
||||
if (d.targetHigh !== undefined) data.targetHigh = d.targetHigh;
|
||||
if (d.decimals !== undefined) data.decimals = d.decimals;
|
||||
if (d.description !== undefined) data.description = d.description;
|
||||
if (d.correlationEnabled !== undefined) {
|
||||
data.correlationEnabled = d.correlationEnabled;
|
||||
}
|
||||
|
||||
// Inverted-range guard for a PARTIAL bound update. The schema refine only
|
||||
// fires when both bounds arrive together; moving a single bound past the
|
||||
@@ -143,6 +147,7 @@ export const PATCH = apiHandler(
|
||||
action: { name: "custom-metric.metric.update" },
|
||||
meta: { customMetricId: id },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess(serialiseCustomMetric(updated));
|
||||
},
|
||||
@@ -179,6 +184,7 @@ export const DELETE = apiHandler(
|
||||
action: { name: "custom-metric.metric.delete" },
|
||||
meta: { customMetricId: id },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ const ROW = {
|
||||
targetHigh: null,
|
||||
decimals: null,
|
||||
description: null,
|
||||
correlationEnabled: false,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
@@ -108,7 +109,13 @@ describe("POST /api/custom-metrics", () => {
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.customMetric.create).mockResolvedValue(ROW as never);
|
||||
|
||||
const res = await POST(postReq({ name: "Grip strength", unit: "kg" }));
|
||||
const res = await POST(
|
||||
postReq({
|
||||
name: "Grip strength",
|
||||
unit: "kg",
|
||||
correlationEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const createCall = vi.mocked(prisma.customMetric.create).mock
|
||||
.calls[0][0] as {
|
||||
@@ -117,6 +124,7 @@ describe("POST /api/custom-metrics", () => {
|
||||
expect(createCall.data.userId).toBe("user-1");
|
||||
expect(createCall.data.name).toBe("Grip strength");
|
||||
expect("userId" in createCall.data).toBe(true);
|
||||
expect(createCall.data.correlationEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("409s a duplicate live name without creating", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -94,8 +95,15 @@ async function postCustomMetric(request: NextRequest) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { name, unit, targetLow, targetHigh, decimals, description } =
|
||||
parsed.data;
|
||||
const {
|
||||
name,
|
||||
unit,
|
||||
targetLow,
|
||||
targetHigh,
|
||||
decimals,
|
||||
description,
|
||||
correlationEnabled,
|
||||
} = parsed.data;
|
||||
|
||||
// The `@@unique([userId, name])` index is the structural backstop and spans
|
||||
// soft-deleted rows too. Resolve the name up front: a LIVE collision is a
|
||||
@@ -120,6 +128,7 @@ async function postCustomMetric(request: NextRequest) {
|
||||
targetHigh: targetHigh ?? null,
|
||||
decimals: decimals ?? null,
|
||||
description: description ?? null,
|
||||
correlationEnabled,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
@@ -132,6 +141,7 @@ async function postCustomMetric(request: NextRequest) {
|
||||
targetHigh: targetHigh ?? null,
|
||||
decimals: decimals ?? null,
|
||||
description: description ?? null,
|
||||
correlationEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +155,7 @@ async function postCustomMetric(request: NextRequest) {
|
||||
action: { name: "custom-metric.metric.create" },
|
||||
meta: { customMetricId: created.id, revived: existing !== null },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
// A freshly created metric has no values; a revived one may retain entries
|
||||
// logged before it was soft-deleted, so resolve the latest + count for it.
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// channels in (read from EnvironmentContext); default empty so no env
|
||||
// channel produces a point.
|
||||
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,6 +56,15 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/correlation-patterns", () => ({
|
||||
PATTERN_FAMILIES: {
|
||||
discoveryRetrospective: "DISCOVERY_RETROSPECTIVE",
|
||||
discoveryRecent: "DISCOVERY_RECENT",
|
||||
},
|
||||
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
|
||||
decisionForEvidence: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
|
||||
@@ -38,9 +38,15 @@ import {
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import {
|
||||
decisionForEvidence,
|
||||
PATTERN_FAMILIES,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
import {
|
||||
buildMeasurementDailySeries,
|
||||
fetchComplianceSeries,
|
||||
fetchCustomMetricBehaviourSeries,
|
||||
fetchEnvironmentSeries,
|
||||
fetchLabDraws,
|
||||
fetchMeasurementWindowSeries,
|
||||
@@ -126,16 +132,22 @@ export const GET = apiHandler(async () => {
|
||||
// their own sources. Each degrades to an empty series when the user has no
|
||||
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
|
||||
const [complianceSeries, symptomSeries, labDraws, environmentSeries] =
|
||||
await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
]);
|
||||
const [
|
||||
complianceSeries,
|
||||
symptomSeries,
|
||||
labDraws,
|
||||
environmentSeries,
|
||||
customMetricSeries,
|
||||
] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
fetchCustomMetricBehaviourSeries(user.id, tz, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
key === "MOOD"
|
||||
@@ -170,6 +182,9 @@ export const GET = apiHandler(async () => {
|
||||
for (const envSeries of environmentSeries) {
|
||||
series.push(envSeries);
|
||||
}
|
||||
for (const customSeries of customMetricSeries) {
|
||||
series.push(customSeries);
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series, { locale });
|
||||
|
||||
@@ -191,6 +206,46 @@ export const GET = apiHandler(async () => {
|
||||
locale,
|
||||
});
|
||||
|
||||
const toEvidence = (pattern: (typeof result.discovered)[number]) => ({
|
||||
factorKey: pattern.behaviour,
|
||||
outcomeKey: pattern.outcome,
|
||||
lagDays: pattern.lagDays,
|
||||
sampleSize: pattern.n,
|
||||
effectSize: pattern.r,
|
||||
pValue: pattern.pValue,
|
||||
qValue: pattern.qValue,
|
||||
});
|
||||
const [retrospectiveDecisions, recentDecisions] = await Promise.all([
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
family: PATTERN_FAMILIES.discoveryRetrospective,
|
||||
accepted: result.discovered.map(toEvidence),
|
||||
computedAt: now,
|
||||
}),
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
family: PATTERN_FAMILIES.discoveryRecent,
|
||||
accepted: emerging.emerging.map(toEvidence),
|
||||
computedAt: now,
|
||||
}),
|
||||
]);
|
||||
const attachPatternDecision = (
|
||||
pattern: (typeof result.discovered)[number],
|
||||
decisions: typeof retrospectiveDecisions,
|
||||
) => {
|
||||
const decision = decisionForEvidence(decisions, toEvidence(pattern));
|
||||
return decision ? { ...pattern, ...decision } : pattern;
|
||||
};
|
||||
const discovered = result.discovered.map((pattern) =>
|
||||
attachPatternDecision(pattern, retrospectiveDecisions),
|
||||
);
|
||||
const emergingWithDecisions = {
|
||||
...emerging,
|
||||
emerging: emerging.emerging.map((pattern) =>
|
||||
attachPatternDecision(pattern, recentDecisions),
|
||||
),
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
meta: {
|
||||
@@ -220,8 +275,18 @@ export const GET = apiHandler(async () => {
|
||||
(sum, s) => sum + s.points.length,
|
||||
0,
|
||||
),
|
||||
custom_metric_channels: customMetricSeries.length,
|
||||
custom_metric_days: customMetricSeries.reduce(
|
||||
(sum, channel) => sum + channel.points.length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...result, emerging, labCorrelations });
|
||||
return apiSuccess({
|
||||
...result,
|
||||
discovered,
|
||||
emerging: emergingWithDecisions,
|
||||
labCorrelations,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
correlationPattern: { findFirst: vi.fn(), update: vi.fn() },
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "owner-1", username: "owner", role: "USER", locale: "en" },
|
||||
};
|
||||
const PATTERN = {
|
||||
id: "pattern-1",
|
||||
userId: "owner-1",
|
||||
canonicalKey: `p1:${"a".repeat(64)}`,
|
||||
evidenceHash: "b".repeat(64),
|
||||
effectSize: 0.34,
|
||||
sampleSize: 42,
|
||||
dismissedAt: null,
|
||||
};
|
||||
const params = { params: Promise.resolve({ id: PATTERN.id }) };
|
||||
|
||||
function request(body: unknown) {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/insights/patterns/${PATTERN.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("PATCH /api/insights/patterns/[id]", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("stores the current evidence as the account-scoped dismissal baseline", async () => {
|
||||
vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue(
|
||||
PATTERN as never,
|
||||
);
|
||||
vi.mocked(prisma.correlationPattern.update).mockResolvedValue({
|
||||
...PATTERN,
|
||||
dismissedAt: new Date("2026-07-28T10:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const response = await PATCH(request({ dismissed: true }), params);
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.correlationPattern.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: PATTERN.id, userId: "owner-1", isCurrent: true },
|
||||
});
|
||||
expect(prisma.correlationPattern.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: PATTERN.id },
|
||||
data: expect.objectContaining({
|
||||
dismissedEvidenceHash: PATTERN.evidenceHash,
|
||||
dismissedEffectSize: PATTERN.effectSize,
|
||||
dismissedSampleSize: PATTERN.sampleSize,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reveal a pattern outside the authenticated account", async () => {
|
||||
vi.mocked(prisma.correlationPattern.findFirst).mockResolvedValue(null);
|
||||
const response = await PATCH(request({ dismissed: true }), params);
|
||||
expect(response.status).toBe(404);
|
||||
expect(prisma.correlationPattern.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects fields other than the dismissal decision", async () => {
|
||||
const response = await PATCH(
|
||||
request({ dismissed: true, factorKey: "OTHER" }),
|
||||
params,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
expect(prisma.correlationPattern.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateCorrelationPatternSchema } from "@/lib/validations/correlation-patterns";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
const patternIdSchema = z.string().min(1).max(128);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const parsedId = patternIdSchema.safeParse((await params).id);
|
||||
if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422);
|
||||
const id = parsedId.data;
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateCorrelationPatternSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const pattern = await prisma.correlationPattern.findFirst({
|
||||
where: { id, userId: user.id, isCurrent: true },
|
||||
});
|
||||
if (!pattern) return apiError("Correlation pattern not found", 404);
|
||||
|
||||
const dismissedAt = parsed.data.dismissed ? new Date() : null;
|
||||
const updated = await prisma.correlationPattern.update({
|
||||
where: { id: pattern.id },
|
||||
data: parsed.data.dismissed
|
||||
? {
|
||||
dismissedAt,
|
||||
dismissedEvidenceHash: pattern.evidenceHash,
|
||||
dismissedEffectSize: pattern.effectSize,
|
||||
dismissedSampleSize: pattern.sampleSize,
|
||||
}
|
||||
: {
|
||||
dismissedAt: null,
|
||||
dismissedEvidenceHash: null,
|
||||
dismissedEffectSize: null,
|
||||
dismissedSampleSize: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
canonicalKey: true,
|
||||
dismissedAt: true,
|
||||
evidenceHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog(
|
||||
parsed.data.dismissed
|
||||
? "correlationPattern.dismiss"
|
||||
: "correlationPattern.restore",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { correlationPatternId: pattern.id },
|
||||
},
|
||||
);
|
||||
annotate({
|
||||
action: {
|
||||
name: parsed.data.dismissed
|
||||
? "insights.pattern.dismiss"
|
||||
: "insights.pattern.restore",
|
||||
},
|
||||
meta: { correlationPatternId: pattern.id },
|
||||
});
|
||||
invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
id: updated.id,
|
||||
canonicalKey: updated.canonicalKey,
|
||||
dismissed: updated.dismissedAt != null,
|
||||
dismissedAt: updated.dismissedAt?.toISOString() ?? null,
|
||||
evidenceHash: updated.evidenceHash,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const patterns = await prisma.correlationPattern.findMany({
|
||||
where: { userId: user.id, isCurrent: true },
|
||||
orderBy: [{ lastComputedAt: "desc" }, { canonicalKey: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
canonicalKey: true,
|
||||
family: true,
|
||||
factorKey: true,
|
||||
outcomeKey: true,
|
||||
lagDays: true,
|
||||
sampleSize: true,
|
||||
effectSize: true,
|
||||
pValue: true,
|
||||
qValue: true,
|
||||
evidenceHash: true,
|
||||
lastComputedAt: true,
|
||||
dismissedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.pattern.list" },
|
||||
meta: { total: patterns.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
patterns: patterns.map((pattern) => ({
|
||||
...pattern,
|
||||
lastComputedAt: pattern.lastComputedAt.toISOString(),
|
||||
dismissedAt: pattern.dismissedAt?.toISOString() ?? null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user