feat(ai): show stored model text inside a shared record as the owner would

Serving stored text and starting model work are separate questions. A
delegate may not start model work on somebody else's record, but reads
the owner's stored briefing, period narrative, status notes, workout
notes and Coach unread signal exactly when the owner would: those
readers now resolve the record's own capability state, not the
viewer's. Generation, warms and enqueues still ask as the viewer, so a
delegate's read of a stale narrative queues nothing. A hidden
model-written narrative is no longer overwritten by a deterministic
warm while AI is unavailable.

The sharing front door reads the digest's briefing lead again, and an
integration case pins both halves.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:17:00 +02:00
1 parent 7df260d62e
commit 2143445da2
49 files changed
+306 -93

No files matched your search

@@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
@@ -63,7 +63,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -87,7 +87,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -135,7 +135,7 @@ describe("GET /api/insights/biomarker-assessment", () => {
it("answers 200 with no note while statusText is unavailable, calling no generator", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
@@ -25,7 +25,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status";
import { resolveMetricStatusLocale } from "@/lib/insights/metric-status";
@@ -52,7 +52,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.biomarker-status.unavailable" },
@@ -46,7 +46,7 @@ vi.mock("@/lib/insights/blood-pressure-status", () => ({
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
@@ -54,7 +54,7 @@ vi.mock("@/lib/ai/provider", () => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
import {
@@ -79,7 +79,7 @@ function makeReq(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(probeProviderPresence).mockResolvedValue(true);
vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({
hasProvider: true,
@@ -97,7 +97,7 @@ describe("GET /api/insights/blood-pressure-status", () => {
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.text).toBe("ok");
expect(body.data.ai).toEqual(AI_AVAILABLE);
expect(getAiCapability).toHaveBeenCalledWith("statusText");
expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "statusText");
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
@@ -110,7 +110,7 @@ describe("GET /api/insights/blood-pressure-status", () => {
"answers 200 with no note for %s, reading and warming nothing",
async (reason) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason));
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
@@ -128,7 +128,9 @@ describe("GET /api/insights/blood-pressure-status", () => {
it("reports hasProvider as presence only: false for a missing provider", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider"));
vi.mocked(aiCapabilityToServe).mockResolvedValue(
aiUnavailable("no_provider"),
);
vi.mocked(probeProviderPresence).mockResolvedValue(false);
const res = await callGet(makeReq());
const body = (await res.json()) as { data: Record<string, unknown> };
@@ -7,7 +7,7 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.blood-pressure-status.unavailable" },
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.bmi-status.unavailable" },
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { requireRecordAuth, evaluateContext, readStatus, getAiCapability } =
const { requireRecordAuth, evaluateContext, readStatus, aiCapabilityToServe } =
vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
@@ -21,7 +21,7 @@ const { requireRecordAuth, evaluateContext, readStatus, getAiCapability } =
nudgedAt: null,
}),
),
getAiCapability: vi.fn(),
aiCapabilityToServe: vi.fn(),
}));
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
@@ -30,7 +30,7 @@ vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireRecordAuth,
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
vi.mock("@/lib/ai/coach/nudge-status", () => ({
readCoachNudgeStatus: readStatus,
}));
@@ -47,7 +47,7 @@ import { GET } from "../route";
beforeEach(() => {
vi.clearAllMocks();
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
getAiCapability.mockResolvedValue(AVAILABLE);
aiCapabilityToServe.mockResolvedValue(AVAILABLE);
});
describe("nudge-status — NEXT_APP_OPEN hook", () => {
@@ -114,7 +114,7 @@ describe("nudge-status — the coach capability", () => {
data: Record<string, unknown>;
};
expect(getAiCapability).toHaveBeenCalledWith("coach");
expect(aiCapabilityToServe).toHaveBeenCalledWith("u1", "coach");
expect(res.data).toEqual({
unread: true,
nudgedAt: "2026-07-18",
@@ -130,7 +130,7 @@ describe("nudge-status — the coach capability", () => {
actor: { id: "u1" },
});
const ai = { available: false, reason, onDeviceAllowed: false };
getAiCapability.mockResolvedValue(ai);
aiCapabilityToServe.mockResolvedValue(ai);
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
data: Record<string, unknown>;
@@ -22,15 +22,16 @@
* local seen-stamp keys on a stable value (kept for the existing client
* contract).
*
* A mixed read: while the `coach` capability is unavailable (operator switch,
* opt-out, no provider, no consent) the route still answers 200, with the
* A mixed read: while the record's `coach` capability is unavailable
* (operator switch, opt-out, no provider, no consent) the route still answers
* 200, with the
* quiet shape `{ unread: false, nudgedAt: null, conversationId: null }` and
* an `ai` state saying why. There is no Coach to open, so there is nothing
* unread, and no app-open reminder is evaluated.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { prisma } from "@/lib/db";
@@ -52,7 +53,7 @@ export const GET = apiHandler(async () => {
// budget and writes into their conversation, and reading whether the thread
// has something unopened does neither.
const { user, actor } = await requireRecordAuth("read", "record");
const ai = await getAiCapability("coach");
const ai = await aiCapabilityToServe(user.id, "coach");
if (!ai.available) {
return apiSuccess({
nudgedAt: null,
@@ -87,7 +87,7 @@ vi.mock("@/lib/ai/provider", () => ({
// The per-request `ai` block beside the body. Pinned here so the envelope
// assertions do not depend on the capability loader's own reads.
vi.mock("@/lib/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
aiCapabilityToServe: vi.fn(),
}));
vi.mock("@/lib/medication-category", () => ({
@@ -105,7 +105,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { probeProviderPresence } from "@/lib/ai/provider";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
@@ -132,7 +132,7 @@ beforeEach(() => {
vi.resetAllMocks();
// resetAllMocks drops the module gate's default; restore it.
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -268,7 +268,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "user_disabled",
onDeviceAllowed: false,
@@ -295,8 +295,8 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.ai.statusText.reason).toBe("user_disabled");
expect(
vi
.mocked(getAiCapability)
.mock.calls.map((c) => c[0])
.mocked(aiCapabilityToServe)
.mock.calls.map((c) => c[1])
.sort(),
).toEqual(["briefing", "statusText"]);
});
+3 -3
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { probeProviderPresence } from "@/lib/ai/provider";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { apiError, apiSuccess } from "@/lib/api-response";
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
import { summarize } from "@/lib/analytics/trends";
@@ -95,8 +95,8 @@ export const GET = apiHandler(async () => {
);
const [briefing, statusText] = await Promise.all([
getAiCapability("briefing"),
getAiCapability("statusText"),
aiCapabilityToServe(user.id, "briefing"),
aiCapabilityToServe(user.id, "statusText"),
]);
return apiSuccess({
+2 -2
View File
@@ -35,7 +35,7 @@ import {
import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types";
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
export const dynamic = "force-dynamic";
@@ -161,7 +161,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// The model-written override is served, and warmed, only while
// `statusText` is available. Otherwise the deterministic text stands and
// `ai` says why there is no warmer prose.
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
const assessment = await resolveDerivedAssessment({
metric,
userId: user.id,
@@ -7,7 +7,7 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.medication-compliance-status.unavailable" },
@@ -9,7 +9,7 @@ vi.mock("@/lib/db", () => ({
}));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
@@ -62,7 +62,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { generateMetricStatus } from "@/lib/insights/metric-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -85,7 +85,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -146,7 +146,7 @@ describe("GET /api/insights/metric-status", () => {
"answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)",
async (readings, insufficient) => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
+2 -2
View File
@@ -30,7 +30,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
import { prisma } from "@/lib/db";
import {
@@ -104,7 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!gate.enabled) return gate.response;
}
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
// The note is not served, but whether there is anything to assess is
// data: the card keeps its insufficient-data state.
@@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// The `statusText` capability decides whether the note is served; the
// unavailable body's provider-presence probe is stubbed.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
vi.mock("@/lib/ai/provider", () => ({
probeProviderPresence: vi.fn(async () => true),
}));
@@ -51,7 +51,7 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -81,7 +81,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
@@ -120,13 +120,13 @@ describe("GET /api/insights/mood-status — module gate", () => {
userRow({ mood: false }) as never,
);
await callGet(makeReq());
expect(getAiCapability).not.toHaveBeenCalled();
expect(aiCapabilityToServe).not.toHaveBeenCalled();
});
it("answers 200 with no note while statusText is unavailable", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never);
vi.mocked(getAiCapability).mockResolvedValue({
vi.mocked(aiCapabilityToServe).mockResolvedValue({
available: false,
reason: "no_provider",
onDeviceAllowed: true,
+2 -2
View File
@@ -8,7 +8,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -34,7 +34,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.mood-status.unavailable" },
+26 -19
View File
@@ -13,10 +13,12 @@
*
* A mixed read. The narrative is data when the deterministic composer wrote
* it and model output when a provider did. Model-written text is served only
* while the `periodNarrative` capability is available; otherwise `narrative`
* is null and `ai` says why. The warm is enqueued only when the capability is
* available, or when there is no servable text at all (the worker then writes
* the deterministic narrative). The `insights` module is the AI analysis
* while the record's `periodNarrative` capability is available (answered from
* the record's own state, so a delegate reads the owner's narrative exactly
* when the owner would); otherwise `narrative` is null and `ai` says why. The
* warm is enqueued only when the capability is available, or when no row
* exists at all (the worker then writes the deterministic narrative); never
* for a delegate. The `insights` module is the AI analysis
* opt-out and is folded into the capability, so it no longer refuses the
* route.
*/
@@ -27,9 +29,9 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { prisma } from "@/lib/db";
import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate";
import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic";
import {
PERIOD_DAYS,
type NarrativePeriod,
@@ -86,21 +88,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
const locale = narrowLocale(resolved);
const ai = await getAiCapability("periodNarrative");
const stored = await readPeriodNarrative(user.id, period, locale);
// A row without a recorded provider is treated as model text: failing
// closed hides at most a deterministic line the next warm rewrites.
const modelWritten =
stored !== null && stored.providerType !== DETERMINISTIC_PROVIDER_TYPE;
const existing =
stored !== null && (!modelWritten || ai.available) ? stored : null;
// Whether model-written text may be shown is the record's own answer,
// whoever is reading; the reader already applies it (a model-written row
// reads as absent while it may not be shown). Starting a warm for a
// delegate stays suppressed below.
const ai = await aiCapabilityToServe(user.id, "periodNarrative");
const existing = await readPeriodNarrative(user.id, period, locale);
const isFresh =
existing !== null &&
Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS;
// With AI unavailable a warm is still useful when nothing servable exists:
// it produces the deterministic narrative. A stale deterministic row is
// served as it is rather than re-warmed.
const mayWarm = ai.available || existing === null;
// With AI unavailable a warm only helps when no row exists at all: the
// worker then writes the deterministic narrative. A hidden model-written
// row must not be overwritten by one (turning AI back on brings it back),
// and a stale deterministic row is served as it is.
const mayWarm =
ai.available ||
(existing === null &&
(await prisma.insightNarrative.findUnique({
where: { userId_period_locale: { userId: user.id, period, locale } },
select: { id: true },
})) === null);
// Read-only: never block on the provider. Warm out of band whenever the row
// is stale / missing. The generator produces AI prose when a provider is
@@ -127,7 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
has_narrative: existing !== null,
revalidating,
generation_suppressed: generationSuppressed,
model_text_hidden: modelWritten && !ai.available,
ai_reason: ai.reason,
},
});
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.pulse-status.unavailable" },
+2 -2
View File
@@ -7,7 +7,7 @@ import {
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { unavailableStatusBody } from "@/lib/insights/status-unavailable";
export const dynamic = "force-dynamic";
@@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// record, which is not a section a scoped grant can name. The miss behind it
// enqueues nothing while a delegate is holding the request.
const { user } = await requireRecordAuth("manage", "record");
const ai = await getAiCapability("statusText");
const ai = await aiCapabilityToServe(user.id, "statusText");
if (!ai.available) {
annotate({
action: { name: "insights.weight-status.unavailable" },
+2 -2
View File
@@ -30,7 +30,7 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
@@ -65,7 +65,7 @@ export const GET = apiHandler(
// served only while `workoutInsights` is available (operator switch, the
// AI analysis opt-out, provider presence, consent). Otherwise the row is
// not even loaded, `aiInsight` is null, and `ai` says why.
const ai = await getAiCapability("workoutInsights");
const ai = await aiCapabilityToServe(user.id, "workoutInsights");
const row = await prisma.workout.findUnique({
where: { id },
@@ -64,13 +64,13 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => {
});
// The Activity Insight paragraph follows the `workoutInsights` capability.
vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import {
AI_AVAILABLE,
aiUnavailable,
@@ -146,7 +146,7 @@ describe("GET /api/workouts/{id}", () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
// The timezone resolver caches per user for 60 s in process; drop the
// entry so a test that pins a zone is not served the previous one.
invalidateUserTimezone(SESSION_OK.user.id);
@@ -572,7 +572,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
vi.clearAllMocks();
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
@@ -624,7 +624,10 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
paragraph: "A steady, aerobic-leaning run.",
generatedAt: generatedAt.toISOString(),
});
expect(getAiCapability).toHaveBeenCalledWith("workoutInsights");
expect(aiCapabilityToServe).toHaveBeenCalledWith(
"user-1",
"workoutInsights",
);
expect(body.data.ai).toEqual(AI_AVAILABLE);
});
@@ -637,7 +640,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => {
] as const)(
"does not read or expose the stored paragraph while workoutInsights is %s",
async (reason) => {
vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason));
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
@@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
import { prisma } from "@/lib/db";
@@ -41,6 +41,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -39,6 +39,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -78,6 +78,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -57,6 +57,8 @@ vi.mock("@/lib/ai/capabilities/egress", () => ({
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
aiCapabilityToServe: (...args: unknown[]) =>
(aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -51,6 +51,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -50,6 +50,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -55,6 +55,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -57,6 +57,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
})),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
@@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -44,6 +44,8 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
aiCapabilityToServe: (...args: unknown[]) =>
(aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args),
aiCapabilityForJob: vi.fn(async () => ({
available: true,
reason: null,
@@ -98,6 +98,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -29,6 +29,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -38,6 +38,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -30,6 +30,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -54,6 +54,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -61,6 +61,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -42,6 +42,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({
reason: null,
onDeviceAllowed: true,
}),
aiCapabilityToServe: async () => ({
available: true,
reason: null,
onDeviceAllowed: true,
}),
}));
vi.mock("@/lib/insights/memory", () => ({
@@ -38,6 +38,8 @@ vi.mock("@/lib/ai/provider", () => ({
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...args: unknown[]) =>
aiCapabilityForRecord(...(args as [])),
aiCapabilityToServe: (...args: unknown[]) =>
(aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args),
}));
vi.mock("@/lib/sharing/delegated-generation", () => ({
@@ -30,6 +30,8 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
const aiCapabilityForRecord = vi.fn();
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a),
aiCapabilityToServe: (...args: unknown[]) =>
(aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args),
}));
const probeProviderPresence = vi.fn();
@@ -75,7 +75,10 @@ vi.mock("@/lib/ai/provider", () => ({
const { aiCapabilityForRecord } = vi.hoisted(() => ({
aiCapabilityForRecord: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord,
aiCapabilityToServe: aiCapabilityForRecord,
}));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
@@ -48,7 +48,10 @@ const {
probeProviderPresence: vi.fn(),
enqueueStatusGeneration: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord,
aiCapabilityToServe: aiCapabilityForRecord,
}));
vi.mock("@/lib/ai/provider", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/provider")>()),
probeProviderPresence,
@@ -20,7 +20,10 @@ vi.mock("@/lib/db", () => ({ prisma: {} }));
const { aiCapabilityForRecord } = vi.hoisted(() => ({
aiCapabilityForRecord: vi.fn(),
}));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord }));
vi.mock("@/lib/ai/capabilities/gate", () => ({
aiCapabilityForRecord,
aiCapabilityToServe: aiCapabilityForRecord,
}));
const UNAVAILABLE = {
available: false,
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import {
buildPeriodNarrativeContext,
type NarrativePeriod,
@@ -565,14 +565,15 @@ export async function readPeriodNarrative(
/**
* Whether a stored narrative may be served: always for the deterministic
* narrative, and for model-written prose only while `periodNarrative` is
* available for the record.
* available for the record, answered from the record's own state whoever is
* reading.
*/
async function narrativeServable(
userId: string,
providerType: string | null,
): Promise<boolean> {
if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true;
return (await aiCapabilityForRecord(userId, "periodNarrative")).available;
return (await aiCapabilityToServe(userId, "periodNarrative")).available;
}
/** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */
+8 -4
View File
@@ -1,7 +1,7 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import { probeProviderPresence } from "@/lib/ai/provider";
import {
@@ -161,10 +161,12 @@ async function readStatusNote(
/**
* Whether stored status notes may be served, or re-dated, for this record.
* Every reader and both unchanged-data gates ask this first.
* Every reader and both unchanged-data gates ask this first. Answered from
* the record's own state, so a delegate reads the owner's notes exactly when
* the owner would; starting work is a separate question (see below).
*/
async function statusTextServable(userId: string): Promise<boolean> {
return (await aiCapabilityForRecord(userId, "statusText")).available;
return (await aiCapabilityToServe(userId, "statusText")).available;
}
/**
@@ -708,7 +710,9 @@ export async function resolveReadOnlyStatusMiss(args: {
metric: InsightStatusScope;
locale: SupportedLocale;
}): Promise<ReadOnlyMissOutcome> {
const capability = await aiCapabilityForRecord(args.userId, "statusText");
// Serving view: whether the record's notes may be shown at all. Starting
// work for a delegate is refused by the suppression branch below.
const capability = await aiCapabilityToServe(args.userId, "statusText");
if (!capability.available) {
const reason = capability.reason ?? "check_failed";
annotate({
@@ -419,3 +419,72 @@ describe("the status family", () => {
},
);
});
describe("inside somebody else's record (S8, a MANAGE delegate)", () => {
async function seedOwnerText(recordId: string): Promise<void> {
await seed(recordId);
const prisma = getPrismaClient();
// The narrative is stale, so an owner's read would warm it.
await prisma.insightNarrative.updateMany({
where: { userId: recordId },
data: { updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
});
const { writeStatusNote, statusCacheAction } =
await import("@/lib/insights/status-cache");
const { userDayKey } = await import("@/lib/tz/format");
await writeStatusNote({
userId: recordId,
cacheAction: statusCacheAction("weight", "en"),
todayKey: userDayKey(new Date(), "UTC"),
text: "The owner's weight note.",
});
}
it("reads the owner's stored model text exactly as the owner would", async () => {
await enterState("S8", seedOwnerText);
const narrative = await readNarrative();
expect(narrative.data.narrative?.text).toBe(
"A model wrote this retrospective.",
);
expect(narrative.data.ai.available).toBe(true);
const snapshot = await readSnapshot();
expect(snapshot.data.briefing).not.toBeNull();
const digest = await readDigest();
expect(digest.data.briefingLead).toBe("Model prose about your week.");
const { GET } = await import("@/app/api/insights/weight-status/route");
const status = await json<{ text: string | null }>(
await GET(new NextRequest("http://localhost/api/insights/weight-status")),
);
expect(status.status).toBe(200);
expect(status.data.text).toBe("The owner's weight note.");
});
it("starts no model work on the owner's record", async () => {
await enterState("S8", seedOwnerText);
// A stale narrative the owner's read would warm: the delegate's does not.
const narrative = await readNarrative();
expect(narrative.data.revalidating).toBe(false);
expect(enqueueNarrativeWarm).not.toHaveBeenCalled();
// Every generating route refuses a delegate before any work.
const { POST: chat } = await import("@/app/api/insights/chat/route");
const chatRes = await chat(
new NextRequest("http://localhost/api/insights/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: "How was my week?" }),
}),
);
expect(chatRes.status).toBe(403);
const warm = await pregenerate();
expect(warm.status).toBe(403);
expect(enqueueForceWarm).not.toHaveBeenCalled();
expect(await getPrismaClient().coachConversation.count()).toBe(1);
});
});
@@ -261,6 +261,17 @@ frontDoorRead("GET /api/dashboard/snapshot", {
read: (data: { user: { username: string } }) => data.user.username,
});
/** The person's own provider (presence only) and an AI consent receipt. */
function ownProvider() {
return {
aiProvider: "ANTHROPIC",
aiAnthropicKeyEncrypted: "v1:presence-only",
consentReceipts: {
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
},
};
}
/** A parseable cached briefing — the only thing the digest lifts prose from. */
function cachedBriefing(marker: string): string {
return JSON.stringify({
@@ -272,12 +283,16 @@ function cachedBriefing(marker: string): string {
}
frontDoorRead("GET /api/daily/digest", {
// The briefing lead is stored model text: shown whenever the record's own
// `briefing` capability allows it, whoever reads. Each account therefore
// has its own provider (presence only) and consent.
seed: async (userId, marker) => {
await getPrismaClient().user.update({
where: { id: userId },
data: {
insightsCachedText: cachedBriefing(marker),
insightsCachedAt: new Date(),
...ownProvider(),
},
});
},
@@ -342,6 +357,10 @@ async function seedAssistantMessage(userId: string, at: string) {
frontDoorRead("GET /api/insights/coach/nudge-status", {
seed: async (userId, marker) => {
await getPrismaClient().user.update({
where: { id: userId },
data: ownProvider(),
});
await seedAssistantMessage(
userId,
marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z",