merge: wave/v1220-coachui into release/v1.21.2

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:58:51 +02:00
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);
});