mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
9fe5673251
commit
5b75184649
32 files changed
+2648
-423
No files matched your search
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user