feat(coach,admin): finish the self-context and invite surfaces end to end

Invites move into their own admin section with a real ledger: status
badges, expiry, uses, a redemption history of who joined when (new
redemption table, revocation is now soft so history survives), and an
honest one-time link/QR moment. The assistant self-context grows
structured fields for conditions, allergies and focus — encrypted like
the free text — merges age and gender from the profile into one fenced
prompt block, and asks up to three targeted follow-up questions that
surface as tappable chips above the coach composer. A fourth nudge
trigger gently suggests a check-up when the self-context is stale and
the coach is actually in use.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:18:48 +02:00
1 parent 9fe5673251
commit 5b75184649
32 files changed
+2648 -423

No files matched your search

+18 -12
View File
@@ -1,11 +1,14 @@
/**
* DELETE /api/admin/invites/{id} — revoke a registration invite.
*
* v1.15.20 — hard delete: an invite row carries no health data and no
* plaintext secret, and the audit log already records mint + revoke, so
* keeping a tombstone would only grow the table. Idempotent — deleting
* an unknown id returns `{ deleted: false }` rather than 404 so a
* double-tap in the admin UI never surfaces an error.
* v1.16.0 — soft revocation (`revokedAt`), replacing the v1.15.20 hard
* delete: the admin table now renders redemption history per invite,
* and a deleted row would erase who was admitted through it. The
* consume path refuses a revoked invite like an expired one.
*
* Idempotent — revoking an unknown or already-revoked id returns
* `{ revoked: false }` rather than 404 so a double-tap in the admin UI
* never surfaces an error.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
@@ -20,11 +23,14 @@ export const DELETE = apiHandler(
const { user } = await requireAdmin();
const { id } = await params;
const { count } = await prisma.inviteToken.deleteMany({ where: { id } });
const deleted = count > 0;
const { count } = await prisma.inviteToken.updateMany({
where: { id, revokedAt: null },
data: { revokedAt: new Date() },
});
const revoked = count > 0;
if (deleted) {
await auditLog("admin.invite.deleted", {
if (revoked) {
await auditLog("admin.invite.revoked", {
userId: user.id,
ipAddress: getClientIp(req),
details: { inviteId: id },
@@ -32,10 +38,10 @@ export const DELETE = apiHandler(
}
annotate({
action: { name: "admin.invite.deleted" },
meta: { deleted },
action: { name: "admin.invite.revoked" },
meta: { revoked },
});
return apiSuccess({ deleted });
return apiSuccess({ revoked });
},
);
+11
View File
@@ -40,10 +40,21 @@ export const GET = apiHandler(async () => {
createdAt: true,
expiresAt: true,
usedAt: true,
revokedAt: true,
uses: true,
maxUses: true,
creator: { select: { id: true, username: true } },
consumer: { select: { id: true, username: true } },
// v1.16.0 — full per-signup history. `consumer` only carries the
// LAST account; multi-use invites list every admitted signup here.
redemptions: {
select: {
id: true,
redeemedAt: true,
user: { select: { id: true, username: true, email: true } },
},
orderBy: { redeemedAt: "desc" },
},
},
orderBy: { createdAt: "desc" },
});
@@ -0,0 +1,72 @@
/**
* GET /api/coach/about-me/questions — pending clarifying questions.
* DELETE /api/coach/about-me/questions — dismiss one (body
* `{ question }`) or all (empty body / `{}`).
*
* v1.16.0 — the Coach composer renders the pending questions as
* tappable suggestion chips. Tapping a chip inserts the question into
* the composer AND dismisses it here; the small ✕ on a chip dismisses
* without inserting. The questions are user-visible suggestion text
* only — stored encrypted, scoped to the caller, never interpreted
* server-side beyond exact-match removal.
*/
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
getPendingQuestionsForUser,
PENDING_QUESTION_MAX_CHARS,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
const dismissSchema = z.object({
/** Exact question text to dismiss. Omitted = dismiss all. */
question: z.string().max(PENDING_QUESTION_MAX_CHARS).optional(),
});
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const questions = await getPendingQuestionsForUser(user.id);
annotate({
action: { name: "coach.about_me.questions.get" },
meta: { count: questions.length },
});
return apiSuccess({ questions });
});
export const DELETE = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = dismissSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const current = await getPendingQuestionsForUser(user.id);
const remaining =
parsed.data.question === undefined
? []
: current.filter((q) => q !== parsed.data.question);
await setPendingQuestionsForUser(user.id, remaining);
annotate({
action: { name: "coach.about_me.questions.dismissed" },
meta: { before: current.length, after: remaining.length },
});
return apiSuccess({ questions: remaining });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+130 -49
View File
@@ -1,21 +1,28 @@
/**
* GET /api/coach/about-me — the caller's "about me" self-description.
* PUT /api/coach/about-me — write (or clear) the self-description.
* GET /api/coach/about-me — the caller's structured self-context.
* PUT /api/coach/about-me — write (or clear) the self-context.
*
* v1.15.20 — the user-authored context the Coach system prompt and the
* daily briefing inject as a delimited, user-provided block (see
* `src/lib/ai/coach/about-me.ts`). Free text, hard-capped at 4 000 chars,
* encrypted at rest through the shared Bytes codec. An empty / whitespace
* PUT clears the text (the row is kept with a NULL payload so `updatedAt`
* documents the deletion instant).
* v1.15.20 — free-text "about me", encrypted at rest.
* v1.16.0 — extended with three structured fields (conditions,
* allergies, coach focus; each ≤500 chars, encrypted) and the
* clarifying-questions loop: after a save the server derives up to 3
* follow-up questions (AI when a provider + the daily Coach budget
* allow, deterministic hints otherwise) and persists them encrypted;
* the Coach composer renders them as tappable chips
* (`/api/coach/about-me/questions`).
*
* Plain text end to end: the client renders the value as a React text
* child only — no markdown library exists in the tree and none may be
* added (XSS posture, see the contributor notes).
* PUT field semantics: `aboutMe` is required (empty string clears).
* The structured fields are optional — omitted leaves the stored value
* untouched, an empty string clears it — so older clients that only
* send `aboutMe` keep working unchanged.
*
* Plain text end to end: the client renders every value as a React
* text child only — no markdown library exists in the tree and none
* may be added (XSS posture, see the contributor notes).
*
* Ownership: the user id always comes from `requireAuth()`; the body
* carries only the text. Audit rows never contain the text itself — only
* its length — because it is free-form health prose.
* carries only the text. Audit rows never contain the text itself —
* only per-field lengths — because it is free-form health prose.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -29,11 +36,15 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
decryptFromBytes,
encryptToBytes,
} from "@/lib/ai/coach/bytes-codec";
getPendingQuestionsForUser,
getSelfContextForUser,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
import {
ABOUT_ME_FIELD_MAX_CHARS,
ABOUT_ME_MAX_CHARS,
aboutMePutSchema,
} from "@/lib/validations/about-me";
@@ -44,32 +55,35 @@ const PUT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const row = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { aboutMeEncrypted: true, updatedAt: true },
});
let aboutMe: string | null = null;
if (row?.aboutMeEncrypted) {
try {
aboutMe = decryptFromBytes(row.aboutMeEncrypted);
} catch {
// Fail closed per row — never surface ciphertext. The user sees an
// empty editor and can re-write; the stale ciphertext is replaced
// on the next save.
aboutMe = null;
}
}
const [ctx, pendingQuestions, row] = await Promise.all([
getSelfContextForUser(user.id),
getPendingQuestionsForUser(user.id),
prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
}),
]);
annotate({
action: { name: "coach.about_me.get" },
meta: { present: aboutMe !== null },
meta: {
present: ctx.aboutMe !== null,
structured:
ctx.conditions !== null ||
ctx.allergies !== null ||
ctx.coachFocus !== null,
},
});
return apiSuccess({
aboutMe,
aboutMe: ctx.aboutMe,
conditions: ctx.conditions,
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
updatedAt: row?.updatedAt?.toISOString() ?? null,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
});
});
@@ -101,42 +115,109 @@ export const PUT = apiHandler(async (req: Request) => {
const text = parsed.data.aboutMe.trim();
const cleared = text.length === 0;
const payload = cleared ? null : encryptToBytes(text);
// Field-by-field data assembly (no mass assignment): omitted fields
// never appear in the update object, so they stay untouched.
const encryptOptional = (
raw: string | undefined,
): Uint8Array<ArrayBuffer> | null | undefined => {
if (raw === undefined) return undefined;
const value = raw.trim();
return value.length === 0 ? null : encryptToBytes(value);
};
const update: {
aboutMeEncrypted: Uint8Array<ArrayBuffer> | null;
conditionsEncrypted?: Uint8Array<ArrayBuffer> | null;
allergiesEncrypted?: Uint8Array<ArrayBuffer> | null;
coachFocusEncrypted?: Uint8Array<ArrayBuffer> | null;
} = {
aboutMeEncrypted: cleared ? null : encryptToBytes(text),
};
const conditionsPayload = encryptOptional(parsed.data.conditions);
if (conditionsPayload !== undefined) {
update.conditionsEncrypted = conditionsPayload;
}
const allergiesPayload = encryptOptional(parsed.data.allergies);
if (allergiesPayload !== undefined) {
update.allergiesEncrypted = allergiesPayload;
}
const coachFocusPayload = encryptOptional(parsed.data.coachFocus);
if (coachFocusPayload !== undefined) {
update.coachFocusEncrypted = coachFocusPayload;
}
const fieldLengths: Record<string, number> = {
aboutMe: text.length,
...(parsed.data.conditions !== undefined
? { conditions: parsed.data.conditions.trim().length }
: {}),
...(parsed.data.allergies !== undefined
? { allergies: parsed.data.allergies.trim().length }
: {}),
...(parsed.data.coachFocus !== undefined
? { coachFocus: parsed.data.coachFocus.trim().length }
: {}),
};
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: {
userId: user.id,
aboutMeEncrypted: payload,
},
update: {
aboutMeEncrypted: payload,
},
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
// The audit row carries the length only — the text is free-form health
// prose and must not land in the (plaintext) audit details.
// Read back the effective state (covers omitted fields) and derive
// the clarifying questions. An entirely empty self-context clears
// the pending questions instead of generating new ones.
const ctx = await getSelfContextForUser(user.id);
const isEmpty =
ctx.aboutMe === null &&
ctx.conditions === null &&
ctx.allergies === null &&
ctx.coachFocus === null;
let pendingQuestions: string[] = [];
let questionsSource: "ai" | "fallback" | "none" = "none";
if (isEmpty) {
await setPendingQuestionsForUser(user.id, null);
} else {
const derived = await deriveClarifyingQuestions(user.id, ctx, user.locale);
pendingQuestions = derived.questions;
questionsSource = derived.source;
await setPendingQuestionsForUser(user.id, pendingQuestions);
}
// The audit row carries per-field lengths only — the text is
// free-form health prose and must not land in the plaintext audit
// details.
await auditLog(
cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
{
userId: user.id,
ipAddress: getClientIp(req),
details: { length: text.length },
details: { ...fieldLengths },
},
);
annotate({
action: {
name: cleared ? "coach.about_me.cleared" : "coach.about_me.updated",
name: isEmpty ? "coach.about_me.cleared" : "coach.about_me.updated",
},
meta: {
length: text.length,
questions_source: questionsSource,
questions_count: pendingQuestions.length,
},
meta: { length: text.length },
});
return apiSuccess({
aboutMe: cleared ? null : text,
aboutMe: ctx.aboutMe,
conditions: ctx.conditions,
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
updatedAt: row.updatedAt.toISOString(),
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
});
});
+4 -2
View File
@@ -64,7 +64,7 @@ import {
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getAboutMeForUser } from "@/lib/ai/coach/about-me";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
@@ -318,7 +318,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// AI) rides the system prompt as a delimited, user-provided context
// block. Fail-open: a missing / undecryptable text yields null and the
// prompt is byte-identical to the pre-feature one.
const aboutMe = await getAboutMeForUser(userId);
// v1.16.0 — composed self-context: structured questionnaire fields
// plus age/gender merged in from the User profile.
const aboutMe = await getSelfContextTextForUser(userId, locale);
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe);
const allTurns: CoachTurn[] = [
...priorTurns,
+2 -2
View File
@@ -28,7 +28,7 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import {
buildAboutMeInsightBlock,
getAboutMeForUser,
getSelfContextTextForUser,
} from "@/lib/ai/coach/about-me";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
@@ -551,7 +551,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.15.20 — fold the user-authored "about me" self-description
// (Settings → AI) into the briefing as a delimited, user-provided
// SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing.
const aboutMe = await getAboutMeForUser(userId);
const aboutMe = await getSelfContextTextForUser(userId, locale);
if (aboutMe) {
userPrompt += buildAboutMeInsightBlock(aboutMe, locale);
}