Merge branch 'feat/module-surfaces' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:10:11 +02:00
commit ab0d1b4b71
67 files changed
+1975 -703

No files matched your search

@@ -83,7 +83,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { apiError } from "@/lib/api-response";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
@@ -100,6 +100,10 @@ beforeEach(() => {
// prior test would serve a hit and skip the builder every assertion spies on.
__resetAllCachesForTests();
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
// Every module on unless a test says otherwise.
vi.mocked(resolveModuleMap).mockResolvedValue(
{} as Awaited<ReturnType<typeof resolveModuleMap>>,
);
// v1.15.20 — default to an allowing analytics-read budget.
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
allowed: true,
@@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => {
);
});
it("skips the lab draws with the labs module off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(resolveModuleMap).mockResolvedValue({
labs: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
});
it("recomputes rather than serve a cached body after a module was switched off", async () => {
// The switched-off set is part of the cache key. Watched red with the
// suffix removed from the key: the second call is a cache hit and the
// measurement window is read once.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
vi.mocked(resolveModuleMap).mockResolvedValue({
mood: false,
} as Awaited<ReturnType<typeof resolveModuleMap>>);
await callGet(makeReq());
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
});
// v1.18.0 (B2) — the route now also requires the `insights` module.
it("returns 403 + module.disabled when the insights module is off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+24 -5
View File
@@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
MODULE_KEYS,
requireModuleEnabled,
resolveModuleMap,
} from "@/lib/modules/gate";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
@@ -89,12 +94,18 @@ export const GET = apiHandler(async () => {
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
// so a cache hit performs no write on the read path.
// Statistics serve only with their owning modules: a channel whose module
// is off never enters the scan. The switched-off set is part of the cache
// key, so toggling a module never serves a body computed under the other
// setting.
const modules = await resolveModuleMap(user.id);
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
const body = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildCorrelationsResponse>>
>,
`${user.id}|correlations|${locale}`,
() => buildCorrelationsResponse(user.id, locale),
`${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`,
() => buildCorrelationsResponse(user.id, locale, modules),
annotate,
);
@@ -104,6 +115,7 @@ export const GET = apiHandler(async () => {
async function buildCorrelationsResponse(
userId: string,
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
modules: SurfaceModuleMap,
) {
const profile = await prisma.user.findUnique({
where: { id: userId },
@@ -125,8 +137,15 @@ async function buildCorrelationsResponse(
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
// a different pass over a different grain, so they are not matrix channels.
const [matrix, labDraws] = await Promise.all([
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }),
fetchLabDraws(userId, tz, since),
assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "tiered",
modules,
}),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
]);
const { series, diagnostics } = matrix;