feat(daily): add the daily-digest spine and priority-card primitive

Introduce the two foundational primitives of the unified daily-value
system, with no user surface yet:

- PriorityItem: the one wire-serialisable model behind every "worth a
  look" rail item, coach check-in, milestone, and device-verdict pointer.
  Its Lucide glyph is derived from the closed `kind` enum in the renderer
  so the model itself stays JSON-serialisable across the API and future
  native-client boundaries.
- PriorityCard: a calm, token-only, reduced-motion-aware card composed
  entirely from the shipped Card / TileHeader / ProseBlocks primitives —
  no new visual language, semantic-token status wash only, no markdown.
- buildDailyDigest: one pure, deterministic server composer that assembles
  the day's read from data that already exists — the nightly briefing
  lifted read-only from the insights cache plus the dashboard-snapshot
  health score, meds-today, and sleep freshness. It computes no analytics
  and never reaches an AI provider. It emits the DailyDigest DTO the Today
  surface, the daily push, and a future iOS widget will all consume.
  Deterministic item builders cover overdue doses, broken integrations,
  and due Vorsorge reminders; the push line prefers the cached briefing
  lead with a deterministic score floor so a keyless self-hoster still
  gets a first-class line.

The read seam GET /api/daily/digest gathers the cached inputs, gates on
the insights module (cookie or Bearer; userId from the session), and
returns the digest. OpenAPI regenerated; the route is covered by the
module gate inventory. Digest strings ship in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 19:13:01 +02:00
1 parent ecc34880ba
commit ba6c0990bf
18 files changed
+1580

No files matched your search

@@ -111,6 +111,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
// is the AI-narrative daily layer, so it gates on the `insights` module via
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
// token when the account turned insights off.
"src/app/api/daily",
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
// mental-health posture): both the batch ingest and the window-summary
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { apiError } from "@/lib/api-response";
import type { DailyDigest } from "@/lib/daily/digest";
/**
* `GET /api/daily/digest` — the P3 read seam.
*
* Under test: cookie/Bearer auth narrows the user, the `insights` module gate
* returns a 403 `module.disabled` envelope when off (even for a valid
* session), and the happy path returns the `DailyDigest` DTO shape. The digest
* itself is composed by `loadDailyDigest`, mocked here — the route must reach
* no provider (there is nothing AI-shaped in this module graph).
*/
vi.mock("@/lib/db", () => ({
prisma: { appSettings: { findUnique: vi.fn().mockResolvedValue(null) } },
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
}));
vi.mock("@/lib/daily/load-digest", () => ({ loadDailyDigest: vi.fn() }));
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 { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { loadDailyDigest } from "@/lib/daily/load-digest";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
locale: "en",
},
};
const DIGEST: DailyDigest = {
generatedAt: "2026-07-16T09:00:00.000Z",
phase: "final",
sleepPending: false,
score: { value: 82, band: "good", delta: 3 },
topSignal: null,
briefingLead: "Blood pressure is holding steady.",
line: "Blood pressure is holding steady.",
worthALook: [],
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest(new URL("http://localhost/api/daily/digest"));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(loadDailyDigest).mockResolvedValue(DIGEST);
});
describe("GET /api/daily/digest", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("returns the 403 module.disabled envelope when insights is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError('Module "insights" is not enabled', 403, {
errorCode: "module.disabled",
module: "insights",
}),
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = await res.json();
expect(body.meta?.errorCode).toBe("module.disabled");
expect(vi.mocked(loadDailyDigest)).not.toHaveBeenCalled();
});
it("gates on the insights module key", async () => {
await callGet(makeReq());
expect(vi.mocked(requireModuleEnabled)).toHaveBeenCalledWith(
"user-1",
"insights",
);
});
it("returns 200 with the DailyDigest DTO on the happy path", async () => {
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data).toEqual(DIGEST);
expect(vi.mocked(loadDailyDigest)).toHaveBeenCalledOnce();
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* GET /api/daily/digest
*
* The read seam for the unified daily-value system (P3). Returns the
* `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget
* all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the
* nightly briefing lifted read-only from `User.insightsCachedText`, the
* dashboard-snapshot health score / meds-today / sleep freshness) plus two
* light deterministic reads (broken integrations, overdue Vorsorge). No
* provider call is reachable from this path, and nothing warms on mount.
*
* Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the
* resolved session — never a body field. Gated on the `insights` module (the
* daily digest is the AI-narrative daily layer); a disabled account gets the
* standard 403 `module.disabled` envelope even over a Bearer token. The rail's
* data-tile inputs inherit their own module gates via the snapshot builder.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { loadDailyDigest } from "@/lib/daily/load-digest";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
const digest = await loadDailyDigest(user);
const response = apiSuccess(digest);
response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return response;
});