Merge branch 'main' into release/v1.30.31

# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 10:49:00 +02:00
commit 02a56047a4
74 files changed
+1935 -2323

No files matched your search

@@ -25,6 +25,12 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
* matching the `onboarding` page's existing test pattern.
*/
// The page bounces a disabled-module direct hit to /insights (it used to
// render a blank page), so the SSR render needs a router in scope.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
+19 -4
View File
@@ -34,6 +34,8 @@ import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -260,6 +262,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
export default function AchievementsPage() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
const router = useRouter();
// v1.18.0 — the achievements module gate. When the account has the
// module turned off the whole page disappears: an empty state stands in
@@ -276,14 +279,26 @@ export default function AchievementsPage() {
enabled: isAuthenticated && achievementsEnabled,
});
useEffect(() => {
if (!authLoading && isAuthenticated && !achievementsEnabled) {
router.push("/insights");
}
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
if (authLoading || (achievementsEnabled && isLoading)) {
return <PageAuthGate label={t("common.loading")} />;
}
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
// is hidden by the same gate, so a direct hit on /achievements renders
// nothing rather than an orphaned shell. No new i18n string is minted
// (the disabled state has no copy of its own).
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
// hidden by the same gate, so this only catches a direct URL hit. It used to
// render `null` and stop there, which left a blank page with no explanation
// and no route anywhere — a dead end rather than a gate. It now bounces to
// /insights, matching every other module-gated page (`useModulePageGuard`);
// the brief `null` is the pre-redirect frame, not the destination.
//
// The shared guard is not used wholesale here because it also redirects an
// unauthenticated visitor to /auth/login, and this page deliberately answers
// that case with a "sign in to see your achievements" header instead.
if (isAuthenticated && !achievementsEnabled) {
return null;
}
@@ -93,8 +93,12 @@ vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() }));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
enforceBudget: vi.fn(),
recordSpend: vi.fn(),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 0,
totalAfter: 0,
})),
reconcileSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
+8 -7
View File
@@ -9,8 +9,9 @@
* Behaviour:
* 1. requireAuth() — cookie session OR bearer token (iOS app).
* 2. Validate body with `coachChatRequestSchema`.
* 3. enforceBudget() — 429 with `coach.budget.exceeded` when the
* user has already burned the day's token cap.
* 3. reserveBudget(resolveDailyCap(chain)) — 429 with
* `coach.budget.exceeded` when the day's token cap for the
* credential that would pay for the call is already burned.
* 4. detectRefusal() — pattern-based prompt-injection +
* off-topic guard. Refusal emits a single `token` frame with
* the localised refusal copy and a `done` frame; never hits a
@@ -39,6 +40,8 @@ import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { localeLanguageNames as LANGUAGE_NAMES } from "@/lib/i18n/config";
import {
AllProvidersFailedError,
runStreamingRawCompletionWithFallback,
@@ -386,9 +389,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
message,
refusalText:
replayed.message ??
(locale === "de"
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
getServerTranslator(locale).t("coach.refusal.conversationPoisoned"),
});
}
} else {
@@ -622,7 +623,7 @@ ${groundingBlock}`
CONVERSATION
${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}.`;
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
// the provider call. The reservation increments the day's total by the
@@ -727,7 +728,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
CONVERSATION
${transcript}
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`,
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. Fetch any figures you cite with the tools first.`,
},
];
const loop = await runCoachToolLoop({
@@ -34,8 +34,16 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-05-28"),
enforceBudget: vi.fn(async () => undefined),
recordSpend: vi.fn(async () => undefined),
reserveBudget: vi.fn(async () => ({
allowed: true,
reserved: 700,
totalAfter: 700,
})),
reconcileSpend: vi.fn(async () => undefined),
// The REAL cap resolver is used in the cap test below; the default mock
// returns a distinctive figure so an accidental default-cap regression is
// visible in the assertion rather than silently passing.
resolveDailyCap: vi.fn(() => 1_234_567),
}));
vi.mock("@/lib/ai/provider", () => ({
@@ -73,9 +81,16 @@ vi.mock("@/lib/logging/context", () => ({
import { POST } from "../route";
import { requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { enforceBudget } from "@/lib/ai/coach/budget";
import {
reserveBudget,
reconcileSpend,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import {
runRawCompletionWithFallback,
AllProvidersFailedError,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
function postReq(body: unknown): NextRequest {
@@ -98,7 +113,12 @@ beforeEach(() => {
remaining: 9,
resetAt: Date.now() + 60_000,
});
vi.mocked(enforceBudget).mockResolvedValue(undefined);
vi.mocked(reserveBudget).mockResolvedValue({
allowed: true,
reserved: 700,
totalAfter: 700,
});
vi.mocked(resolveDailyCap).mockReturnValue(1_234_567);
vi.mocked(resolveProviderChain).mockResolvedValue([
{
providerType: "openai",
@@ -216,6 +236,107 @@ describe("POST /api/medications/extract — happy path", () => {
});
});
describe("POST /api/medications/extract — daily budget", () => {
function okCompletion(tokensUsed: number | null) {
return {
result: {
content: JSON.stringify({ dose: "5", doseUnit: "mg" }),
tokensUsed,
model: "gpt-4",
providerType: "anthropic",
},
workingProvider: { providerType: "openai", instance: {} as never },
fallbackHops: [],
};
}
it("caps against the COST OWNER — the resolved chain, not the operator ceiling", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce(
okCompletion(120) as never,
);
await POST(postReq({ text: "5mg weekly" }) as never);
// The cap is derived from the chain that would actually pay for the call.
// A user-key chain ("openai") therefore never rides the operator ceiling —
// `budget.test.ts` pins that resolveDailyCap([{openai}]) === USER_PLAN_CAP.
expect(resolveDailyCap).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ providerType: "openai" }),
]),
);
// ...and the reservation is made against exactly that cap, not a default.
expect(reserveBudget).toHaveBeenCalledWith(
"user-1",
expect.any(Number),
"2026-05-28",
1_234_567,
);
});
it("returns 429 without calling the provider when the day's cap is spent", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(reserveBudget).mockResolvedValueOnce({
allowed: false,
reserved: 700,
totalAfter: 9_999_999,
});
const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never);
expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/budget/i);
// The refusal happens BEFORE any upstream spend.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
it("reconciles the reservation against the provider's reported count", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce(
okCompletion(120) as never,
);
await POST(postReq({ text: "5mg weekly" }) as never);
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
700,
120,
"2026-05-28",
);
});
it("bills the reservation, not zero, when the provider reports no count", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockResolvedValueOnce(
okCompletion(null) as never,
);
await POST(postReq({ text: "5mg weekly" }) as never);
expect(reconcileSpend).toHaveBeenCalledWith(
"user-1",
700,
700,
"2026-05-28",
);
});
it("refunds the whole reservation when the provider chain fails", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
vi.mocked(runRawCompletionWithFallback).mockRejectedValueOnce(
new AllProvidersFailedError([]),
);
const res = await POST(postReq({ text: "5mg weekly" }) as never);
expect(res.status).toBe(503);
expect(reconcileSpend).toHaveBeenCalledWith("user-1", 700, 0, "2026-05-28");
});
});
describe("POST /api/medications/extract — validation + provider errors", () => {
it("returns 422 on a malformed body", async () => {
vi.mocked(requireAuth).mockResolvedValue(AUTH_OK as never);
+57 -15
View File
@@ -26,13 +26,19 @@
* 1. requireAuth() — cookie OR Bearer.
* 2. requireAssistantSurface("coach") — operator can disable.
* 3. checkRateLimit(...) — 10 requests / 5 min / user.
* 4. enforceBudget() — daily Coach token ceiling.
* 5. resolveProviderChain() — fall back through providers.
* 4. resolveProviderChain() — fall back through providers.
* 5. reserveBudget(resolveDailyCap(chain)) — atomic daily token ceiling,
* capped against the COST OWNER: a self-hoster on their own key or a
* local model is measured against the user-plan ceiling, not the
* operator's. Runs AFTER the chain resolves because the cap depends
* on which credential would actually pay for the call.
* 6. runRawCompletionWithFallback() — same machinery the Coach
* uses; structured-output is enforced via JSON.parse + Zod here
* rather than via the strict-schema wrapper.
* 7. applyCitationGuard() — drop hallucinated name/dose.
* 8. recordSpend() — bump the day's ledger.
* 8. reconcileSpend() — settle the reservation against
* the provider's reported count on EVERY exit path, refunding when
* nothing was generated.
*
* Response: `{ data: MedicationExtractionResult, error: null }`. Every
* field on the result is optional; the wizard is the merge target.
@@ -62,8 +68,9 @@ import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
buildDateKey,
enforceBudget,
recordSpend,
reconcileSpend,
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import {
applyCitationGuard,
@@ -75,6 +82,12 @@ import {
/** Hard cap on the free-text payload; mirrors `coachChatRequestSchema.message`. */
const MAX_TEXT_LENGTH = 2000;
/**
* Output ceiling for the extraction reply — a single small JSON object. Also
* the output half of the budget reservation estimate.
*/
const MAX_OUTPUT_TOKENS = 600;
const requestSchema = z.object({
text: z.string().min(1).max(MAX_TEXT_LENGTH),
/** Optional UI locale; informational hint for the model. */
@@ -154,8 +167,6 @@ async function handleExtract(request: NextRequest): Promise<Response> {
});
}
await enforceBudget(userId);
// Resolve the same provider chain the Coach uses. If the user has no
// provider configured AND the operator has not seeded an admin key,
// surface a structured 503 — the wizard hides the overlay.
@@ -181,6 +192,31 @@ async function handleExtract(request: NextRequest): Promise<Response> {
locale,
});
// Atomically reserve the day's budget BEFORE the provider call. The old
// `enforceBudget` read-then-write left a TOCTOU window (concurrent calls
// could each observe a sub-cap total and all proceed) AND passed no cap, so
// it always measured against `OPERATOR_COST_CAP` — rationing a self-hoster
// paying for their own key by the operator's ceiling. `resolveDailyCap`
// charges the operator ceiling only when the chain's primary is the
// operator's own credential; every other chain gets the user-plan ceiling.
const dateKey = buildDateKey();
const estimatedTokens =
MAX_OUTPUT_TOKENS +
Math.ceil((systemPrompt.length + userPrompt.length) / 4);
const reservation = await reserveBudget(
userId,
estimatedTokens,
dateKey,
resolveDailyCap(chain),
);
if (!reservation.allowed) {
annotate({
action: { name: "medications.extract.budget-exceeded" },
meta: { totalAfter: reservation.totalAfter },
});
return apiError("coach.budget.exceeded", 429);
}
let completion;
try {
completion = await runRawCompletionWithFallback({
@@ -194,13 +230,18 @@ async function handleExtract(request: NextRequest): Promise<Response> {
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
maxTokens: MAX_OUTPUT_TOKENS,
// Parsed with `parseModelJson` (JSON.parse) below — opt the OpenAI /
// Codex chains into their strict JSON-object mode (gated on this flag).
responseFormat: "json",
}),
});
} catch (err) {
// Nothing was generated — refund the whole reservation rather than bill an
// invented figure. Best-effort: a failed refund must not mask the 503.
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
() => {},
);
if (err instanceof AllProvidersFailedError) {
annotate({
action: { name: "medications.extract.provider-failed" },
@@ -211,6 +252,14 @@ async function handleExtract(request: NextRequest): Promise<Response> {
throw err;
}
// Reconcile against what the provider actually reported. This runs for the
// unusable-reply branches too: those tokens were burned upstream even though
// the body could not be used, so they stay on the ledger rather than funding
// a free retry loop. Falls back to the reservation when the provider reports
// no count, so an unreported generation is never billed as zero.
const actualTokens = completion.result.tokensUsed ?? reservation.reserved;
await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey);
const reply = completion.result.content?.trim() ?? "";
if (!reply) {
annotate({ action: { name: "medications.extract.empty-reply" } });
@@ -242,13 +291,6 @@ async function handleExtract(request: NextRequest): Promise<Response> {
text,
);
// Bump the day's spend ledger so the daily budget gate stays in sync.
await recordSpend({
userId,
tokens: completion.result.tokensUsed ?? 0,
dateKey: buildDateKey(),
});
annotate({
action: { name: "medications.extract.ok" },
meta: {
+2 -2
View File
@@ -108,7 +108,7 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
}
function CoachConversationsBody() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const router = useRouter();
const [filter, setFilter] = useState<string>("");
// v1.30.2 (QoL H1) — search now drives the server-side title query;
@@ -290,7 +290,7 @@ function CoachConversationsBody() {
<span className="truncate">{c.title}</span>
</span>
<span className="text-muted-foreground block text-xs">
{formatRelativeTime(c.updatedAt, t)}
{formatRelativeTime(c.updatedAt, t, locale)}
</span>
</button>
<Button
+21 -5
View File
@@ -18,6 +18,8 @@ import { ChronotypeSection } from "@/components/insights/sleep/chronotype-sectio
import { AverageSleepSection } from "@/components/insights/sleep/average-sleep-section";
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
/**
* v1.4.25 W4c — `/insights/sleep`.
@@ -41,6 +43,13 @@ export default function InsightsSchlafPage() {
const { ready } = useModulePageGuard("sleep");
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
// The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
// to paint the same sentence three times, none of them offering a way out.
// The page owns the shared state and renders one notice with a retry; the
// cards no longer carry their own copy of it.
const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm(
Boolean(user) && !isEmpty,
);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
// /insights instead of half-rendering the sleep surface.
@@ -127,11 +136,18 @@ export default function InsightsSchlafPage() {
viewports and sit side-by-side from `sm` up; each card keeps its own
internals (the debt headline and the chronotype band treatment).
*/}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SleepRhythmSection enabled={!isEmpty} />
<AverageSleepSection enabled={!isEmpty} />
<ChronotypeSection enabled={!isEmpty} />
</div>
{rhythmError ? (
<QueryErrorCard
description={t("insights.sleep.rhythm.loadError")}
onRetry={() => void refetchRhythm()}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SleepRhythmSection enabled={!isEmpty} />
<AverageSleepSection enabled={!isEmpty} />
<ChronotypeSection enabled={!isEmpty} />
</div>
)}
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
+14 -2
View File
@@ -9,6 +9,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
/**
* v1.4.32 — `/insights/workouts`.
@@ -36,7 +37,13 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function InsightsWorkoutsPageClient() {
const { t } = useTranslations();
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
// `isError` / `refetch` were already on the hook and simply not read here, so
// a failed list read fell through every branch to `null` and painted a titled
// shell with nothing in it — no message, no way back. Not the empty state,
// but just as opaque.
const { data, isLoading, isEmpty, isError, refetch } = useWorkouts({
limit: 100,
});
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
@@ -57,7 +64,12 @@ export default function InsightsWorkoutsPageClient() {
{/* No `<MetricRangeControls>` here: workouts are session records, not a
MeasurementType series, so the period-over-period range read has
nothing to aggregate. */}
{isLoading ? (
{isError ? (
<QueryErrorCard
title={t("insights.workouts.loadError")}
onRetry={() => refetch()}
/>
) : isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
+13 -11
View File
@@ -22,7 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BackLink } from "@/components/ui/back-link";
import { Card } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
MedicationDetailTabs,
type MedicationDetailSnapshot,
@@ -49,6 +49,7 @@ export default function MedicationDetailPage({
data: medication,
isLoading,
isError,
refetch,
} = useQuery<MedicationDetailSnapshot>({
queryKey: queryKeys.medicationDetail(id),
queryFn: async () => {
@@ -71,20 +72,21 @@ export default function MedicationDetailPage({
return <PageAuthGate label={t("common.loading")} />;
}
// `retry: false` above stays: a deleted medication 404s here and automatic
// backoff against a row that no longer exists is pure waste. But that flag
// also stranded the page on a single transient failure, with no way forward
// short of navigating away and back. A user-initiated retry is the right
// granularity — it costs one request per click and cannot storm.
if (isError || !medication) {
return (
<div className="space-y-6">
<BackLink href="/medications" label={t("medications.back")} />
<Card
className="p-6"
role="alert"
aria-live="polite"
data-slot="medication-detail-error-card"
>
<p className="text-destructive text-sm">
{t("medications.detail.shell.loadFailed")}
</p>
</Card>
<div data-slot="medication-detail-error-card">
<QueryErrorCard
description={t("medications.detail.shell.loadFailed")}
onRetry={() => void refetch()}
/>
</div>
</div>
);
}