mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user