mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/v139-review-server' into release/v1.39.0
This commit is contained in:
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;
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -985,6 +985,7 @@ export function probeProviderChain(
|
||||
managedBy: entries.length > 0 ? managedBy : null,
|
||||
};
|
||||
},
|
||||
{ freshInBackground: true },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user