mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge remote-tracking branch 'origin/v141/e-memory' into v141/b-loop
This commit is contained in:
commit
f46e0960fb
51 files changed
+6021
-823
No files matched your search
@@ -332,7 +332,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// being available (the GDPR reason), and a timestamp on one's own row is
|
||||
// data too.
|
||||
"src/app/api/insights/coach/seen/route.ts",
|
||||
"src/app/api/insights/coach/facts/route.ts",
|
||||
// `insights/coach/facts/route.ts` is not listed, by the rule below: it
|
||||
// lists and erases without the Coach gate but keeps it on POST (the
|
||||
// remember button saves to the Coach's memory, which is Coach use).
|
||||
"src/app/api/insights/coach/facts/[id]/route.ts",
|
||||
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
|
||||
// PATCH (confirming a proposal is Coach use) and erases without it.
|
||||
|
||||
@@ -36,6 +36,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { clampReviewDays } from "@/lib/ai/coach/memory/propose-plan";
|
||||
import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents";
|
||||
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
@@ -102,15 +103,25 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
// +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read
|
||||
// the current row first so an explicitly-pinned review date is never
|
||||
// clobbered; the read stays owner-scoped, so it leaks no cross-account state.
|
||||
// v1.41 — a plan the Coach proposed in a turn carries its review window as
|
||||
// the distance from the proposal to its `reviewDate`; taking it on here
|
||||
// counts that window from now, exactly like the tap under the answer
|
||||
// (`decidePlanProposal`).
|
||||
if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) {
|
||||
const current = await prisma.coachPlan.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { reviewDate: true },
|
||||
select: { reviewDate: true, status: true, createdAt: true },
|
||||
});
|
||||
if (current && current.reviewDate === null) {
|
||||
data.reviewDate = new Date(
|
||||
Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000,
|
||||
);
|
||||
} else if (current?.reviewDate && current.status === "proposed") {
|
||||
const days = clampReviewDays(
|
||||
(current.reviewDate.getTime() - current.createdAt.getTime()) /
|
||||
86_400_000,
|
||||
);
|
||||
data.reviewDate = new Date(Date.now() + days * 86_400_000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* PATCH /api/insights/coach/facts/[id] — edit one Coach fact's wording.
|
||||
* DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact.
|
||||
*
|
||||
* v1.11.1 — the per-fact "forget this one thing" delete for the durable
|
||||
* Coach facts surface.
|
||||
* Coach facts surface. It is also the "Undo" under a note the Coach saved in
|
||||
* a turn (v1.41).
|
||||
*
|
||||
* v1.41 — PATCH rewrites the fact's text (re-encrypted; the category and the
|
||||
* source stay). Only a listed fact can be edited: a proposal still waiting
|
||||
* for the person's answer is not one, and answers 404 like an unknown id.
|
||||
* Editing is the person's own data, so no Coach or AI gate.
|
||||
*
|
||||
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
|
||||
* `where: { id, userId, deletedAt: null }` rather than `update`. A
|
||||
@@ -18,14 +25,89 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { coachFactPatchSchema } from "@/lib/validations/coach-fact";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const PATCH_RATE_LIMIT = 40;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-facts:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 2 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachFactPatchSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const text = parsed.data.fact.replace(/\s+/g, " ");
|
||||
// `updateMany` scoped by id, owner and liveness: an unknown, cross-user,
|
||||
// deleted or still-proposed id is a 0-count no-op, never a P2025 throw.
|
||||
const { count } = await prisma.coachFact.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
source: { not: PROPOSED_FACT_SOURCE },
|
||||
},
|
||||
data: { factEncrypted: encryptToBytes(text) },
|
||||
});
|
||||
if (count === 0) {
|
||||
// Indistinguishable from "owned by someone else" — never reveal which.
|
||||
return apiError("Fact not found", 404);
|
||||
}
|
||||
|
||||
const row = await prisma.coachFact.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { id: true, category: true, source: true, updatedAt: true },
|
||||
});
|
||||
if (!row) return apiError("Fact not found", 404);
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.facts.updated" },
|
||||
meta: { category: row.category },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
fact: {
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
text,
|
||||
source: row.source,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* v1.41 — the memory list's write half: the remember button (POST), editing
|
||||
* a fact (PATCH), and a GET that never lists a proposal still waiting for
|
||||
* the person's answer.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachFact: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/memory/remember", () => ({
|
||||
rememberMessageAsFact: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// The capability gate answers "unavailable" throughout: every verb must
|
||||
// still work, and must not even ask.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
requireAiCapability: vi.fn(async () => {
|
||||
throw new Error("the coach capability was consulted");
|
||||
}),
|
||||
getAiCapability: vi.fn(async () => ({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the codec so the test never needs an encryption key. The "row"
|
||||
// carries a tagged Uint8Array and the mock maps it back to a string;
|
||||
// a sentinel buffer triggers a throw to exercise the fail-closed skip.
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) => Buffer.from(buf).toString()),
|
||||
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(s))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as () => Promise<Response>;
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
const callPatch = PATCH as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>;
|
||||
|
||||
function req(method: string, body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/coach/facts", {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/facts (v1.41)", () => {
|
||||
it("never lists a proposal and returns where each fact came from", async () => {
|
||||
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "f1",
|
||||
category: "goal",
|
||||
factEncrypted: new Uint8Array(Buffer.from("75 kg by December")),
|
||||
confidence: 80,
|
||||
source: "coach",
|
||||
sourceConversationId: "c1",
|
||||
sourceMessageId: "m1",
|
||||
lastUsedAt: new Date("2026-10-05T10:00:00Z"),
|
||||
createdAt: new Date("2026-10-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-10-01T10:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { facts: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.facts[0]).toMatchObject({
|
||||
id: "f1",
|
||||
text: "75 kg by December",
|
||||
source: "coach",
|
||||
sourceMessageId: "m1",
|
||||
lastUsedAt: "2026-10-05T10:00:00.000Z",
|
||||
});
|
||||
const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
|
||||
?.where as Record<string, unknown>;
|
||||
expect(where.source).toEqual({ not: "proposed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/insights/coach/facts", () => {
|
||||
it("remembers the caller's own message by id", async () => {
|
||||
vi.mocked(rememberMessageAsFact).mockResolvedValue({
|
||||
id: "f9",
|
||||
category: "medication",
|
||||
text: "I take ramipril",
|
||||
source: "user",
|
||||
created: true,
|
||||
});
|
||||
const res = await callPost(req("POST", { messageId: "um1" }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(rememberMessageAsFact).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
messageId: "um1",
|
||||
});
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toEqual({
|
||||
fact: {
|
||||
id: "f9",
|
||||
category: "medication",
|
||||
text: "I take ramipril",
|
||||
source: "user",
|
||||
},
|
||||
created: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("never accepts text or a userId in the body", async () => {
|
||||
const res = await callPost(
|
||||
req("POST", { messageId: "um1", fact: "anything", userId: "x" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(rememberMessageAsFact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a 404 for a message the caller does not own", async () => {
|
||||
vi.mocked(rememberMessageAsFact).mockResolvedValue(null);
|
||||
const res = await callPost(req("POST", { messageId: "foreign" }));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("is refused while the Coach module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
} as never);
|
||||
const res = await callPost(req("POST", { messageId: "um1" }));
|
||||
expect(res.status).toBe(403);
|
||||
expect(rememberMessageAsFact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/insights/coach/facts/[id]", () => {
|
||||
const ctx = { params: Promise.resolve({ id: "f1" }) };
|
||||
|
||||
it("rewrites the text, re-encrypted, scoped to the caller's live facts", async () => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({
|
||||
id: "f1",
|
||||
category: "goal",
|
||||
source: "coach",
|
||||
updatedAt: new Date("2026-10-06T10:00:00Z"),
|
||||
} as never);
|
||||
const res = await callPatch(
|
||||
req("PATCH", { fact: " Wants 74 kg by December " }),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: { factEncrypted: Uint8Array };
|
||||
};
|
||||
expect(arg.where).toEqual({
|
||||
id: "f1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
source: { not: "proposed" },
|
||||
});
|
||||
expect(Buffer.from(arg.data.factEncrypted).toString()).toBe(
|
||||
"Wants 74 kg by December",
|
||||
);
|
||||
expect(Object.keys(arg.data)).toEqual(["factEncrypted"]);
|
||||
});
|
||||
|
||||
it("is a 404 for an unknown, foreign or proposed id", async () => {
|
||||
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callPatch(req("PATCH", { fact: "Something new" }), ctx);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses an empty or over-long fact, or extra keys", async () => {
|
||||
for (const body of [
|
||||
{ fact: "" },
|
||||
{ fact: "x".repeat(161) },
|
||||
{ fact: "fine text", category: "condition" },
|
||||
]) {
|
||||
const res = await callPatch(req("PATCH", body), ctx);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.coachFact.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts.
|
||||
* POST /api/insights/coach/facts — remember one of the caller's own Coach
|
||||
* messages (the remember button).
|
||||
* DELETE /api/insights/coach/facts — "forget all": soft-delete every active
|
||||
* fact for the caller.
|
||||
*
|
||||
* v1.11.1 — the GDPR / "forget what you know about me" surface for the
|
||||
* durable Coach facts the assistant extracts during conversations. Facts
|
||||
* are server-extracted, not user-authored, so this surface is read +
|
||||
* delete only (no POST / PATCH).
|
||||
* durable Coach facts. v1.41 — also the Coach's one memory list: each fact
|
||||
* says where it came from (`source`), when it was last used in a turn and
|
||||
* which message it came out of; the person edits a fact through
|
||||
* `PATCH …/facts/{id}`. A health fact still waiting for the person's answer
|
||||
* (`source: "proposed"`) is not listed: nothing the person has not agreed to
|
||||
* appears as something the Coach knows. "Forget all" clears those too.
|
||||
*
|
||||
* POST names a message, never text: the server reads the caller's own stored
|
||||
* message, files it with the medication / condition lexicon (anything else is
|
||||
* `context`) and saves it as `source: "user"`. The tap is the person's
|
||||
* confirmation, so a health message is saved too. A message the caller does
|
||||
* not own, or an assistant message, is a 404.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller
|
||||
* can only ever see or clear their own facts. The fact text is decrypted
|
||||
@@ -20,16 +31,35 @@
|
||||
* Erasure that depends on the feature being on is no erasure.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember";
|
||||
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { coachFactCreateSchema } from "@/lib/validations/coach-fact";
|
||||
|
||||
// The remember button is one tap per message; the limit only caps a runaway
|
||||
// client loop, like the plan mutations.
|
||||
const CREATE_RATE_LIMIT = 40;
|
||||
const CREATE_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rows = await prisma.coachFact.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
source: { not: PROPOSED_FACT_SOURCE },
|
||||
},
|
||||
// Highest-confidence first, then newest — mirrors the injection
|
||||
// ordering so the management list reads in the same priority the
|
||||
// assistant actually weights the facts.
|
||||
@@ -39,7 +69,12 @@ export const GET = apiHandler(async () => {
|
||||
category: true,
|
||||
factEncrypted: true,
|
||||
confidence: true,
|
||||
source: true,
|
||||
sourceConversationId: true,
|
||||
sourceMessageId: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,7 +83,12 @@ export const GET = apiHandler(async () => {
|
||||
category: string;
|
||||
text: string;
|
||||
confidence: number;
|
||||
source: string;
|
||||
sourceConversationId: string | null;
|
||||
sourceMessageId: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -65,7 +105,12 @@ export const GET = apiHandler(async () => {
|
||||
category: row.category,
|
||||
text,
|
||||
confidence: row.confidence,
|
||||
source: row.source,
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
sourceMessageId: row.sourceMessageId,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +122,61 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({ facts });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// Saving to the Coach's memory is Coach use, like confirming a plan; it is
|
||||
// not model work, so no AI gate.
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`coach-facts:create:${user.id}`,
|
||||
CREATE_RATE_LIMIT,
|
||||
CREATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachFactCreateSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const remembered = await rememberMessageAsFact({
|
||||
userId: user.id,
|
||||
messageId: parsed.data.messageId,
|
||||
});
|
||||
if (!remembered) {
|
||||
// Indistinguishable from "owned by someone else" — never reveal which.
|
||||
return apiError("Message not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.facts.created" },
|
||||
meta: { category: remembered.category, created: remembered.created },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
fact: {
|
||||
id: remembered.id,
|
||||
category: remembered.category,
|
||||
text: remembered.text,
|
||||
source: remembered.source,
|
||||
},
|
||||
created: remembered.created,
|
||||
},
|
||||
remembered.created ? 201 : 200,
|
||||
);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
|
||||
Reference in new issue
Block a user