mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: wave/v1220-coachui into release/v1.21.2
This commit is contained in:
commit
f5195cfcda
9 files changed
+605
-5
No files matched your search
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.22.0 (A3) — the seeded-question route resolves today's single most
|
||||
* notable derived signal into a tappable Coach opener, server-side. When the
|
||||
* confidence + notability gate yields nothing the route returns
|
||||
* `{ signal: null }` and the hero keeps its neutral greeting — never a
|
||||
* fabricated opener.
|
||||
*/
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
resolveModuleMap: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }),
|
||||
}));
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the profile loader + the shared briefing detector so the route test
|
||||
// stays isolated from the compute tier.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/insights/derived")>()),
|
||||
loadBaselineProfile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/derived-briefing", () => ({
|
||||
detectDerivedBriefingSignals: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { GET } from "../route";
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/coach/seeded-question"),
|
||||
);
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/seeded-question", () => {
|
||||
it("returns the most notable signal (the head) when one crosses the gate", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({
|
||||
signals: [
|
||||
{
|
||||
sourceMetric: "readiness",
|
||||
label: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
confidence: 72,
|
||||
},
|
||||
{
|
||||
sourceMetric: "recovery",
|
||||
label: "recovery",
|
||||
score: 49,
|
||||
band: "red",
|
||||
confidence: 65,
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
signal: { sourceMetric: string; score: number; band: string } | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toEqual({
|
||||
sourceMetric: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.22.0 (A3) — pre-seeded relevance opener for the unscoped Coach.
|
||||
*
|
||||
* `GET /api/insights/coach/seeded-question` resolves today's single most
|
||||
* notable derived wellness signal (readiness / recovery shift, the same
|
||||
* confidence-gated detector the daily briefing uses) into a tappable
|
||||
* suggested opener for the Coach's blank new-chat hero. When no signal
|
||||
* crosses the gate the route returns `{ signal: null }` and the hero
|
||||
* keeps its neutral greeting — never a fabricated opener.
|
||||
*
|
||||
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
|
||||
* and never recomputes it (server-authoritative parity). This is an
|
||||
* INTERNAL same-origin route — it is deliberately NOT registered in the
|
||||
* OpenAPI contract the iOS client consumes.
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
* analytics-read rate limit, `insights` module gate.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Resolved opener the hero renders, or null when nothing notable. */
|
||||
export interface CoachSeededQuestionDTO {
|
||||
/**
|
||||
* The notable signal driving the opener, or null when nothing crossed
|
||||
* the briefing detector's confidence + notability gate.
|
||||
*/
|
||||
signal: {
|
||||
/** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */
|
||||
sourceMetric: string;
|
||||
/** Latest 0–100 score. */
|
||||
score: number;
|
||||
/** Band — `yellow` / `red` (green never surfaces as notable). */
|
||||
band: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader; passed into the detector,
|
||||
// never re-fetched per candidate (the detector probes readiness +
|
||||
// recovery off the one profile).
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
const detected = await detectDerivedBriefingSignals(user.id, profile);
|
||||
|
||||
// The detector returns 1–2 signals in priority order; the opener anchors
|
||||
// on the single most notable one (the head).
|
||||
const top = detected?.signals[0] ?? null;
|
||||
const signal: CoachSeededQuestionDTO["signal"] = top
|
||||
? { sourceMetric: top.sourceMetric, score: top.score, band: top.band }
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: {
|
||||
has_signal: signal !== null,
|
||||
source_metric: signal?.sourceMetric ?? "none",
|
||||
band: signal?.band ?? "none",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ signal } satisfies CoachSeededQuestionDTO);
|
||||
});
|
||||
Reference in new issue
Block a user