feat(insights): persist correlation patterns

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 14:01:11 +02:00
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 () => ({
+57 -1
View File
@@ -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);
}
+6
View File
@@ -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 () => {
+13 -2
View File
@@ -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 () => ({
+76 -11
View File
@@ -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,
});
},
);
+42
View File
@@ -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,
})),
});
});