Merge branch 'fix/v139-review-server' into release/v1.39.0

This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:48:05 +02:00
commit 53bd7098da
41 files changed
+1121 -167

No files matched your search

@@ -0,0 +1,8 @@
-- A period start logged a few days before an existing start folds that start
-- into itself (the same period, entered out of order), soft-deleting it.
-- Nothing recorded which start was folded, so taking the earlier start back
-- could not bring the later one back: a mis-tapped "started" wiped the real
-- start and reopened the cycle before it. The folded row now names the start
-- that absorbed it, and removing that start restores it.
ALTER TABLE "menstrual_cycles"
ADD COLUMN IF NOT EXISTS "absorbed_into_id" TEXT;
+8 -3
View File
@@ -7113,9 +7113,14 @@ model MenstrualCycle {
/// Per-row IANA timezone the date strings are anchored to (NULL =
/// legacy Europe/Berlin), mirroring MoodEntry.tz.
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
/// The start that folded this one in (a start logged a few days earlier is
/// the same period entered out of order). Set only on the soft-deleted,
/// folded row; removing the absorbing start restores every row naming it
/// (`removeCycleStartedOn`). Cleared when the row is live again.
absorbedIntoId String? @map("absorbed_into_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -71,6 +71,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability";
export const dynamic = "force-dynamic";
@@ -483,6 +484,12 @@ const handler = apiHandler(
// the account restore assumes.
if (payload.appSettings && restoreInstanceSettings) {
const settings = payload.appSettings;
// A file from before 0343 carries the Coach's operator answer in
// module availability; carry it into the Coach switch.
const coachFold = foldLegacyCoachAvailability(
settings.moduleAvailabilityJson,
settings.assistantCoachEnabled,
);
const settingsData = {
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
@@ -523,13 +530,13 @@ const handler = apiHandler(
settings.adminAiInsightsFeedbackSummary as never,
defaultUserTimezone: settings.defaultUserTimezone,
assistantEnabled: settings.assistantEnabled,
assistantCoachEnabled: settings.assistantCoachEnabled,
assistantCoachEnabled: coachFold.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled:
settings.assistantInsightStatusEnabled,
assistantDocumentAiEnabled:
settings.assistantDocumentAiEnabled ?? true,
moduleAvailabilityJson: settings.moduleAvailabilityJson as never,
moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
};
+16 -9
View File
@@ -19,7 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
type RouteParams = { params: Promise<{ id: string }> };
@@ -37,21 +37,25 @@ export const DELETE = apiHandler(
const existing = await prisma.menstrualCycle.findUnique({
where: { id },
select: { id: true, userId: true, startDate: true },
select: { id: true, userId: true, startDate: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Cycle not found", 404);
}
// Already a tombstone: the delete is done, and nothing is left to move.
if (existing.deletedAt !== null) {
return new Response(null, { status: 204 });
}
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
// half-applied delete would read the row being removed as still live.
const reanchored = await prisma.$transaction(async (db) => {
await db.menstrualCycle.update({
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return reanchorAfterRemovedStart(db, user.id, existing.startDate);
});
// The shared removal also gives back a start this one folded in and
// hands the removed cycle's days back. `null` is a concurrent delete that
// tombstoned the row first: nothing left to move.
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.startDate),
);
const reanchored = removed?.reanchored ?? null;
await auditLog("cycle.cycle.delete", {
userId: user.id,
@@ -61,6 +65,9 @@ export const DELETE = apiHandler(
details: {
cycleId: id,
startDate: existing.startDate,
...(removed && removed.restoredCycleIds.length > 0
? { restoredCycleIds: removed.restoredCycleIds }
: {}),
...(reanchored
? {
reanchoredCycleId: reanchored.cycleId,
+2 -1
View File
@@ -64,6 +64,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { validTimezoneOr } from "@/lib/tz/format";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import {
@@ -321,7 +322,7 @@ export const GET = apiHandler(async () => {
// timezone. Falls back to Europe/Berlin when the column is somehow
// missing (defensive — the schema's NOT NULL default normally pins
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const userTz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE);
// v1.4.38 W-F — wrap the whole response in the analytics LRU.
// Subsequent iOS polls inside the TTL hit memory; measurement /
+7 -1
View File
@@ -39,6 +39,7 @@ import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { isValidTimezone } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -47,7 +48,12 @@ const querySchema = z.object({
// The zone the day is anchored to. Absent falls back to the account's own
// display timezone; the edit dialog passes the entry's stored `tz` so a row
// logged elsewhere keeps its own day boundaries.
tz: z.string().min(1).max(64).optional(),
tz: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
});
export const GET = apiHandler(async (request: NextRequest) => {
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { memoizePerRequest } from "@/lib/request-cache";
function twice(kind: "http" | "background", fresh: boolean) {
const factory = vi.fn(async () => Math.random());
return eventStorage.run(new WideEventBuilder(kind), async () => {
const options = fresh ? { freshInBackground: true } : {};
await memoizePerRequest("k", factory, options);
await memoizePerRequest("k", factory, options);
return factory.mock.calls.length;
});
}
describe("memoizePerRequest", () => {
it("reads once per request", async () => {
expect(await twice("http", false)).toBe(1);
expect(await twice("http", true)).toBe(1);
});
it("reads once per background event unless the input must stay fresh", async () => {
expect(await twice("background", false)).toBe(1);
expect(await twice("background", true)).toBe(2);
});
});
+12 -7
View File
@@ -56,13 +56,17 @@ export interface AiCapabilityScope {
/** The kinds of the record's active consent receipts. */
function loadActiveConsentKinds(recordId: string): Promise<Set<string>> {
return memoizePerRequest(`ai-consent-kinds:${recordId}`, async () => {
const rows = await prisma.consentReceipt.findMany({
where: { userId: recordId, revokedAt: null },
select: { kind: true },
});
return new Set(rows.map((row) => row.kind));
});
return memoizePerRequest(
`ai-consent-kinds:${recordId}`,
async () => {
const rows = await prisma.consentReceipt.findMany({
where: { userId: recordId, revokedAt: null },
select: { kind: true },
});
return new Set(rows.map((row) => row.kind));
},
{ freshInBackground: true },
);
}
function scopeKey(scope: AiCapabilityScope): string {
@@ -114,6 +118,7 @@ export function loadAiCapabilityInputs(
return null;
}
},
{ freshInBackground: true },
);
}
@@ -9,6 +9,17 @@
*/
import { describe, it, expect, vi } from "vitest";
// Reminders are held while the Coach is unavailable; these cases are about
// surfacing, so the Coach answers. The hold is pinned against Postgres in
// `tests/integration/coach-reminder-surfacing.test.ts`.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: (buf: Uint8Array) => Buffer.from(buf).toString("utf8"),
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
@@ -14,16 +14,33 @@
* - the budget runs through the ATOMIC reserve/reconcile pair, not a
* read-then-write check.
*
* `@/lib/consent/receipts` is mocked rather than the consent guard itself, so
* the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface`
* logic is what these tests exercise.
* Only the receipt read is mocked, so the real wire re-check
* (`aiEgressRefusal`) and the capability table's `aboutMeQuestions` row are
* what these tests exercise: which receipts cover the prompt is the table's
* answer, not this module's.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
const latestActiveReceipt = vi.hoisted(() => vi.fn());
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
const activeKinds = vi.hoisted(() => ({ kinds: new Set<string>() }));
const consentFindFirst = vi.hoisted(() =>
vi.fn(async (args: { where: { kind: { in: string[] } } }) =>
args.where.kind.in.some((kind) => activeKinds.kinds.has(kind))
? { id: "receipt" }
: null,
),
);
vi.mock("@/lib/db", () => ({
prisma: { consentReceipt: { findFirst: consentFindFirst } },
}));
// The capability itself is open; the route resolved it before calling in.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForJob: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
requireAiCapability: vi.fn(async () => undefined),
}));
vi.mock("@/lib/documents/document-settings", () => ({
documentAutoReadEnabled: vi.fn(async () => false),
}));
@@ -54,9 +71,13 @@ vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
const buildCoachSnapshot = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
import { deriveClarifyingQuestions } from "../self-context-questions";
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
import type { SelfContext } from "../about-me";
import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts";
@@ -85,7 +106,7 @@ let provider: ReturnType<typeof makeProvider>;
beforeEach(() => {
vi.clearAllMocks();
provider = makeProvider();
latestActiveReceipt.mockResolvedValue(null);
activeKinds.kinds = new Set();
providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true);
providerMocks.resolveProvider.mockResolvedValue({ type: "none" });
budgetMocks.reserveBudget.mockResolvedValue({
@@ -215,9 +236,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_coach" ? { id: "receipt-1" } : null,
);
activeKinds.kinds = new Set(["ai_coach"]);
const out = await deriveClarifyingQuestions(
"user-1",
@@ -236,9 +255,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
kind === "ai_full" ? { id: "receipt-2" } : null,
);
activeKinds.kinds = new Set(["ai_full"]);
const out = await deriveClarifyingQuestions(
"user-1",
@@ -252,6 +269,32 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
});
it("accepts the analysis-only grant the capability table lists", async () => {
// The capability the route resolved admits `ai_insights_only`; the
// question path used to demand a Coach receipt on top and fell back.
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "admin-openai", instance: provider },
]);
activeKinds.kinds = new Set(["ai_insights_only"]);
const out = await deriveClarifyingQuestions(
"user-1",
ctx,
"en",
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
{ aiAvailable: true },
);
expect(out.source).toBe("ai");
expect(consentFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
kind: { in: [...AI_CAPABILITIES.aboutMeQuestions.consent.kinds] },
}),
}),
);
});
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
providerMocks.resolveProviderChain.mockResolvedValue([
{ providerType: "openai", instance: provider },
@@ -267,7 +310,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
expect(out.source).toBe("ai");
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
expect(latestActiveReceipt).not.toHaveBeenCalled();
expect(consentFindFirst).not.toHaveBeenCalled();
});
it("leaves a local chain ungated", async () => {
+16 -1
View File
@@ -15,6 +15,7 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
@@ -56,6 +57,18 @@ export function buildReminderSurfaceMessage(
return { title, body };
}
/**
* Whether a user's Coach reminders are held: the record's `coach` capability
* is unavailable (operator switch, module off, opt-out, no provider, no
* consent), which is the same answer that keeps the badge dark. A reminder
* surfaced into a Coach nobody can open is a message that never shows, and
* the nag cap would then dismiss it unseen; held, it waits and surfaces once
* the Coach is back.
*/
export async function coachRemindersHeld(userId: string): Promise<boolean> {
return !(await aiCapabilityToServe(userId, "coach")).available;
}
export interface SurfaceOutcome {
/** Reminders that reached the conversation in this call. */
surfaced: number;
@@ -70,7 +83,8 @@ export interface SurfaceOutcome {
* cost the user the reminders beside it. At most `MAX_NOTES_PER_MESSAGE`
* notes ride one message; the remainder keep their status and surface on
* the next trigger. A write failure leaves every row untouched: no badge
* claims a message that was never written.
* claims a message that was never written. While the Coach is unavailable
* nothing is written and every row keeps its status.
*/
export async function surfaceCoachReminders(
prisma: ReminderSurfacePrisma,
@@ -80,6 +94,7 @@ export async function surfaceCoachReminders(
): Promise<SurfaceOutcome> {
const outcome: SurfaceOutcome = { surfaced: 0, errored: 0 };
if (rows.length === 0) return outcome;
if (await coachRemindersHeld(userId)) return outcome;
const usable: { id: string; note: string }[] = [];
for (const row of rows) {
+18 -15
View File
@@ -36,10 +36,7 @@ import {
resolveProviderChain,
} from "@/lib/ai/provider";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
import {
chainRequiresServerManagedConsent,
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildDateKey,
@@ -213,18 +210,24 @@ export async function deriveClarifyingQuestions(
const chain = await resolveQuestionsChain(userId);
if (chain === null) return fallback();
// Consent gate — BEFORE the snapshot is built, let alone sent. This
// prompt ships the complete Coach snapshot, so a chain that could egress
// via the operator's server-managed credential needs an active
// `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are
// the user's own egress and stay ungated, matching every other surface.
if (
chainRequiresServerManagedConsent(chain) &&
!(await hasActiveConsentForSurface(userId, "coach"))
) {
// The wire re-check — BEFORE the snapshot is built, let alone sent. The
// capability table's `aboutMeQuestions` row decides which receipts cover
// this prompt and when one is needed (a chain that could reach the
// operator's key or the shared central Codex; a person's own key, their
// ChatGPT account or a local model need none), so this path and the
// capability the route resolved cannot disagree.
const refusal = await aiEgressRefusal(
"aboutMeQuestions",
userId,
chain.map((entry) => entry.providerType),
);
if (refusal) {
annotate({
action: { name: "coach.self_context.consent_required" },
meta: { self_context_questions_source: "fallback" },
action: { name: "coach.self_context.egress_refused" },
meta: {
self_context_questions_source: "fallback",
reason: refusal.reason,
},
});
return fallback();
}
+1
View File
@@ -985,6 +985,7 @@ export function probeProviderChain(
managedBy: entries.length > 0 ? managedBy : null,
};
},
{ freshInBackground: true },
);
}
+68 -6
View File
@@ -114,10 +114,6 @@ export async function openCycleAt(
if (absorbed) {
moved.absorbedCycleId = absorbed.id;
moved.absorbedStartDate = absorbed.startDate;
await db.menstrualCycle.update({
where: { id: absorbed.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
}
// Close the prior open cycle: its end is the day before this start.
@@ -160,12 +156,26 @@ export async function openCycleAt(
create: { userId, startDate: date, tz, isPredicted: false, ...carried },
update: {
deletedAt: null,
absorbedIntoId: null,
isPredicted: false,
...carried,
syncVersion: { increment: 1 },
},
});
// The folded start is soft-deleted and names the start that absorbed it,
// so taking this start back (a mis-tap) can give it back.
if (absorbed) {
await db.menstrualCycle.update({
where: { id: absorbed.id },
data: {
deletedAt: new Date(),
absorbedIntoId: cycle.id,
syncVersion: { increment: 1 },
},
});
}
// Re-anchor the FOLLOWING neighbour's side of the boundary.
const next = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: date } },
@@ -342,13 +352,21 @@ export async function ensureCycleForBleedingDay(
* body behind every way of taking a period start back: deleting the day-log
* that opened it, clearing that day's flow, and deleting the cycle itself.
*
* A start this one folded in when it was opened (a later start within one
* period, see `openCycleAt`) comes back: the earlier start was the mistake,
* so the later one was the real first day all along.
*
* Returns null when no live cycle starts on that date.
*/
export async function removeCycleStartedOn(
db: CycleDb,
userId: string,
date: string,
): Promise<{ cycleId: string; reanchored: ReanchoredCycle | null } | null> {
): Promise<{
cycleId: string;
reanchored: ReanchoredCycle | null;
restoredCycleIds: string[];
} | null> {
const opened = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: date },
select: { id: true },
@@ -359,6 +377,23 @@ export async function removeCycleStartedOn(
where: { id: opened.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Give back the starts it folded in, before any boundary is re-derived, so
// the cycle before this one closes against them rather than reopening.
const restored = await db.menstrualCycle.findMany({
where: { userId, absorbedIntoId: opened.id, deletedAt: { not: null } },
orderBy: { startDate: "asc" },
select: { id: true, startDate: true },
});
for (const row of restored) {
await db.menstrualCycle.update({
where: { id: row.id },
data: {
deletedAt: null,
absorbedIntoId: null,
syncVersion: { increment: 1 },
},
});
}
const reanchored = await reanchorAfterRemovedStart(db, userId, date);
// The removed cycle's days go back to the cycle before it (or to none).
const prior = await db.menstrualCycle.findFirst({
@@ -370,7 +405,34 @@ export async function removeCycleStartedOn(
where: { userId, cycleId: opened.id },
data: { cycleId: prior?.id ?? null, syncVersion: { increment: 1 } },
});
return { cycleId: opened.id, reanchored };
// A restored start owns its span again, and its own end is re-derived from
// whatever was logged after it while it was folded.
for (const row of restored) {
const next = await db.menstrualCycle.findFirst({
where: { userId, deletedAt: null, startDate: { gt: row.startDate } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
await db.menstrualCycle.update({
where: { id: row.id },
data: {
endDate: next ? addDays(next.startDate, -1) : null,
lengthDays: next ? dayDiff(next.startDate, row.startDate) : null,
},
});
await attributeDaysToCycle(
db,
userId,
row.id,
row.startDate,
next?.startDate ?? null,
);
}
return {
cycleId: opened.id,
reanchored,
restoredCycleIds: restored.map((row) => row.id),
};
}
/** The boundary columns the re-derivation moved, for the audit row. */
+2 -1
View File
@@ -61,6 +61,7 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { validTimezoneOr } from "@/lib/tz/format";
import {
loadAllergies,
loadAnamnesis,
@@ -145,7 +146,7 @@ export async function collectDoctorReportData(
insurerIkNumber: true,
},
});
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
const reportTz = validTimezoneOr(userProfile?.timezone, "Europe/Berlin");
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse = aggregateDenseTypes && !excluded.includes("PULSE");
@@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => ({
// pre-v1.4.33 assertion semantics.
let mockEvent: object | null = null;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => mockEvent ?? { addWarning: vi.fn() },
getEvent: () => mockEvent ?? { getKind: () => "http", addWarning: vi.fn() },
}));
import { prisma } from "@/lib/db";
@@ -154,7 +154,7 @@ describe("per-request memoisation", () => {
// Pin a stable stub object so every `getEvent()` inside this test
// returns the same reference — the memo keys on identity, so a
// shared reference == shared cache.
mockEvent = { addWarning: vi.fn() };
mockEvent = { getKind: () => "http", addWarning: vi.fn() };
const a = await getAssistantFlags();
const b = await getAssistantFlags();
@@ -174,9 +174,9 @@ describe("per-request memoisation", () => {
assistantDocumentAiEnabled: true,
});
mockEvent = { addWarning: vi.fn(), id: "req-1" };
mockEvent = { getKind: () => "http", addWarning: vi.fn(), id: "req-1" };
await getAssistantFlags();
mockEvent = { addWarning: vi.fn(), id: "req-2" };
mockEvent = { getKind: () => "http", addWarning: vi.fn(), id: "req-2" };
await getAssistantFlags();
expect(FIND).toHaveBeenCalledTimes(2);
+32 -27
View File
@@ -49,35 +49,40 @@ const ASSISTANT_FLAGS_OFF: AssistantFlagSet = Object.freeze({
* switch; failing open meant a database blip switched every AI egress on.
*
* Memoised per request, so a page that fires several gated reads at once reads
* the singleton row once.
* the singleton row once. Read afresh on every call inside a background job,
* whose one event spans a whole multi-user pass.
*/
export function loadAssistantSwitches(): Promise<AssistantFlagSet | null> {
return memoizePerRequest("assistant-flags", async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
},
});
return resolveAssistantFlags({
enabled: settings?.assistantEnabled ?? true,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
documentAi: settings?.assistantDocumentAiEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant switches; every AI capability is off for this request",
);
return null;
}
});
return memoizePerRequest(
"assistant-flags",
async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
},
});
return resolveAssistantFlags({
enabled: settings?.assistantEnabled ?? true,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
documentAi: settings?.assistantDocumentAiEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant switches; every AI capability is off for this request",
);
return null;
}
},
{ freshInBackground: true },
);
}
/**
+2 -2
View File
@@ -40,7 +40,7 @@ import {
} from "@/lib/analytics/classifications";
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format";
export type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
@@ -555,7 +555,7 @@ export async function buildAchievementsResult(
// day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time,
// compliance-80) plus the hidden night-owl / early-bird / leap-day
// triggers for every non-CET account.
const tz = user.timezone || DEFAULT_TIMEZONE;
const tz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE);
const [
vitalsBuckets,
@@ -41,8 +41,9 @@ vi.mock("@/lib/db", () => ({
// path has its own test in comprehensive-generate-force.test.ts.
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
// arm has its own test in comprehensive-budget-refusal.test.ts.
const aiEgressRefusal = vi.fn();
vi.mock("@/lib/ai/capabilities/egress", () => ({
aiEgressRefusal: vi.fn(async () => null),
aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: vi.fn(async () => ({
@@ -139,6 +140,47 @@ beforeEach(() => {
extractFeatures.mockResolvedValue(FEATURES);
userUpdate.mockResolvedValue({});
getSelfContextTextForUser.mockResolvedValue(null);
aiEgressRefusal.mockResolvedValue(null);
});
describe("generateComprehensiveInsight — consent withdrawn in flight", () => {
it("drops the reply instead of writing it back over the purge", async () => {
findUnique.mockResolvedValue({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }),
insightsExcludeMetrics: [],
insightsSnapshotHash: "0".repeat(64),
});
runRawCompletionWithFallback.mockResolvedValue({
result: {
content: JSON.stringify({ dailyBriefing: { p: "new" } }),
tokensUsed: 10,
providerType: "openai",
model: "m",
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
});
userUpdateMany.mockResolvedValue({ count: 1 });
const { AiUnavailableError } =
await import("@/lib/ai/capabilities/refusal");
// Open at the wire; withdrawn by the time the reply is back.
aiEgressRefusal
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
new AiUnavailableError("briefing", "consent_required"),
);
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
expect(outcome).toEqual({ status: "skipped", reason: "no-consent" });
expect(aiEgressRefusal).toHaveBeenLastCalledWith("briefing", "u1", [
"openai",
]);
expect(userUpdateMany).not.toHaveBeenCalled();
expect(invalidateUserInsights).not.toHaveBeenCalled();
});
});
describe("generateComprehensiveInsight — scope/version commit guard", () => {
@@ -188,6 +188,31 @@ describe("runStatusCompletion — capability at the wire", () => {
);
});
it("drops a reply whose consent was withdrawn while the call was in flight", async () => {
mockProviderReply(40);
// Open when the request leaves; withdrawn by the time the reply is back.
aiEgressRefusal
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
new AiUnavailableError("statusText", "consent_required"),
);
const outcome = await runStatusCompletion(completionArgs() as never);
expect(outcome).toEqual({ kind: "none", reason: "consent_required" });
// Re-checked against the provider that actually answered.
expect(aiEgressRefusal).toHaveBeenLastCalledWith("statusText", "u1", [
"admin-openai",
]);
// The tokens were spent upstream and stay on the ledger.
expect(ledgerTotal).toBe(40);
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({ at: "reply" }),
}),
);
});
it("returns none with reason no_provider when the chain is empty", async () => {
resolveProviderChain.mockResolvedValue([]);
const outcome = await runStatusCompletion(completionArgs() as never);
+22 -1
View File
@@ -508,7 +508,11 @@ function failBeforeProvider(
}
type InsightCommitResult =
"committed" | "scope-changed" | "profile-scope-changed";
| "committed"
| "scope-changed"
| "profile-scope-changed"
| "no-consent"
| "unavailable";
/**
* The exact User columns (besides `insightsPrivacyMode`, guarded
@@ -530,6 +534,12 @@ type PromptScopeAtRead = {
* was built from — without keying on the broad `updatedAt` timestamp, which
* a provider credential refresh (e.g. `resolveProviderChain()`'s token
* maintenance) also advances and would otherwise false-positive here.
*
* The consent rule runs once more too, against the provider that answered
* (`servedBy`, `null` for a timestamp-only stamp that writes no text), with a
* fresh receipt read: a withdrawal that landed while the
* call was in flight already purged the stored briefing, and this write would
* bring it back.
*/
async function commitInsightUnderScope(
userId: string,
@@ -537,8 +547,15 @@ async function commitInsightUnderScope(
selfContextAtRead: string | null,
promptScopeAtRead: PromptScopeAtRead,
locale: SupportedLocale,
servedBy: string | null,
data: Prisma.UserUpdateInput,
): Promise<InsightCommitResult> {
if (servedBy !== null) {
const late = await aiEgressRefusal("briefing", userId, [servedBy]);
if (late) {
return late.reason === "consent_required" ? "no-consent" : "unavailable";
}
}
const currentSelfContext = await getSelfContextTextForUser(userId, locale);
if (currentSelfContext !== selfContextAtRead) {
return "profile-scope-changed";
@@ -930,6 +947,7 @@ export async function generateComprehensiveInsight(
aboutMe,
promptScopeAtRead,
locale,
rerolled.providerType,
{
insightsCachedAt: new Date(),
insightsCachedText: rerolled.text,
@@ -956,6 +974,7 @@ export async function generateComprehensiveInsight(
aboutMe,
promptScopeAtRead,
locale,
null,
{
insightsCachedAt: new Date(),
// The fingerprint covers the generation locale, so a matching hash
@@ -979,6 +998,7 @@ export async function generateComprehensiveInsight(
aboutMe,
promptScopeAtRead,
locale,
null,
{
insightsCachedAt: new Date(),
insightsCachedLocale: locale,
@@ -1322,6 +1342,7 @@ export async function generateComprehensiveInsight(
aboutMe,
promptScopeAtRead,
locale,
workingProviderType,
{
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify(insights),
+22
View File
@@ -321,6 +321,28 @@ export async function runStatusCompletion(
return { kind: "error" };
}
// Consent withdrawn while the call was in flight. The withdrawal purged the
// stored text in its own transaction; a reply persisted after it would
// bring that text back under a consent that no longer exists. So the rule
// runs once more, against the provider that actually answered, with a fresh
// receipt read, and a refused reply is dropped as if it had never arrived.
// The tokens stay on the ledger: they were spent upstream.
const late = await aiEgressRefusal(args.capability, userId, [
workingProvider.providerType,
]);
if (late) {
annotate({
action: { name: "insights.status.capability_unavailable" },
meta: {
cacheAction,
capability: args.capability,
reason: late.reason,
at: "reply",
},
});
return { kind: "none", reason: late.reason };
}
return {
kind: "ok",
content,
@@ -19,6 +19,17 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
// Reminders are held while the Coach is unavailable; these cases are about
// surfacing, so the Coach answers. The hold is pinned against Postgres in
// `tests/integration/coach-reminder-surfacing.test.ts`.
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityToServe: vi.fn(async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
})),
}));
// The context-cue backstop delegates to the shared evaluator; its own
// behaviour is pinned in `ai/coach/__tests__/context-reminders.test.ts`.
const { contextEvaluateMock } = vi.hoisted(() => ({
@@ -709,6 +709,40 @@ describe("reaction line — degradation", () => {
expectSurfaceStillWorks();
});
it("consent withdrawn while the call was in flight: writes no line", async () => {
resolveProviderChain.mockResolvedValue([
{
providerType: "openai",
instance: {
generateCompletion: vi.fn().mockResolvedValue({
content: "A solid night, deeper than your recent stretch.",
tokensUsed: 1_100,
cachedInputTokens: 100,
}),
},
},
]);
aiEgressRefusal
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(
new AiUnavailableError("reactionLines", "consent_required"),
);
const outcome = await runReactionLine(JOB);
expect(outcome).toEqual({ status: "skipped", reason: "consent_required" });
expect(aiEgressRefusal).toHaveBeenLastCalledWith("reactionLines", "u1", [
"openai",
]);
expect(
updateMany.mock.calls.some(
([arg]) =>
(arg as { data?: { lineEncrypted?: unknown } }).data
?.lineEncrypted instanceof Uint8Array,
),
).toBe(false);
});
it("a committed line is never regenerated — the unique row is the throttle", async () => {
findUnique.mockResolvedValue({ id: "r1", generatedAt: new Date() });
resolveProviderChain.mockResolvedValue([
+24 -1
View File
@@ -34,6 +34,10 @@
* that has been stuck on an empty conversation resolves into the message it
* always implied rather than staying stuck.
*
* While the record's `coach` capability is unavailable its reminders are held:
* none is surfaced, charged or auto-dismissed, and they resume once the Coach
* is back (`coachRemindersHeld`, the same answer that keeps the badge dark).
*
* The sweep still never calls a provider and never pushes. The message body is
* a deterministic localized template around the user's OWN note text — the
* words they asked to be reminded of, never anything fabricated.
@@ -50,7 +54,10 @@ import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { surfaceCoachReminders } from "@/lib/ai/coach/reminder-surface";
import {
coachRemindersHeld,
surfaceCoachReminders,
} from "@/lib/ai/coach/reminder-surface";
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
@@ -258,11 +265,26 @@ export async function runCoachReminderSweep(
// it has already sat there for a day as one more surfacing, then
// auto-dismiss the rows that reached the cap. Two bulk statements, no
// message: dismissal is silence, not another nag.
// A day the Coach was unavailable is not a day the reminder sat ignored:
// the badge was dark and there was nothing to open. Those users' rows are
// neither charged nor dismissed.
const surfacedOwners = await prisma.coachReminder.findMany({
where: { deletedAt: null, status: "surfaced" },
distinct: ["userId"],
select: { userId: true },
});
const heldOwners: string[] = [];
for (const { userId } of surfacedOwners) {
if (await coachRemindersHeld(userId)) heldOwners.push(userId);
}
const notHeld =
heldOwners.length > 0 ? { userId: { notIn: heldOwners } } : {};
await prisma.coachReminder.updateMany({
where: {
deletedAt: null,
status: "surfaced",
lastSurfacedAt: { lte: new Date(now.getTime() - NAG_STALE_MS) },
...notHeld,
},
data: { surfaceCount: { increment: 1 } },
});
@@ -271,6 +293,7 @@ export async function runCoachReminderSweep(
deletedAt: null,
status: "surfaced",
surfaceCount: { gte: REMINDER_NAG_CAP },
...notHeld,
},
data: { status: "dismissed" },
});
+11
View File
@@ -710,6 +710,17 @@ export async function runReactionLine(
return { status: "skipped", reason: "ungrounded_output" };
}
// Consent withdrawn while the call was in flight: the withdrawal cleared
// stored lines in its own transaction, and this commit would write one
// back. The rule once more, for the provider that answered, fresh.
const late = await aiEgressRefusal("reactionLines", job.userId, [
chain[0].providerType,
]);
if (late) {
await finishTerminalAttempt().catch(() => {});
return { status: "skipped", reason: late.reason };
}
const committed = await prisma.arrivalReaction.updateMany({
where: {
id: row.id,
+5
View File
@@ -288,6 +288,11 @@ export class WideEventBuilder {
return structuredClone(this.event) as WideEvent;
}
/** `http` for a request, `background` for a job or a hand-rolled route. */
getKind(): EventKind {
return this.event.kind ?? "http";
}
getDurationMs(): number {
return Math.round(performance.now() - this.startTime);
}
+34 -30
View File
@@ -121,8 +121,8 @@ export async function readDailySeries(opts: {
// every bucket's UTC-midnight start read as the previous local day west of
// UTC. The 7-day chart buckets raw rows by the local day, so switching
// ranges moved daily totals between neighbouring days. The live fold is
// one indexed pass per window (about 0.1 s for 90 days of per-minute
// samples) and returns at most one row per day.
// one pass per window (about 0.3 s for a year of per-minute samples,
// measured against Postgres 16) and returns at most one row per day.
const timeZone = opts.timeZone ?? (await resolveUserTimezone(userId));
const rows = await readLiveBuckets({
userId,
@@ -185,24 +185,31 @@ export async function readLiveBuckets(opts: {
// `TRUNC_UNIT` map keyed by the Zod-constrained grain, never from input.
const unit = Prisma.raw(`'${TRUNC_UNIT[grain]}'`);
// Cumulative types SUM per bucket, every other type averages. The type list
// is the closed code constant, spliced as literals; `s."type"` is a GROUP BY
// is the closed code constant, spliced as literals; `m."type"` is a GROUP BY
// column, so choosing the aggregate per group is legal.
const cumulativeList = [...CUMULATIVE_HK_TYPES]
.map((t) => `'${t}'`)
.join(",");
const aggregator = Prisma.raw(
`(CASE WHEN s."type"::text IN (${cumulativeList}) THEN SUM(s."value") ELSE AVG(s."value") END)::double precision`,
`(CASE WHEN m."type"::text IN (${cumulativeList}) THEN SUM(m."value") ELSE AVG(m."value") END)::double precision`,
);
const rankRaw = Prisma.raw(
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
);
const typeFilter = type
? Prisma.sql`AND m."type" = ${type}::measurement_type`
: Prisma.empty;
// measured_at is a UTC wall-clock timestamp: pin it to UTC, re-read it in
// the user's zone and truncate there. The bucket is computed once in `src`
// and referenced by name afterwards, so the zone parameter never has to
// match itself across SELECT and GROUP BY.
// the user's zone and truncate there.
//
// Fold first, pick second. Every source is folded per bucket in one hashed
// pass (a few rows per day, however dense the stream), and only then does
// each bucket keep its ladder-canonical source. The fold of the canonical
// source's rows is exactly the fold of that source's group, so the result
// is the same as collapsing the rows first; what changes is the cost. A
// year of per-minute heart rate is half a million rows, and ranking them
// before the fold sorted them twice (about 0.55 s); folding first never
// sorts more than one row per source per day.
const buckets = await prisma.$queryRaw<
Array<{
type: string;
@@ -213,41 +220,38 @@ export async function readLiveBuckets(opts: {
max_value: number | null;
}>
>`
WITH src AS (
WITH per_source AS (
SELECT
m."type",
m."source",
m."value",
date_trunc(${unit}, (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS d
date_trunc(${unit}, (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS d,
${aggregator} AS avg,
COUNT(*)::int AS cnt,
MIN(m."value") AS min_value,
MAX(m."value") AS max_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (m."type", m.d)
m."type" AS t,
m.d AS d,
m."source" AS canon
FROM src m
ORDER BY m."type", m.d, (${rankRaw}), m."source"
SELECT DISTINCT ON (p."type", p.d)
p."type", p.d, p.avg, p.cnt, p.min_value, p.max_value
FROM per_source p
ORDER BY p."type", p.d, (${rankRaw}), p."source"
)
SELECT
s."type"::text AS type,
s.d AT TIME ZONE ${timeZone} AS bucket_start,
${aggregator} AS avg,
COUNT(*)::int AS cnt,
MIN(s."value") AS min_value,
MAX(s."value") AS max_value
FROM src s
JOIN canon c
ON c.t = s."type"
AND c.d = s.d
AND c.canon = s."source"
GROUP BY s."type", s.d
ORDER BY s.d ASC
c."type"::text AS type,
c.d AT TIME ZONE ${timeZone} AS bucket_start,
c.avg,
c.cnt,
c.min_value,
c.max_value
FROM canon c
ORDER BY c.d ASC
LIMIT ${cap}
`;
return buckets.map((b) => {
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { foldLegacyCoachAvailability } from "../operator-availability";
describe("foldLegacyCoachAvailability", () => {
it("carries a pre-0343 `coach: false` into the Coach switch and drops the key", () => {
expect(
foldLegacyCoachAvailability({ coach: false, labs: true }, true),
).toEqual({
moduleAvailabilityJson: { labs: true },
assistantCoachEnabled: false,
});
});
it("drops a `coach: true` key without switching the Coach on", () => {
expect(foldLegacyCoachAvailability({ coach: true }, false)).toEqual({
moduleAvailabilityJson: {},
assistantCoachEnabled: false,
});
});
it("leaves a current blob and a missing blob as they are", () => {
expect(foldLegacyCoachAvailability({ labs: false }, true)).toEqual({
moduleAvailabilityJson: { labs: false },
assistantCoachEnabled: true,
});
expect(foldLegacyCoachAvailability(null, true)).toEqual({
moduleAvailabilityJson: null,
assistantCoachEnabled: true,
});
});
});
+26 -22
View File
@@ -169,28 +169,32 @@ export function resolveModuleEnabled(
/** Internal: load the gate inputs once per request. */
function loadInputs(userId: string): Promise<ModuleGateInputs> {
return memoizePerRequest(`module-gate-inputs:${userId}`, async () => {
const [user, cycleProfile] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
gender: true,
disableCoach: true,
modulePreferencesJson: true,
},
}),
prisma.cycleProfile.findUnique({
where: { userId },
select: { cycleTrackingEnabled: true },
}),
]);
return {
gender: user?.gender ?? null,
disableCoach: user?.disableCoach ?? false,
modulePreferences: normalisePrefs(user?.modulePreferencesJson),
cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null,
};
});
return memoizePerRequest(
`module-gate-inputs:${userId}`,
async () => {
const [user, cycleProfile] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
gender: true,
disableCoach: true,
modulePreferencesJson: true,
},
}),
prisma.cycleProfile.findUnique({
where: { userId },
select: { cycleTrackingEnabled: true },
}),
]);
return {
gender: user?.gender ?? null,
disableCoach: user?.disableCoach ?? false,
modulePreferences: normalisePrefs(user?.modulePreferencesJson),
cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null,
};
},
{ freshInBackground: true },
);
}
/**
+39 -7
View File
@@ -104,13 +104,45 @@ async function loadAvailabilityBlob(): Promise<unknown> {
* all-available so the modules stay visible on first boot.
*/
export async function getOperatorModuleAvailability(): Promise<OperatorModuleAvailability> {
return memoizePerRequest("operator-module-availability", async () => {
const [blob, switches] = await Promise.all([
loadAvailabilityBlob(),
getAssistantFlags(),
]);
return resolveOperatorAvailability(blob, switches.coach);
});
return memoizePerRequest(
"operator-module-availability",
async () => {
const [blob, switches] = await Promise.all([
loadAvailabilityBlob(),
getAssistantFlags(),
]);
return resolveOperatorAvailability(blob, switches.coach);
},
{ freshInBackground: true },
);
}
/**
* Bring an instance settings pair written before migration 0343 forward.
*
* Until 0343 the operator turned the Coach off in module availability
* (`{ coach: false }`); since then the Coach switch alone carries that answer
* and the availability key is ignored. The migration folded every live row; a
* restored backup from before it needs the same fold, or the Coach it had off
* comes back on and the dead key sits in the blob. Returns the pair to write.
*/
export function foldLegacyCoachAvailability(
moduleAvailabilityJson: unknown,
assistantCoachEnabled: boolean,
): { moduleAvailabilityJson: unknown; assistantCoachEnabled: boolean } {
if (
typeof moduleAvailabilityJson !== "object" ||
moduleAvailabilityJson === null ||
Array.isArray(moduleAvailabilityJson) ||
!("coach" in moduleAvailabilityJson)
) {
return { moduleAvailabilityJson, assistantCoachEnabled };
}
const { coach, ...rest } = moduleAvailabilityJson as Record<string, unknown>;
return {
moduleAvailabilityJson: rest,
assistantCoachEnabled: coach === false ? false : assistantCoachEnabled,
};
}
/** A module key whose operator layer the blob owns (every key but the Coach). */
+20
View File
@@ -35,6 +35,22 @@ function getCache(builder: WideEventBuilder): Map<string, unknown> {
return cache;
}
export interface MemoizeOptions {
/**
* Read afresh on every call inside a background event.
*
* A request is short and acts for one record, so one read of the operator's
* switches serves all of it. A background job opens one wide event for its
* whole run, and a nightly pass loops over every user inside it: a cached
* switch, module map, provider presence or consent set would be the answer
* from the start of the run, served to every later user and to the wire
* re-check, so turning the Assistant off at 02:05 would not stop the pass
* that began at 02:00. Every input that decides whether AI work may run
* sets this.
*/
freshInBackground?: boolean;
}
/**
* Memoise `factory()` for the lifetime of the current request. Returns
* the factory's result directly when no event context is active so
@@ -49,11 +65,15 @@ function getCache(builder: WideEventBuilder): Map<string, unknown> {
export function memoizePerRequest<T>(
key: string,
factory: () => Promise<T>,
options: MemoizeOptions = {},
): Promise<T> {
const event = getEvent();
if (!event) {
return factory();
}
if (options.freshInBackground && event.getKind() === "background") {
return factory();
}
const cache = getCache(event);
const existing = cache.get(key);
if (existing !== undefined) {
+7 -2
View File
@@ -33,7 +33,7 @@ import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
import { isNearUtc } from "@/lib/tz/format";
import { isNearUtc, isValidTimezone } from "@/lib/tz/format";
import {
ensureUserRollupsFresh,
readRollupBuckets,
@@ -400,7 +400,12 @@ async function readBand(
// straight to the live path keyed in their zone. The coarser bands stay
// on the UTC-anchored rollups — a few hours of offset moves bucket
// MEMBERSHIP at week/month/year grain by at most one edge reading.
if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) {
if (
granularity === "DAY" &&
tz &&
isValidTimezone(tz) &&
!isNearUtc(tz, new Date(now))
) {
return readBandLive(userId, type, granularity, from, to, tz);
}
// `readRollupBuckets` never throws — an empty / missing partition yields
+42
View File
@@ -55,6 +55,24 @@ describe("isValidTimezone", () => {
it("rejects strings longer than 64 chars", () => {
expect(isValidTimezone("A".repeat(65))).toBe(false);
});
// The runtime accepts UTC offsets as zone ids, but Postgres reads a bare
// offset in `AT TIME ZONE` with the POSIX sign (east is negative), so a day
// cut in SQL would land on the wrong side of UTC.
it("rejects bare UTC offsets", () => {
expect(isValidTimezone("+05:30")).toBe(false);
expect(isValidTimezone("-0800")).toBe(false);
expect(isValidTimezone("+05")).toBe(false);
expect(isValidTimezone("\u221205:00")).toBe(false);
});
it("keeps accepting named zones, aliases and the Etc family", () => {
expect(isValidTimezone("Asia/Kolkata")).toBe(true);
expect(isValidTimezone("Asia/Calcutta")).toBe(true);
expect(isValidTimezone("Europe/Kyiv")).toBe(true);
expect(isValidTimezone("Etc/UTC")).toBe(true);
expect(isValidTimezone("Etc/GMT+5")).toBe(true);
});
});
describe("resolveServerDefaultTimezone", () => {
@@ -109,6 +127,16 @@ describe("resolveUserTimezone", () => {
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
});
it("falls back to the server default for a stored bare offset", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "+05:30",
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultUserTimezone: "Asia/Tokyo",
} as never);
expect(await resolveUserTimezone("user-1")).toBe("Asia/Tokyo");
});
it("falls back to the server default when the user row is missing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
@@ -337,3 +365,17 @@ describe("DEFAULT_TIMEZONE", () => {
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
});
});
describe("request schemas refuse a bare offset zone", () => {
it("the screener submission", async () => {
const { createAssessmentSchema } =
await import("@/lib/validations/mental-health");
const base = { instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] };
expect(
createAssessmentSchema.safeParse({ ...base, tz: "Asia/Kolkata" }).success,
).toBe(true);
expect(
createAssessmentSchema.safeParse({ ...base, tz: "+05:30" }).success,
).toBe(false);
});
});
+32 -5
View File
@@ -48,9 +48,20 @@ const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
});
/**
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
* Returns `true` if the runtime accepts the zone, `false` otherwise.
* Cheap (microseconds) — call freely at write paths.
* A zone id the runtime resolved to a bare UTC offset (`+05:30`, `-08:00`).
*/
const OFFSET_ZONE = /^[+-]\d/;
/**
* Validate a timezone string: a named IANA zone (or `UTC`) the runtime
* accepts. Returns `false` for anything else. Cheap (microseconds) — call
* freely at write paths.
*
* Bare UTC offsets are refused although the runtime accepts them as zone ids.
* The zone ends up in Postgres `AT TIME ZONE`, which reads a bare offset with
* the POSIX sign (east of UTC is negative), so `+05:30` would cut every day
* eleven hours off. Named zones, their aliases and the `Etc/GMT±N` family
* mean the same thing to both.
*/
export function isValidTimezone(tz: string): boolean {
if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) {
@@ -60,13 +71,29 @@ export function isValidTimezone(tz: string): boolean {
// The memo only caches successful constructions, so probing an
// invalid zone through it cannot poison the formatter map — and a
// valid zone leaves its formatter warm for the real callers.
getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS);
return true;
const resolved = getDateTimeFormat(
"en-US",
tz,
VALIDATION_OPTIONS,
).resolvedOptions().timeZone;
return !OFFSET_ZONE.test(resolved);
} catch {
return false;
}
}
/**
* `tz` when it is a valid zone, else `fallback`. For every place a stored or
* supplied zone reaches SQL: a value written before offsets were refused may
* still sit in the database, and it must never be spliced.
*/
export function validTimezoneOr(
tz: string | null | undefined,
fallback: string,
): string {
return tz && isValidTimezone(tz) ? tz : fallback;
}
/**
* The list of every IANA zone the current Node runtime accepts.
* Returns an empty array on engines older than Node 22 / V8 12.5
+6 -1
View File
@@ -12,6 +12,7 @@ import {
type InstrumentId,
} from "@/lib/mental-health/instruments";
import { assertStableExternalId } from "@/lib/validations/external-id";
import { isValidTimezone } from "@/lib/tz/format";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
@@ -38,7 +39,11 @@ export const createAssessmentSchema = z
functionalDifficulty: z.number().int().min(0).max(3).optional(),
/** ISO instant the screener was taken; defaults to now server-side. */
takenAt: z.string().datetime().optional(),
tz: z.string().max(64).optional(),
tz: z
.string()
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
/** Locale of the validated wording actually presented. */
locale: z.string().max(16).optional(),
/** Client provenance; defaults to WEB. */
@@ -385,6 +385,59 @@ describe("POST /api/admin/backups/[id]/restore", () => {
expect(rewritten.documentMaxFileBytes).toBe(4 * 1_048_576);
});
it("folds a pre-0343 Coach availability key into the Coach switch", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const live = await prisma.appSettings.create({
data: { id: "singleton" },
});
// A file written before the Coach's operator layer moved from module
// availability to the Coach switch: the operator had the Coach off there.
const { assistantDocumentAiEnabled: _dropped, ...beforeTheSwitchExisted } =
live;
void _dropped;
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "MANUAL_UPLOAD_SETTINGS_TEST",
data: encrypt(
JSON.stringify({
schemaVersion: "1",
exportedAt: "2026-05-09T00:00:00.000Z",
userId: admin.id,
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
appSettings: {
...beforeTheSwitchExisted,
documentQuotaBytes: String(live.documentQuotaBytes),
assistantCoachEnabled: true,
moduleAvailabilityJson: { coach: false, labs: false },
},
}),
),
},
});
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
const res = await POST(
makeRequest(backup.id, {
confirm: "RESTORE",
restoreInstanceSettings: true,
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(res.status).toBe(200);
const restored = await prisma.appSettings.findUniqueOrThrow({
where: { id: "singleton" },
});
expect(restored.assistantCoachEnabled).toBe(false);
expect(restored.moduleAvailabilityJson).toEqual({ labs: false });
});
it("returns 404 when the backup id is unknown", async () => {
const admin = await seedAdminSession();
void admin;
@@ -0,0 +1,98 @@
/**
* A switch turned off while a nightly job is running stops the rest of it.
*
* Reads inside one request are memoised on the request's wide event, so a
* page that resolves the AI capabilities five times reads the settings row
* once. A background job opens one wide event for its whole run, and the
* status crons loop over every candidate inside it: memoising there meant the
* switches, the modules and the consent kinds were read once at the start of
* the run and served to every later user, and the wire re-check read the same
* cached answer. Turning the Assistant off at 02:05 did not stop the 02:00
* pass.
*
* Driven here over the real loop, the real capability loader and Postgres:
* the first user's generation turns the switch off, and the remaining users
* must not reach a generator, while the wire re-check inside the first user's
* own run must see the change too.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const candidates = vi.hoisted(() => ({ ids: [] as string[] }));
vi.mock("@/lib/jobs/status-cron-candidates", () => ({
findStatusCronCandidates: vi.fn(async () =>
candidates.ids.map((id) => ({ id, locale: "en" })),
),
}));
import { runStatusCronGenerate } from "@/lib/jobs/reminder/insights-handlers";
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
let counter = 0;
async function seedUserWithProvider(): Promise<string> {
const n = counter++;
const user = await getPrismaClient().user.create({
data: {
username: `fresh-switch-${n}`,
email: `fresh-switch-${n}@example.test`,
role: "USER",
timezone: "UTC",
locale: "en",
aiProvider: "ANTHROPIC",
aiAnthropicKeyEncrypted: "v1:presence-only",
consentReceipts: {
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
},
},
});
return user.id;
}
async function setInsightStatusSwitch(enabled: boolean): Promise<void> {
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", assistantInsightStatusEnabled: enabled },
update: { assistantInsightStatusEnabled: enabled },
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await setInsightStatusSwitch(true);
});
describe("a switch turned off mid-run", () => {
it("stops the remaining users of the same background pass", async () => {
candidates.ids = [
await seedUserWithProvider(),
await seedUserWithProvider(),
await seedUserWithProvider(),
];
const reached: string[] = [];
const wire: Array<string | null> = [];
const generate = vi.fn(async (userId: string) => {
reached.push(userId);
// The wire re-check before the switch moves: open.
wire.push(
(await aiEgressRefusal("statusText", userId, ["anthropic"]))?.reason ??
null,
);
await setInsightStatusSwitch(false);
// The same user's wire re-check after it moved: closed.
wire.push(
(await aiEgressRefusal("statusText", userId, ["anthropic"]))?.reason ??
null,
);
});
await runStatusCronGenerate("job.test.fresh_switch", generate);
expect(reached).toEqual([candidates.ids[0]]);
expect(wire[0]).toBeNull();
expect(wire[1]).not.toBeNull();
});
});
@@ -23,6 +23,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
const NOW = new Date("2026-08-07T05:20:00.000Z");
@@ -32,17 +33,34 @@ beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seedUser(username: string, locale = "en") {
/**
* A user whose Coach can answer: a provider (presence only) and a Coach
* consent receipt. Reminders are held while the Coach is unavailable, so a
* user without them would prove only the hold.
*/
async function seedUser(
username: string,
{ coach = true }: { coach?: boolean } = {},
) {
return getPrismaClient().user.create({
data: {
username,
email: `${username}@example.test`,
timezone: "UTC",
locale,
locale: "en",
...(coach ? COACH_READY : {}),
},
});
}
const COACH_READY = {
aiProvider: "ANTHROPIC",
aiAnthropicKeyEncrypted: "v1:presence-only",
consentReceipts: {
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
},
} as const;
describe("a due Coach reminder reaches the conversation the badge points at", () => {
it("writes the note as an assistant message, and that message is what makes the badge unread", async () => {
const prisma = getPrismaClient();
@@ -236,4 +254,95 @@ describe("a due Coach reminder reaches the conversation the badge points at", ()
expect(untouched.status).toBe("active");
expect((await readCoachNudgeStatus(user.id)).unread).toBe(false);
});
it("holds every reminder while the Coach is unavailable, and resumes when it is back", async () => {
const prisma = getPrismaClient();
const user = await seedUser("reminder-held", { coach: false });
const due = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("held until the Coach is back"),
triggerKind: "date",
dueAt: YESTERDAY,
status: "active",
source: "user",
},
});
// One more ignored day would reach the nag cap.
const nagging = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("surfaced before the Coach went away"),
triggerKind: "date",
dueAt: new Date("2026-08-01T09:00:00.000Z"),
status: "surfaced",
lastSurfacedAt: new Date("2026-08-04T05:20:00.000Z"),
surfaceCount: 2,
source: "user",
},
});
const context = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes("ask about the weigh-in"),
triggerKind: "context",
contextCue: "NEXT_WEIGHT_LOGGED",
status: "active",
source: "user",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: YESTERDAY,
},
});
// Three daily ticks: none surfaces, none is charged or dismissed.
for (let day = 0; day < 3; day++) {
const summary = await runCoachReminderSweep(
prisma,
new Date(NOW.getTime() + day * 86_400_000),
);
expect(summary.remindersDue).toBe(0);
expect(summary.contextSurfaced).toBe(0);
expect(summary.nagDismissed).toBe(0);
}
// The ingest hook holds too.
expect(
(await evaluateCoachContextReminders(prisma, user.id, "measurement", NOW))
.surfaced,
).toBe(0);
expect(
await prisma.coachMessage.count({
where: { conversation: { userId: user.id } },
}),
).toBe(0);
const rows = await prisma.coachReminder.findMany({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
const byId = new Map(rows.map((r) => [r.id, r]));
expect(byId.get(due.id)?.status).toBe("active");
expect(byId.get(nagging.id)?.status).toBe("surfaced");
expect(byId.get(nagging.id)?.surfaceCount).toBe(2);
expect(byId.get(context.id)?.status).toBe("active");
// The Coach comes back: the held reminders surface on the next tick.
await prisma.user.update({
where: { id: user.id },
data: {
aiProvider: COACH_READY.aiProvider,
aiAnthropicKeyEncrypted: COACH_READY.aiAnthropicKeyEncrypted,
consentReceipts: { create: COACH_READY.consentReceipts.create },
},
});
const resumed = await runCoachReminderSweep(prisma, NOW);
expect(resumed.remindersDue).toBe(1);
expect(resumed.contextSurfaced).toBe(1);
});
});
@@ -236,6 +236,66 @@ describe("logging a period start and taking it back", () => {
expect(await liveShapes()).toEqual(before);
});
it("gives back the start a mis-tapped earlier start folded in", async () => {
// A start a few days after another is the same period entered late, so
// the earlier tap folds it in. When the earlier tap was the mistake,
// taking it back has to bring the real start back too, not reopen the
// cycle before it.
await loginAs(USER_ID);
await seedHistory();
await startPeriod("2026-08-10");
const before = await liveShapes();
const realStart = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-08-10" },
select: { id: true },
});
await startPeriod("2026-08-05");
const during = await liveShapes();
expect(during.map((c) => c.startDate)).not.toContain("2026-08-10");
expect(during.at(-1)?.startDate).toBe("2026-08-05");
const misTap = await getPrismaClient().menstrualCycle.findFirstOrThrow({
where: { userId: USER_ID, startDate: "2026-08-05" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
const res = await DELETE(
jsonRequest(`/api/cycle/cycles/${misTap.id}`, "DELETE"),
{ params: Promise.resolve({ id: misTap.id }) },
);
expect(res.status).toBe(204);
expect(await liveShapes()).toEqual(before);
// The real start's own day is its own again.
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-08-10" },
select: { cycleId: true },
});
expect(dayLog.cycleId).toBe(realStart.id);
});
it("gives back the folded start when the mis-tap's day-log is deleted", async () => {
await loginAs(USER_ID);
await seedHistory();
await startPeriod("2026-08-10");
const before = await liveShapes();
await startPeriod("2026-08-05");
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: USER_ID, date: "2026-08-05" },
select: { id: true },
});
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
const res = await DELETE(
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"),
{ params: Promise.resolve({ id: dayLog.id }) },
);
expect(res.status).toBe(204);
expect(await liveShapes()).toEqual(before);
});
it("leaves a day-log that opened no cycle alone", async () => {
// A plain entry on a day that is not a cycle start must not take a cycle
// with it when it is deleted.
@@ -86,6 +86,12 @@ function byLocalDay(
const EXPECTED = { "2026-09-10": 1100, "2026-09-11": 10 };
/** The ladder puts Withings first for pulse, so its two readings are the day. */
const CANON_EXPECTED = [
{ day: "2026-09-10", value: 95, count: 2, min: 90, max: 100 },
{ day: "2026-09-11", value: 64, count: 1, min: 64, max: 64 },
];
describe("daily series buckets by the user's calendar day (#1026)", () => {
for (const tz of ["Europe/Berlin", "Asia/Kolkata", "America/New_York"]) {
it(`chart daily read agrees with the local day (${tz})`, async () => {
@@ -124,4 +130,58 @@ describe("daily series buckets by the user's calendar day (#1026)", () => {
expect(byLocalDay(rows, tz)).toEqual(EXPECTED);
});
}
it("collapses a day to its canonical source before folding it", async () => {
// Two sources on one day: only the ladder's pick is folded, and the
// mean, count and spread all come from that one source.
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "tz-canon",
email: "tz-canon@example.test",
role: "USER",
timezone: "Europe/Berlin",
},
});
const at = (h: number) => localHmAsUtc(DAY, "Europe/Berlin", h, 0);
await prisma.measurement.createMany({
data: [
{ value: 60, source: "APPLE_HEALTH" as const, measuredAt: at(8) },
{ value: 70, source: "APPLE_HEALTH" as const, measuredAt: at(9) },
{ value: 90, source: "WITHINGS" as const, measuredAt: at(10) },
{ value: 100, source: "WITHINGS" as const, measuredAt: at(12) },
{ value: 50, source: "MANUAL" as const, measuredAt: at(11) },
// The next day has one source only.
{
value: 64,
source: "WITHINGS" as const,
measuredAt: localHmAsUtc(NEXT_DAY, "Europe/Berlin", 7, 0),
},
].map((r) => ({
...r,
userId: user.id,
type: "PULSE" as const,
unit: "bpm",
})),
});
const rows = await readLiveBuckets({
userId: user.id,
type: "PULSE",
from: new Date("2026-09-05T00:00:00Z"),
to: new Date("2026-09-15T00:00:00Z"),
cap: 366,
priorityJson: null,
grain: "daily",
timeZone: "Europe/Berlin",
});
expect(
rows.map((r) => ({
day: userDayKey(new Date(r.measuredAt), "Europe/Berlin"),
value: r.value,
count: r.count,
min: r.minValue,
max: r.maxValue,
})),
).toEqual(CANON_EXPECTED);
});
});