mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge remote-tracking branch 'origin/main' into fix/private-notification-origins
# Conflicts: # src/app/api/settings/ntfy/route.ts # src/app/api/settings/webhook/route.ts
This commit is contained in:
commit
71175290af
255 files changed
+12654
-1141
No files matched your search
@@ -259,6 +259,10 @@ function sourceClient() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
// The setup answers. Absent for this fixture's account, which is the
|
||||
// ordinary case: the export has to answer for a record that never entered
|
||||
// the flow without inventing one.
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
// The score as it was SHOWN on a local day. Not recomputable: today's
|
||||
// number always comes from today's rows, so once the readings behind this
|
||||
// day moved, nothing can reproduce what it said.
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
|
||||
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
|
||||
import { restoreOnboardingData } from "@/lib/export/onboarding-backup";
|
||||
import { restoreVisitsData } from "@/lib/export/visits-backup";
|
||||
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
|
||||
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
|
||||
@@ -111,6 +112,7 @@ interface RestoreResponse {
|
||||
correlationPatterns: number;
|
||||
intradayProfiles: number;
|
||||
healthScoreRecords: number;
|
||||
onboardingRecords: number;
|
||||
practitioners: number;
|
||||
encounters: number;
|
||||
encounterLinks: number;
|
||||
@@ -1377,6 +1379,14 @@ const handler = apiHandler(
|
||||
payload,
|
||||
);
|
||||
|
||||
// The needs-based setup answers. Both ends live in
|
||||
// `src/lib/export/onboarding-backup.ts`, same as the three above.
|
||||
const onboardingCleared = await restoreOnboardingData(
|
||||
tx,
|
||||
ownerId,
|
||||
payload,
|
||||
);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1979,6 +1989,7 @@ const handler = apiHandler(
|
||||
correlationPatterns: profileCleared.correlationPatterns,
|
||||
intradayProfiles: intradayCleared.intradayProfiles,
|
||||
healthScoreRecords: healthScoreCleared.healthScoreRecords,
|
||||
onboardingRecords: onboardingCleared.onboardingRecords,
|
||||
practitioners: visitsCleared.practitioners,
|
||||
encounters: visitsCleared.encounters,
|
||||
encounterLinks: visitsCleared.encounterLinks,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* `GET /api/admin/backups` — the off-host leg.
|
||||
*
|
||||
* The rows the weekly pass writes into this database were already covered by
|
||||
* the page that lists them. What was not covered is the question this response
|
||||
* now answers: per account, when did an encrypted copy last reach the
|
||||
* operator's bucket, and is that inside what the nightly schedule promises.
|
||||
*
|
||||
* The bucket is never touched here, and that is the assertion that matters —
|
||||
* the verdicts come from the ledger the worker writes, so an operator whose
|
||||
* worker holds no listing grant on the bucket still gets a true answer.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/jobs/job-failures", () => ({
|
||||
readLastQueueRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const queryRawMock = vi.fn();
|
||||
const findManyMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
|
||||
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
const configuredMock = vi.fn(() => true);
|
||||
vi.mock("@/lib/jobs/offhost-backup", () => ({
|
||||
offhostBackupConfigured: () => configuredMock(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import type { BackupsList } from "@/types/backups";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
async function read(): Promise<BackupsList> {
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data as BackupsList;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configuredMock.mockReturnValue(true);
|
||||
queryRawMock.mockResolvedValue([]);
|
||||
findManyMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
it("rates every account against the nightly schedule without listing the bucket", async () => {
|
||||
const now = Date.now();
|
||||
findManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "account-one",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 3 * HOUR),
|
||||
lastSuccessAt: new Date(now - 3 * HOUR),
|
||||
// The column is BigInt — an object may be 80 GB — and the wire type
|
||||
// is a plain number.
|
||||
sizeBytes: BigInt(4096),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "account-two",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 31 * HOUR),
|
||||
lastSuccessAt: new Date(now - 31 * HOUR),
|
||||
sizeBytes: BigInt(8192),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "u3",
|
||||
username: "account-three",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 100 * HOUR),
|
||||
lastSuccessAt: new Date(now - 100 * HOUR),
|
||||
sizeBytes: BigInt(128),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "u4",
|
||||
username: "account-four",
|
||||
// Walked by a run, nothing landed.
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(now - 2 * HOUR),
|
||||
lastSuccessAt: null,
|
||||
sizeBytes: null,
|
||||
},
|
||||
},
|
||||
// No run has recorded this one at all — the state of every account on
|
||||
// the morning the ledger ships.
|
||||
{ id: "u5", username: "account-five", offhostBackupState: null },
|
||||
]);
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(body.offhost.configured).toBe(true);
|
||||
expect(body.offhost.periodHours).toBe(24);
|
||||
// Worst first, alphabetical within a verdict. The database hands them
|
||||
// back by username; the one account that needs attention must not be
|
||||
// wherever the alphabet leaves it on a hundred-account cohort.
|
||||
expect(
|
||||
body.offhost.rows.map((row) => [row.username, row.freshness]),
|
||||
).toEqual([
|
||||
["account-three", "stale"],
|
||||
["account-two", "due"],
|
||||
["account-four", "never"],
|
||||
["account-five", "unknown"],
|
||||
["account-one", "fresh"],
|
||||
]);
|
||||
const byName = new Map(body.offhost.rows.map((row) => [row.username, row]));
|
||||
expect(byName.get("account-one")?.sizeBytes).toBe(4096);
|
||||
expect(typeof byName.get("account-one")?.sizeBytes).toBe("number");
|
||||
expect(byName.get("account-one")?.ageHours).toBe(3);
|
||||
expect(byName.get("account-four")?.lastSuccessAt).toBeNull();
|
||||
expect(byName.get("account-four")?.sizeBytes).toBeNull();
|
||||
expect(byName.get("account-four")?.lastAttemptAt).not.toBeNull();
|
||||
// `unknown` is the honest one: the ledger has no history for this
|
||||
// account, which is not the same claim as "the bucket is empty".
|
||||
expect(byName.get("account-five")?.lastAttemptAt).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when neither the environment nor the ledger knows of any upload", async () => {
|
||||
configuredMock.mockReturnValue(false);
|
||||
findManyMock.mockResolvedValue([
|
||||
{ id: "u1", username: "account-one", offhostBackupState: null },
|
||||
]);
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(body.offhost).toEqual({
|
||||
configured: false,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a ledger row outrank this process's own environment", async () => {
|
||||
// A hand-rolled split deployment can give BACKUP_S3_* to the worker and
|
||||
// not to the web process. The rows are proof uploads are happening;
|
||||
// saying "nothing leaves this host" over them would be this process
|
||||
// reporting on a job it cannot see.
|
||||
configuredMock.mockReturnValue(false);
|
||||
findManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "account-one",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(),
|
||||
lastSuccessAt: new Date(),
|
||||
sizeBytes: BigInt(4096),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(body.offhost.configured).toBe(true);
|
||||
expect(body.offhost.rows.map((row) => row.freshness)).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("never ships a credential or an object key with the verdicts", async () => {
|
||||
findManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "account-one",
|
||||
offhostBackupState: {
|
||||
lastAttemptAt: new Date(),
|
||||
lastSuccessAt: new Date(),
|
||||
sizeBytes: BigInt(1),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const body = await read();
|
||||
|
||||
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
|
||||
"ageHours",
|
||||
"freshness",
|
||||
"lastAttemptAt",
|
||||
"lastSuccessAt",
|
||||
"sizeBytes",
|
||||
"userId",
|
||||
"username",
|
||||
]);
|
||||
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@
|
||||
* Without those two the page cannot tell a working backup from one that
|
||||
* stopped six weeks ago — every row it lists has a perfectly ordinary
|
||||
* timestamp either way.
|
||||
*
|
||||
* And alongside both, the off-host leg: one row per account saying when this
|
||||
* host last put that account's encrypted copy in the operator's bucket and how
|
||||
* stale that is against the nightly schedule. It comes from the ledger the
|
||||
* worker writes, never from a bucket listing — the answer has to hold on a host
|
||||
* whose worker holds no listing grant on the bucket.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
@@ -21,10 +27,20 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
|
||||
import { readLastQueueRun } from "@/lib/jobs/job-failures";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
type OffhostBackupFreshness,
|
||||
} from "@/lib/jobs/offhost-backup-freshness";
|
||||
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
|
||||
// so callers (in particular `components/admin/backups-section.tsx`) don't
|
||||
// have to reach across the component → route-handler layer boundary.
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import type {
|
||||
BackupRow,
|
||||
BackupsList,
|
||||
OffhostAccountRow,
|
||||
} from "@/types/backups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -67,6 +83,79 @@ export const GET = apiHandler(async () => {
|
||||
createdAt: row.created_at.toISOString(),
|
||||
}));
|
||||
|
||||
// The off-host leg, per account, read from the ledger the nightly worker
|
||||
// writes rather than from the bucket. The page must be able to answer "which
|
||||
// account has no recent copy off-host" on a host whose worker holds the
|
||||
// three grants the runbook documents and no listing grant at all, and a
|
||||
// listing call from a page render would put the operator's credentials on
|
||||
// the request path of every page view.
|
||||
const now = new Date();
|
||||
const offhostAccounts = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
offhostBackupState: {
|
||||
select: {
|
||||
lastAttemptAt: true,
|
||||
lastSuccessAt: true,
|
||||
sizeBytes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { username: "asc" },
|
||||
});
|
||||
|
||||
// Evidence beats this process's environment. `offhostBackupConfigured()`
|
||||
// reads the variables of whichever process serves this request, and a
|
||||
// hand-rolled split that gives `BACKUP_S3_*` only to the worker would make
|
||||
// the web process say "nothing leaves this host" over a ledger full of
|
||||
// fresh rows. A row in that table is proof an upload happened; no
|
||||
// environment read can outrank it.
|
||||
const configured =
|
||||
offhostBackupConfigured() ||
|
||||
offhostAccounts.some((account) => account.offhostBackupState !== null);
|
||||
|
||||
const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => {
|
||||
const state = account.offhostBackupState;
|
||||
const verdict = classifyOffhostBackup({
|
||||
lastAttemptAt: state?.lastAttemptAt ?? null,
|
||||
lastSuccessAt: state?.lastSuccessAt ?? null,
|
||||
now,
|
||||
});
|
||||
return {
|
||||
userId: account.id,
|
||||
username: account.username,
|
||||
lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null,
|
||||
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
|
||||
// BigInt column -> number on the wire. The value is bytes of one
|
||||
// object, capped by the uploader at 80 GB, so it is nowhere near
|
||||
// Number.MAX_SAFE_INTEGER and JSON has no BigInt.
|
||||
sizeBytes:
|
||||
state?.sizeBytes === null || state?.sizeBytes === undefined
|
||||
? null
|
||||
: Number(state.sizeBytes),
|
||||
ageHours: verdict.ageHours,
|
||||
freshness: verdict.freshness,
|
||||
};
|
||||
});
|
||||
|
||||
// Worst first. Sorting by username puts the one account that needs
|
||||
// attention wherever the alphabet happens to leave it, which on a
|
||||
// hundred-account cohort is the same "one bad row hides in the crowd"
|
||||
// problem this card was built to end, moved out of a count and into a list.
|
||||
const FRESHNESS_ORDER: Record<OffhostBackupFreshness, number> = {
|
||||
stale: 0,
|
||||
due: 1,
|
||||
never: 2,
|
||||
unknown: 3,
|
||||
fresh: 4,
|
||||
};
|
||||
offhostRows.sort(
|
||||
(a, b) =>
|
||||
FRESHNESS_ORDER[a.freshness] - FRESHNESS_ORDER[b.freshness] ||
|
||||
a.username.localeCompare(b.username),
|
||||
);
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows: list,
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
@@ -77,8 +166,15 @@ export const GET = apiHandler(async () => {
|
||||
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
|
||||
.map((row) => row.created_at),
|
||||
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
|
||||
now: new Date(),
|
||||
now,
|
||||
}),
|
||||
offhost: {
|
||||
configured,
|
||||
periodHours: OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
// An unconfigured host has nothing to list, and the card says so rather
|
||||
// than painting an empty table that reads as "no problems".
|
||||
rows: configured ? offhostRows : [],
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(payload);
|
||||
|
||||
@@ -16,9 +16,32 @@ import {
|
||||
invalidateServerDefaultTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { summariseGlitchtipDelivery } from "@/lib/monitoring/glitchtip-delivery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The delivery verdict, flattened onto the settings payload. */
|
||||
function summariseGlitchtipDeliveryFields(state: {
|
||||
lastOkAt?: Date | null;
|
||||
lastFailureAt?: Date | null;
|
||||
lastFailureReason?: string | null;
|
||||
}) {
|
||||
const summary = summariseGlitchtipDelivery(
|
||||
{
|
||||
lastOkAt: state.lastOkAt ?? null,
|
||||
lastFailureAt: state.lastFailureAt ?? null,
|
||||
lastFailureReason: state.lastFailureReason ?? null,
|
||||
},
|
||||
new Date(),
|
||||
);
|
||||
return {
|
||||
glitchtipReportsDelivering: summary.reportsDelivering,
|
||||
glitchtipEverDelivered: summary.everDelivered,
|
||||
glitchtipLastFailureReason: summary.lastFailureReason,
|
||||
glitchtipDeliveryWindowHours: summary.windowHours,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.get" } });
|
||||
@@ -48,6 +71,15 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
|
||||
glitchtipDsn: settings?.glitchtipDsn ?? null,
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
// The delivery outcome, not the intent, and resolved here rather than on
|
||||
// the client: a DSN that is set and parses says a target was typed, only
|
||||
// this says something left the host, and whether it left recently enough
|
||||
// is a question about a clock that belongs with the ledger.
|
||||
...summariseGlitchtipDeliveryFields({
|
||||
lastOkAt: settings?.glitchtipLastOkAt ?? null,
|
||||
lastFailureAt: settings?.glitchtipLastFailureAt ?? null,
|
||||
lastFailureReason: settings?.glitchtipLastFailureReason ?? null,
|
||||
}),
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
// Document vault limits (per-file cap + per-user quota default). BigInt
|
||||
@@ -274,6 +306,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
...summariseGlitchtipDeliveryFields({
|
||||
lastOkAt: settings.glitchtipLastOkAt,
|
||||
lastFailureAt: settings.glitchtipLastFailureAt,
|
||||
lastFailureReason: settings.glitchtipLastFailureReason,
|
||||
}),
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
@@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = generateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.appSettings.findUnique({
|
||||
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
type AITestOverride,
|
||||
} from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (error) return error;
|
||||
const parsed = aiTestOverrideSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid override payload", 422);
|
||||
return apiValidationError(
|
||||
"Invalid override payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
override = parsed.data;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -84,7 +86,17 @@ async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
const { user, actor } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("identifier required", 422);
|
||||
return apiValidationError(
|
||||
"identifier required",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const identifier = parsed.data.identifier;
|
||||
|
||||
@@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid credentials", 422);
|
||||
return apiValidationError(
|
||||
"Invalid credentials",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
@@ -15,8 +15,13 @@
|
||||
* helper. The Coach prompt builder + snapshot builder both read this
|
||||
* row on every turn — there's no caching layer to invalidate.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -57,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "coach-prefs.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "coach-prefs.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
|
||||
@@ -74,7 +81,17 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.me.coach-prefs.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "coach-prefs.body.invalid_shape");
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"coach-prefs.body.invalid_shape",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "coach-prefs.body.invalid_shape" },
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the canonical defaulted form so the column shape stays
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 422, {
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "cycle-prefs.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
@@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "disable-coach.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "disable-coach.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "disable-coach.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
const parsed = mfaDisableSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const factor = await verifyMfaFactor(
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
const parsed = totpConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid code", 422);
|
||||
return apiValidationError(
|
||||
"Invalid code",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Decrypt is fail-closed — a missing/rotated key throws rather than
|
||||
|
||||
@@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -39,7 +41,11 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = mfaWebauthnRenameSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
|
||||
@@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { challengeId, credential, name } = parsed.data;
|
||||
|
||||
|
||||
@@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/modules", {
|
||||
method: "PATCH",
|
||||
@@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "modules.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
* carries it. The semantics are a DISABLED allowlist: a key set to
|
||||
* `false` disables that module; `true` (or absence) leaves it enabled.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "modules.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "modules.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
|
||||
|
||||
@@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
// 400, not the 422 this used to pin. A body that will not parse is a
|
||||
// client-side serialisation fault, and `safeJson` plus roughly two hundred
|
||||
// and twenty other routes have always answered 400 for it; the dotted token
|
||||
// moved to `meta.errorCode`, which is where a machine code belongs.
|
||||
it("rejects malformed JSON with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
|
||||
@@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
});
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Invalid JSON body",
|
||||
meta: { errorCode: "notification-prefs.body.invalid_json" },
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* extra round-trip. Rate-limit is intentionally generous (60/min) —
|
||||
* matches the disable-coach route which is the closest analogue.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "notification-prefs.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "notification-prefs.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
|
||||
* outside that route ever read.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
@@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "report-selection.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "report-selection.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = savedReportProfileSchema.safeParse(body ?? {});
|
||||
@@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.body.invalid_shape");
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"report-selection.body.invalid_shape",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "report-selection.body.invalid_shape" },
|
||||
);
|
||||
}
|
||||
|
||||
const minted = selectionFromRequest(parsed.data);
|
||||
@@ -66,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.me.report-selection.put.invalid" },
|
||||
meta: { unknownLeaves: minted.error.unknownLeaves },
|
||||
});
|
||||
throw new HttpError(422, "report-selection.leaves.unknown");
|
||||
// Not a Zod refusal, so there is no issue list to carry — but the same
|
||||
// rule applies: a sentence in `error`, the machine token in
|
||||
// `meta.errorCode`, and the leaves that were refused named beside it.
|
||||
return apiError(
|
||||
"Report selection names leaves this build does not know",
|
||||
422,
|
||||
{
|
||||
errorCode: "report-selection.leaves.unknown",
|
||||
unknownLeaves: minted.error.unknownLeaves,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Persist the canonical ordering rather than the caller's, so two clients
|
||||
|
||||
+102
-29
@@ -54,6 +54,14 @@ import {
|
||||
resolveModuleMap,
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
import {
|
||||
emptyOnboardingNeeds,
|
||||
readHeldUnitPreferences,
|
||||
} from "@/lib/onboarding/needs";
|
||||
import {
|
||||
loadOnboardingRecordRow,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
@@ -107,35 +115,63 @@ export const GET = apiHandler(async () => {
|
||||
// no switch at all, and the reason the own-record payload is untouched.
|
||||
const sections = accountAccess.active?.sections ?? null;
|
||||
|
||||
const [record, cycleProfile, resolvedModules, moduleAvailability] =
|
||||
await Promise.all([
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
recordId === user.id
|
||||
? Promise.resolve({ gender: user.gender })
|
||||
: prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: { gender: true },
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(recordId),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
// Server-WIDE, so it is the same map for every record and is not
|
||||
// re-scoped here.
|
||||
getOperatorModuleAvailability(),
|
||||
]);
|
||||
const [
|
||||
record,
|
||||
cycleProfile,
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
onboardingRow,
|
||||
] = await Promise.all([
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
recordId === user.id
|
||||
? Promise.resolve({
|
||||
gender: user.gender,
|
||||
glucoseUnit: user.glucoseUnit,
|
||||
unitPreference: user.unitPreference,
|
||||
})
|
||||
: prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: {
|
||||
gender: true,
|
||||
// v1.39 (C1) — the record's own unit columns, for the setup
|
||||
// flow's "never re-ask a value the account holds" rule.
|
||||
glucoseUnit: true,
|
||||
unitPreference: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(recordId),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
// Server-WIDE, so it is the same map for every record and is not
|
||||
// re-scoped here.
|
||||
getOperatorModuleAvailability(),
|
||||
// v1.39 (C1) — the needs-based setup state for the RECORD, like the two
|
||||
// module fields above and for the same reason: it says what that record
|
||||
// tracks, and its only consumer reads it beside that record's reading
|
||||
// count, medication count and integration state. Ordering a record's rows
|
||||
// by somebody else's answers is the one thing this field must not do.
|
||||
//
|
||||
// The three WRITE routes stay actor-only (`requireAuth()` refuses outright
|
||||
// under a switch) until a guardian's route into a managed record's setup
|
||||
// arrives with the rest of that record's configuration (#939 / C2). A read
|
||||
// that is wider than the write is the honest shape here: the storage is
|
||||
// keyed by record already, and a delegate seeing the record's own setup
|
||||
// state is what makes the dashboard they are looking at coherent.
|
||||
loadOnboardingRecordRow(prisma, recordId),
|
||||
]);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
|
||||
@@ -149,6 +185,25 @@ export const GET = apiHandler(async () => {
|
||||
// exactly as it was, and `moduleAccess` is the same answer with the reason
|
||||
// attached — the record's own switch, the grant's edge, or the operator's.
|
||||
// Every client that only wants "paint it or not" keeps reading the boolean.
|
||||
// v1.39 (C1) — the published steps carry the Q6 resolution: a record whose
|
||||
// account already holds both unit preferences reads `units` as `done`, so
|
||||
// the flow never re-asks a value the account holds.
|
||||
const recordOnboarding = toOnboardingStateDto(
|
||||
onboardingRow,
|
||||
readHeldUnitPreferences({
|
||||
glucoseUnit: record?.glucoseUnit ?? null,
|
||||
unitPreference: record?.unitPreference ?? null,
|
||||
}),
|
||||
);
|
||||
// The answers name the domains the record tracks, which is exactly what the
|
||||
// module map below is masked for. A SCOPED grant therefore gets the shape of
|
||||
// the flow — how far it got, what it ended on — with the answers emptied;
|
||||
// an unscoped grant, and the record's own session, get them whole.
|
||||
const onboarding =
|
||||
sections === null
|
||||
? recordOnboarding
|
||||
: { ...recordOnboarding, needs: emptyOnboardingNeeds() };
|
||||
|
||||
const { modules, moduleAccess } = buildModuleDisclosure(
|
||||
resolvedModules,
|
||||
moduleAvailability,
|
||||
@@ -186,6 +241,24 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
// `steps` (the nine stable ids and their `pending | done | skipped`
|
||||
// status), `needs` (the answers as given), `completedAt` (the flow's OWN
|
||||
// completion, distinct from `onboardingCompletedAt` above, which keeps its
|
||||
// meaning as the first-run redirect's gate) and `firstResult` (the one
|
||||
// task the flow offered, and whether it produced its result).
|
||||
//
|
||||
// Record-scoped, like `modules` and `cycleTrackingEnabled`: it says what
|
||||
// the record tracks, and the checklist reads it beside that record's
|
||||
// counts. Under a scoped grant the answers are emptied for the reason the
|
||||
// module map is masked.
|
||||
//
|
||||
// Always present, never null: a record that never entered the flow reads
|
||||
// as empty answers with nine `pending` steps, so a client branches on the
|
||||
// step statuses rather than on the field's existence. A client that meets
|
||||
// a step id it does not know skips it — the list is the contract, not the
|
||||
// client's own copy of it.
|
||||
onboarding,
|
||||
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
|
||||
// when never acknowledged; the onboarding welcome gate reads this to
|
||||
// decide whether to require the acknowledgment before "Get started".
|
||||
|
||||
@@ -18,8 +18,14 @@
|
||||
* decides which source's rows the cached targets / derived / analytics
|
||||
* payloads were built from.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "source-priority.body.invalid_json");
|
||||
return apiError("Invalid JSON body", 400, {
|
||||
errorCode: "source-priority.body.invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = sourcePrioritySchema.safeParse(body ?? {});
|
||||
@@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
action: { name: "auth.me.source-priority.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "source-priority.body.invalid_shape");
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"source-priority.body.invalid_shape",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "source-priority.body.invalid_shape" },
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
* limited per IP on top of the per-ticket cap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { mfaTicket, method, code, rememberDevice } = parsed.data;
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data;
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
* `requireFreshMfa` step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = nativeHandoffTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
|
||||
@@ -23,7 +23,12 @@
|
||||
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = oidcNativeTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = stepUpOptionsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.method === "webauthn") {
|
||||
|
||||
@@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
return apiValidationError(
|
||||
"Invalid request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const method: StepUpMethod = parsed.data.method;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -95,7 +96,17 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetric: { findFirst: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
@@ -105,6 +106,16 @@ async function postCustomMetricEntry(
|
||||
// tracked value adds it back in the same diff: the argument for admitting it
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
|
||||
@@ -18,7 +18,12 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
|
||||
import {
|
||||
@@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid calendar query", 422, {
|
||||
errorCode: "cycle.calendar.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid calendar query",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "cycle.calendar.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
@@ -111,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Apple Watch wrist/skin temperature feeds the temperature-trend
|
||||
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
|
||||
// values; the engine derives the trailing-mean deviation itself.
|
||||
//
|
||||
// This is a measurements read on a route declared under `cycle`, and it
|
||||
// needs no section predicate for one reason: no value from it reaches the
|
||||
// response. It goes into `predictCycle` and what comes out the other side
|
||||
// is a shifted date. The crosstab next door reads the same channel and IS
|
||||
// fenced, because there the values are the answer.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
|
||||
import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
|
||||
@@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid history query", 422, {
|
||||
errorCode: "cycle.cycles.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid history query",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "cycle.cycles.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
@@ -94,11 +95,21 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -50,7 +51,7 @@ const WINDOW_DAYS = 365;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
const { user, grantId } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -68,6 +69,19 @@ export const GET = apiHandler(async () => {
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
// The crosstab is a cycle surface that reads two other sections. The phase
|
||||
// labels are cycle data; the outcome columns are not. `lutealAvg`,
|
||||
// `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration,
|
||||
// steps, weight, temperature, glucose and the mood score in display units,
|
||||
// and a grant scoped to the cycle alone was never consent for any of them.
|
||||
// So the two foreign channels are fenced at the query, not at the response:
|
||||
// a section the caller is outside of is never read, so nothing downstream
|
||||
// can leak it back — the rows simply do not exist, exactly as they do not
|
||||
// for a record with no measurements.
|
||||
const visible = await actingDomainVisibility(prisma, grantId);
|
||||
const seesMeasurements = visible("measurements");
|
||||
const seesMind = visible("mind");
|
||||
|
||||
const [
|
||||
cycles,
|
||||
dayLogRows,
|
||||
@@ -116,25 +130,28 @@ export const GET = apiHandler(async () => {
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
// The outcome metrics the phase contrast compares — soft-delete-scoped,
|
||||
// canonical-source deduped per day inside `metricDayMap`.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
|
||||
// for a caller outside `measurements`.
|
||||
seesMeasurements
|
||||
? prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
|
||||
measuredAt: {
|
||||
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})
|
||||
: [],
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
@@ -142,15 +159,18 @@ export const GET = apiHandler(async () => {
|
||||
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
|
||||
// Measurement row, so read it here and inject it into the crosstab as a
|
||||
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
// Skipped whole for a caller outside `mind`.
|
||||
seesMind
|
||||
? prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// The latest open cycle runs to the predicted next-period start so the
|
||||
@@ -239,6 +259,8 @@ export const GET = apiHandler(async () => {
|
||||
lagged_discovered: lagged.discovered.length,
|
||||
lagged_pairs_tested: lagged.pairsTested,
|
||||
symptom_patterns: symptomPatterns.length,
|
||||
measurements_visible: seesMeasurements,
|
||||
mind_visible: seesMind,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -67,7 +68,23 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({ symptoms });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
/**
|
||||
* Wrapped in `withIdempotency`: a custom symptom is minted from the client's
|
||||
* offline outbox under the same `Idempotency-Key`, and nothing else here would
|
||||
* catch the replay. The row's key is minted fresh per request
|
||||
* (`custom:<uuid>`), and the label it would duplicate lives in
|
||||
* `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be
|
||||
* put on it. A replay after a lost success response therefore wrote a SECOND
|
||||
* row carrying the same label, up to the fifty-row cap: a silent duplicate in
|
||||
* the person's own symptom vocabulary rather than a conflict anything refused.
|
||||
* The wrapper answers the first attempt's 201 instead, and the in-flight 409 it
|
||||
* defines is the only 409 this route can produce.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest]>(postCustomSymptom),
|
||||
);
|
||||
|
||||
async function postCustomSymptom(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log
|
||||
// writes the level admits need in order to say anything.
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
@@ -151,4 +168,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,13 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -93,9 +99,14 @@ export const POST = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = documentChatRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document chat request", 422, {
|
||||
errorCode: "documents.chat.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document chat request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.chat.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const { conversationId, message, locale: bodyLocale } = parsed.data;
|
||||
|
||||
@@ -231,9 +242,14 @@ export const GET = apiHandler(
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid chat history query", 422, {
|
||||
errorCode: "documents.chat.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid chat history query",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.chat.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// With a conversationId → that one thread's messages (must hold this document).
|
||||
|
||||
@@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
@@ -236,9 +238,14 @@ async function handleTextExtract(
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document text payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
|
||||
@@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
@@ -154,9 +156,14 @@ async function handleTextIndex(
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document text payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { tokenCount } = await upsertContentIndex({
|
||||
|
||||
@@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -143,9 +145,14 @@ export const PATCH = apiHandler(
|
||||
|
||||
const parsed = documentUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document update", 422, {
|
||||
errorCode: "documents.inbound.invalidUpdate",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document update",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.invalidUpdate",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.inboundDocument.findFirst({
|
||||
|
||||
@@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
@@ -148,9 +150,14 @@ async function handleTextSuggest(
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document text payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
|
||||
@@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
@@ -297,9 +299,14 @@ async function handleTextSummary(
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document text payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// `mode=text` over posted OCR text is a pure echo — the text IS the
|
||||
|
||||
@@ -33,7 +33,13 @@ import {
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashQueryTokens } from "@/lib/documents/content-index";
|
||||
@@ -236,9 +242,14 @@ async function processUpload(
|
||||
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document metadata", 422, {
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid document metadata",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
@@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
kind: kinds.length > 0 ? kinds : undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid list query", 422, {
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid list query",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
},
|
||||
);
|
||||
}
|
||||
const {
|
||||
q,
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { encounterLinkSchema } from "@/lib/validations/encounters";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import {
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
} from "@/lib/encounters/service";
|
||||
import { loadEncounterLinks } from "@/lib/encounters/service";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
loadEncounterLinks,
|
||||
resolveOwnerNotificationContext,
|
||||
shouldHaveReminder,
|
||||
} from "@/lib/encounters/service";
|
||||
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
closeCheckupForVisit,
|
||||
findOwnAppointmentReminderId,
|
||||
@@ -54,6 +53,7 @@ import {
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -35,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
|
||||
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import {
|
||||
ENCOUNTER_INCLUDE,
|
||||
actingDomainVisibility,
|
||||
applyEncounterLinks,
|
||||
loadEncounterLinksForMany,
|
||||
closeCheckupForVisit,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
summarizeRestoreSkips,
|
||||
type RestoreSkipLog,
|
||||
} from "@/lib/export/restore-skips";
|
||||
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user, grantId } = await requireRecordAuth("read", "profile");
|
||||
@@ -154,7 +155,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
|
||||
|
||||
async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("@/lib/db", () => ({
|
||||
// `buildIntradayProfileBackupSection`.
|
||||
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
// The visit tables. Empty here: this file asserts the sections it seeds,
|
||||
// and an account with no visits is the ordinary case the builder must
|
||||
// still answer for.
|
||||
|
||||
@@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
// The parse-refusal pair the routes now answer with: the same envelope
|
||||
// plus the per-field issue list.
|
||||
sanitiseZodIssues: (
|
||||
issues: { path: (string | number)[]; code: string; message: string }[],
|
||||
) =>
|
||||
issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
apiValidationError: (
|
||||
error: string,
|
||||
issues: unknown[],
|
||||
status: number,
|
||||
options?: { errorCode?: string },
|
||||
) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
details: { issues },
|
||||
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
safeJson: async (req: NextRequest) => {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isP2025 } from "@/lib/prisma-errors";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(fitbitCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
return apiValidationError(
|
||||
"Client ID and Client Secret are required",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isP2025 } from "@/lib/prisma-errors";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(googleHealthCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
return apiValidationError(
|
||||
"Client ID and Client Secret are required",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Field-by-field assignment — never spread the parsed object, so no future
|
||||
|
||||
@@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = importSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(
|
||||
return apiValidationError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid import payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -65,9 +71,14 @@ export const POST = apiHandler(
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = coachAttachmentCreateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid attachment request", 422, {
|
||||
errorCode: "coach.fenced.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid attachment request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "coach.fenced.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const { documentId } = parsed.data;
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = fencedChatRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid fenced chat request", 422, {
|
||||
errorCode: "coach.fenced.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid fenced chat request",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "coach.fenced.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const {
|
||||
conversationId,
|
||||
|
||||
@@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -91,7 +93,17 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
|
||||
action: { name: "insights.coach.message.feedback" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
throw new HttpError(422, "feedback.body.invalid");
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"feedback.body.invalid",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "feedback.body.invalid" },
|
||||
);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
|
||||
@@ -27,10 +27,15 @@
|
||||
* 7. Persist user message + assistant message (encrypted) and bump
|
||||
* the day's CoachUsage token ledger.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { redactOptional, redactSecrets } from "@/lib/logging/redact";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -209,9 +214,16 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
action: { name: "insights.coach.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "coach.request.invalid" },
|
||||
{ status: 422 },
|
||||
// The dotted token keeps its place in `error`: this refusal keeps its 422,
|
||||
// so moving the string would be a wire change with nothing forcing it —
|
||||
// the `invalid_json` siblings moved only because their status moved to 400
|
||||
// anyway. `meta.errorCode` publishes the same token in the field a machine
|
||||
// code belongs in, and `details.issues` names the fields that were refused.
|
||||
return apiValidationError(
|
||||
"coach.request.invalid",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "coach.request.invalid" },
|
||||
);
|
||||
}
|
||||
const {
|
||||
|
||||
@@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) {
|
||||
action: { name: "insights.recommendation.feedback" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid feedback payload", 422);
|
||||
return apiValidationError(
|
||||
"Invalid feedback payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
|
||||
@@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = chainBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new HttpError(422, "Invalid provider chain payload");
|
||||
return apiValidationError(
|
||||
"Invalid provider chain payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Defence-in-depth: reject duplicate provider types so a stale-tab
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: {
|
||||
|
||||
@@ -28,7 +28,13 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
@@ -154,9 +160,14 @@ async function handleTextExtract(
|
||||
|
||||
const parsed = ocrTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid OCR text payload", 422, {
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid OCR text payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.ocr.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
@@ -148,7 +149,17 @@ async function postLabResult(request: NextRequest) {
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -12,6 +12,17 @@ const SELF = {
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
|
||||
@@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
@@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = batchPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
return apiValidationError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid batch",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
|
||||
|
||||
@@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
@@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = payloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
return apiValidationError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid batch",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const { externalIds } = parsed.data;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
@@ -801,6 +802,16 @@ async function postMeasurement(request: NextRequest) {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
estimateDailyDoseCount,
|
||||
estimateRunwayDays,
|
||||
@@ -236,103 +237,71 @@ export const GET = apiHandler(
|
||||
* Rate-limit: 30/min/user (same as sibling POST routes).
|
||||
* Audit: `medication.glp1.update` for every successful mutation.
|
||||
*/
|
||||
/**
|
||||
* Wrapped in `withIdempotency`: a titration step is replayed out of the
|
||||
* client's offline outbox under the same `Idempotency-Key`, and a replay after
|
||||
* a lost success response would otherwise write a second dose change or a
|
||||
* second supply delta. Neither row carries a natural key, so the wrapper is the
|
||||
* only thing that can collapse the retry.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
|
||||
// the server acts on; administering one is the case the level names.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
withIdempotency<[NextRequest, RouteParams]>(postGlp1Update),
|
||||
);
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
async function postGlp1Update(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
|
||||
// the server acts on; administering one is the case the level names.
|
||||
const { user, actor } = await requireRecordAuth("manage", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
// Per-caller POST rate-limit — matches the 30/min sibling routes
|
||||
// (inventory, side-effects). Generous for a hand-driven session,
|
||||
// tight enough to cut off the spam case.
|
||||
//
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
|
||||
// allowance and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-glp1:post:${actor.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
// Per-caller POST rate-limit — matches the 30/min sibling routes
|
||||
// (inventory, side-effects). Generous for a hand-driven session,
|
||||
// tight enough to cut off the spam case.
|
||||
//
|
||||
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
|
||||
// allowance and cannot collect a fresh one by switching records.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-glp1:post:${actor.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
}
|
||||
|
||||
const parsed = glp1PostBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const parsed = glp1PostBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
if (parsed.data.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } =
|
||||
parsed.data.doseChange;
|
||||
const created = await prisma.medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
effectiveFrom,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
// Encrypt the titration note at rest; the plaintext column stays null.
|
||||
noteEncrypted: encryptNote(note),
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
const ip = getClientIp(request);
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "doseChange",
|
||||
doseChangeId: created.id,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.doseChange.create",
|
||||
entity_type: "medication_dose_change",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, doseValue, doseUnit },
|
||||
});
|
||||
|
||||
// A dose change shifts the daily-dose estimate the list payload's
|
||||
// runway derivation uses. Hard-evict the medications + compliance
|
||||
// buckets so the card reflects the new runway on the next read.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
|
||||
}
|
||||
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
//
|
||||
// DEPRECATED branch (v1.16.10): this writes the legacy
|
||||
// MedicationInventoryEvent running-sum ledger. The per-item
|
||||
// endpoints (`POST /api/medications/[id]/inventory`,
|
||||
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
|
||||
// GET above and the Coach snapshot read the ledger only while the
|
||||
// medication has zero inventory items. New callers must register
|
||||
// containers instead of posting deltas.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
if (parsed.data.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange;
|
||||
const created = await prisma.medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
effectiveFrom,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
// Encrypt the titration note at rest; the plaintext column stays null.
|
||||
noteEncrypted: encryptNote(note),
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
@@ -340,27 +309,70 @@ export const POST = apiHandler(
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "inventory",
|
||||
inventoryEventId: created.id,
|
||||
delta,
|
||||
reason,
|
||||
kind: "doseChange",
|
||||
doseChangeId: created.id,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.inventory.create",
|
||||
entity_type: "medication_inventory_event",
|
||||
name: "medication.glp1.doseChange.create",
|
||||
entity_type: "medication_dose_change",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, delta },
|
||||
meta: { medication_id: id, doseValue, doseUnit },
|
||||
});
|
||||
|
||||
// A legacy ledger delta changes the stock the list payload reports
|
||||
// for a medication with no per-item containers. Hard-evict so the
|
||||
// card reflects it on the next read.
|
||||
// A dose change shifts the daily-dose estimate the list payload's
|
||||
// runway derivation uses. Hard-evict the medications + compliance
|
||||
// buckets so the card reflects the new runway on the next read.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
},
|
||||
);
|
||||
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
|
||||
}
|
||||
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
//
|
||||
// DEPRECATED branch (v1.16.10): this writes the legacy
|
||||
// MedicationInventoryEvent running-sum ledger. The per-item
|
||||
// endpoints (`POST /api/medications/[id]/inventory`,
|
||||
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
|
||||
// GET above and the Coach snapshot read the ledger only while the
|
||||
// medication has zero inventory items. New callers must register
|
||||
// containers instead of posting deltas.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
});
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "inventory",
|
||||
inventoryEventId: created.id,
|
||||
delta,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.inventory.create",
|
||||
entity_type: "medication_inventory_event",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, delta },
|
||||
});
|
||||
|
||||
// A legacy ledger delta changes the stock the list payload reports
|
||||
// for a medication with no per-item containers. Hard-evict so the
|
||||
// card reflects it on the next read.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
}
|
||||
@@ -22,6 +22,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
@@ -58,6 +59,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
"medications",
|
||||
);
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.36.1 follow-up — the sibling route `POST /api/medications/intake`
|
||||
// refuses a delegate changing a dose the owner already recorded. This route
|
||||
// reaches the same rows through the slot upsert, so without the same rule it
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -203,6 +204,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// notification at the end of this handler.
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
// into the record: the ordinary way this instrument is used when the person
|
||||
// it is about cannot run their own record. The row records what the person
|
||||
// in the room reported; the audit row says who typed it.
|
||||
//
|
||||
// It writes ONE row outside its declared section, and the crossing is
|
||||
// deliberate rather than an oversight: the `*_SCORE` Measurement below is a
|
||||
// projection of this administration, minted `COMPUTED`, keyed on
|
||||
// `assessment:<id>`, and it exists only so the trend reads through the chart
|
||||
// and rollup tier without ever touching the encrypted item content. Fencing
|
||||
// it on `measurements` the way the booster mint is fenced would file a
|
||||
// screener whose own total is missing from the record — an inconsistency
|
||||
// inside the section the grant DOES open, to avoid a derived point in one it
|
||||
// does not. The consent copy for this section says so, which is where a
|
||||
// widening belongs.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// Opt-in module (default OFF) — same gate as GET.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
@@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = clientErrorSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid error data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid error data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const delivery = await sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
|
||||
@@ -31,6 +31,17 @@ const txClient = {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
@@ -105,6 +106,16 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// the `externalId` refusal below.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
// The parse-refusal pair the routes now answer with: the same envelope
|
||||
// plus the per-field issue list.
|
||||
sanitiseZodIssues: (
|
||||
issues: { path: (string | number)[]; code: string; message: string }[],
|
||||
) =>
|
||||
issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
apiValidationError: (
|
||||
error: string,
|
||||
issues: unknown[],
|
||||
status: number,
|
||||
options?: { errorCode?: string },
|
||||
) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
details: { issues },
|
||||
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (
|
||||
error: string,
|
||||
|
||||
@@ -3,7 +3,13 @@ import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
@@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(nightscoutConnectSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("A valid Nightscout URL is required", 422);
|
||||
return apiValidationError(
|
||||
"A valid Nightscout URL is required",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const { url, token } = result.data;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { notificationPreferenceSchema } from "@/lib/validations/notifications";
|
||||
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
@@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = notificationPreferenceSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const { channelId, eventType, enabled } = parsed.data;
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
|
||||
import type { ChannelType, EventType } from "@/lib/notifications/types";
|
||||
import { reEnableChannel } from "@/lib/notifications/channel-state";
|
||||
@@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = reEnableChannelSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const channel = await prisma.notificationChannel.findFirst({
|
||||
where: { id: parsed.data.channelId, userId: user.id },
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* v1.39 (C1) — `PATCH /api/onboarding/answers`.
|
||||
*
|
||||
* One step at a time, saved the moment it is answered, so leaving the flow and
|
||||
* coming back resumes where it stopped (design spec §Principles 4). The body
|
||||
* names the step and either its answer or a deliberate skip; the union in
|
||||
* `src/lib/validations/onboarding-needs.ts` is what makes "skipped, and here is
|
||||
* the answer anyway" unrepresentable.
|
||||
*
|
||||
* Idempotent: the state is computed by a pure function from the stored row and
|
||||
* the body, so sending the same step twice writes the same value twice and the
|
||||
* second write changes nothing a reader can see. The one instant that could
|
||||
* drift — the first-result completion stamp — is kept from the existing row
|
||||
* rather than re-taken.
|
||||
*
|
||||
* `requireAuth()` rather than `requireRecordAuth`: this route writes the
|
||||
* CALLER's own record and refuses outright while the session is acting on
|
||||
* somebody else's (403 `sharing.not_permitted`), at every grant level. The
|
||||
* storage is keyed by record so a guardian running a managed profile's setup
|
||||
* has a row to write, but the route into it belongs with the rest of the
|
||||
* managed-record configuration (#939 / C2), not here — a delegable declaration
|
||||
* is a reviewed entry in the sharing surface guard, not a default.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply";
|
||||
import { readHeldUnitPreferences } from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
readOnboardingRecordState,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RATE_LIMIT = 120;
|
||||
const RATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "onboarding.needs.save" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`onboarding-answers:${user.id}`,
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.save" },
|
||||
meta: { outcome: "rate_limited" },
|
||||
});
|
||||
return apiError("Too many onboarding writes, try again later", 429, {
|
||||
errorCode: "onboarding.answers.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = onboardingAnswerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.save" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "onboarding.answers.invalid",
|
||||
});
|
||||
}
|
||||
const answer = parsed.data;
|
||||
|
||||
const existing = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
const next = applyOnboardingAnswer(
|
||||
readOnboardingRecordState(existing),
|
||||
answer,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
// The unit answers are the one step whose consequence lives outside this
|
||||
// table: `User.glucoseUnit` and `User.unitPreference` are what every display
|
||||
// surface already reads, so the flow writes them rather than inventing a
|
||||
// second source of truth. What is kept beside them in `needs.units` is the
|
||||
// answer as given, so a restart can show it back without re-deriving it from
|
||||
// a column Settings may have changed since.
|
||||
const unitData: { glucoseUnit?: string; unitPreference?: string } = {};
|
||||
if (answer.step === "units" && answer.status !== "skipped") {
|
||||
if (answer.units.glucoseUnit) {
|
||||
unitData.glucoseUnit = answer.units.glucoseUnit;
|
||||
}
|
||||
if (answer.units.unitPreference) {
|
||||
unitData.unitPreference = answer.units.unitPreference;
|
||||
}
|
||||
}
|
||||
|
||||
const upsert = prisma.onboardingRecord.upsert({
|
||||
where: { userId: user.id },
|
||||
// Field-by-field, never a spread of the parsed body: the three columns are
|
||||
// the pure function's output, and nothing the client sent reaches Prisma
|
||||
// without passing through it.
|
||||
create: {
|
||||
userId: user.id,
|
||||
needsJson: toJson(next.needs),
|
||||
stepsJson: toJson(next.steps),
|
||||
firstResultJson: next.firstResult ? toJson(next.firstResult) : undefined,
|
||||
},
|
||||
update: {
|
||||
needsJson: toJson(next.needs),
|
||||
stepsJson: toJson(next.steps),
|
||||
...(next.firstResult
|
||||
? { firstResultJson: toJson(next.firstResult) }
|
||||
: {}),
|
||||
},
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
// One transaction, because the two writes are one answer: a failure between
|
||||
// them would leave the display columns changed with no record of the answer
|
||||
// that changed them.
|
||||
const [written] =
|
||||
Object.keys(unitData).length > 0
|
||||
? await prisma.$transaction([
|
||||
upsert,
|
||||
prisma.user.update({ where: { id: user.id }, data: unitData }),
|
||||
])
|
||||
: await prisma.$transaction([upsert]);
|
||||
|
||||
const held = readHeldUnitPreferences({
|
||||
glucoseUnit: unitData.glucoseUnit ?? user.glucoseUnit,
|
||||
unitPreference: unitData.unitPreference ?? user.unitPreference,
|
||||
});
|
||||
|
||||
// Both columns have a dedicated settings route that writes an audit row, and
|
||||
// the trail should not depend on which surface the person changed them from.
|
||||
// Same event names, so the activity panel groups them.
|
||||
if (unitData.glucoseUnit !== undefined) {
|
||||
await auditLog("user.glucose-unit.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: user.glucoseUnit,
|
||||
next: unitData.glucoseUnit,
|
||||
source: "onboarding",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (unitData.unitPreference !== undefined) {
|
||||
await auditLog("user.unit-preference.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: user.unitPreference,
|
||||
next: unitData.unitPreference,
|
||||
source: "onboarding",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.save" },
|
||||
meta: {
|
||||
step: answer.step,
|
||||
status: answer.status === "skipped" ? "skipped" : "done",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ onboarding: toOnboardingStateDto(written, held) });
|
||||
});
|
||||
@@ -1,12 +1,21 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
prisma: {
|
||||
user: { update: vi.fn(), findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
// v1.39 (C1) — the needs half of the route looks for a setup row before
|
||||
// it does anything. These cases are the LEGACY caller, which has none,
|
||||
// so the lookup answers null and the route falls straight through to the
|
||||
// acknowledgement it always returned.
|
||||
onboardingRecord: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
|
||||
@@ -58,6 +67,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.onboardingRecord.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/complete", () => {
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { normalisePrefs } from "@/lib/modules/gate";
|
||||
import { modulesHoldingRecordData } from "@/lib/modules/domain-data";
|
||||
import {
|
||||
deriveOnboardingModuleDefaults,
|
||||
mergeDerivedModulePreferences,
|
||||
OWNED_MODULE_KEYS,
|
||||
} from "@/lib/modules/registry";
|
||||
import {
|
||||
everyOnboardingQuestionSettled,
|
||||
readHeldUnitPreferences,
|
||||
resolveOnboardingSteps,
|
||||
type HeldUnitPreferences,
|
||||
} from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
readOnboardingRecordState,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
@@ -10,6 +36,23 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding";
|
||||
/**
|
||||
* Complete the onboarding flow. Saves optional profile data and marks
|
||||
* onboarding as completed.
|
||||
*
|
||||
* v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based
|
||||
* flow, and the two live side by side rather than one replacing the other.
|
||||
* The legacy half is unchanged: the optional profile fields, the completion
|
||||
* stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The
|
||||
* needs half runs only for a record that actually FINISHED the questions —
|
||||
* every one of them answered or deliberately passed — and is what turns them
|
||||
* into a module
|
||||
* map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a
|
||||
* re-derivation would re-apply the questionnaire over decisions taken in
|
||||
* Settings since. `POST /api/onboarding/restart` clears that marker when the
|
||||
* person asks for the questions again.
|
||||
*
|
||||
* The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard
|
||||
* at `POST /api/onboarding/step`) are untouched and stay live until C2
|
||||
* replaces that surface. They decide TILE ORDER; the answers below decide
|
||||
* WHICH MODULES ARE ON. Neither reads the other.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -22,9 +65,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const result = z.safeParse(onboardingCompleteSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
@@ -59,5 +107,157 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// next navigation drops the /onboarding redirect immediately.
|
||||
await setOnboardingPendingCookie(false);
|
||||
|
||||
return apiSuccess({ completed: true });
|
||||
const onboarding = await completeNeedsFlow(
|
||||
user.id,
|
||||
readHeldUnitPreferences(user),
|
||||
request,
|
||||
);
|
||||
|
||||
return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) });
|
||||
});
|
||||
|
||||
/**
|
||||
* The needs half: derive the module map once, mark the confirm step, stamp the
|
||||
* flow's own completion.
|
||||
*
|
||||
* Returns the published state, or `null` for a caller that never entered the
|
||||
* needs flow — a legacy wizard completion then answers exactly the body it
|
||||
* always did.
|
||||
*/
|
||||
async function completeNeedsFlow(
|
||||
userId: string,
|
||||
held: HeldUnitPreferences,
|
||||
request: NextRequest,
|
||||
) {
|
||||
const record = await prisma.onboardingRecord.findUnique({
|
||||
where: { userId },
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
if (!record) return null;
|
||||
|
||||
const state = readOnboardingRecordState(record);
|
||||
// The units question answers itself for an account that already holds both
|
||||
// preferences, so the gate and the published state read the same resolved
|
||||
// ledger rather than the raw one.
|
||||
const steps = resolveOnboardingSteps(state.steps, held);
|
||||
if (state.needs.recordTarget === null) {
|
||||
// The row exists but Q1 was never answered, so there is nothing to derive
|
||||
// from. Stamping a completion here would claim a setup that did not happen.
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: "no_answers" },
|
||||
});
|
||||
return toOnboardingStateDto(record, held);
|
||||
}
|
||||
if (!everyOnboardingQuestionSettled(steps)) {
|
||||
// The confirm screen is the only caller that may derive, and it is reached
|
||||
// only once every question has an answer or a deliberate pass. Q1 alone is
|
||||
// not enough: an abandoned flow would have every remaining question read
|
||||
// as its conservative default, switch off the surfaces that default
|
||||
// implies, and latch the result so nobody could re-derive it. This route
|
||||
// is also the LEGACY wizard's endpoint, and that wizard knows nothing
|
||||
// about these questions — so a person who answered Q1, left, and later
|
||||
// finished the old wizard must not have a module map derived for them.
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: { outcome: "incomplete" },
|
||||
});
|
||||
return toOnboardingStateDto(record, held);
|
||||
}
|
||||
|
||||
let derived = false;
|
||||
let keptForData: string[] = [];
|
||||
if (record.modulesDerivedAt === null) {
|
||||
const defaults = deriveOnboardingModuleDefaults({
|
||||
recordTarget: state.needs.recordTarget,
|
||||
areas: state.needs.areas,
|
||||
// An unanswered (skipped) question is the conservative answer: no
|
||||
// medication surface, no doctor report. Both stay one click away in
|
||||
// Settings, which is the whole ordering-versus-removal decision.
|
||||
medication: state.needs.medication ?? "no",
|
||||
visit: state.needs.visit ?? "no",
|
||||
});
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
// Only the modules the answers would switch OFF are worth asking about,
|
||||
// and a record that already holds rows in one of those domains keeps its
|
||||
// surface: the flow orders a record, and it cannot order away content
|
||||
// somebody already has.
|
||||
const wouldSwitchOff = OWNED_MODULE_KEYS.filter(
|
||||
(key) => defaults.preferences[key] === false,
|
||||
);
|
||||
const holdsData = await modulesHoldingRecordData(
|
||||
prisma,
|
||||
userId,
|
||||
wouldSwitchOff,
|
||||
);
|
||||
const merged = mergeDerivedModulePreferences(
|
||||
normalisePrefs(row?.modulePreferencesJson),
|
||||
defaults.preferences,
|
||||
holdsData,
|
||||
);
|
||||
// The same record-keyed write the guardian's modules route uses, so the
|
||||
// two surfaces that decide a record's modules cannot drift apart on how
|
||||
// the decision is stored or what it invalidates. `cycle` delegates to
|
||||
// `CycleProfile.cycleTrackingEnabled`, so the module blob never owns it,
|
||||
// and it is only ever switched ON from here: the gate derives an
|
||||
// unanswered cycle from recorded sex, and an untick is not a request to
|
||||
// retract that.
|
||||
await writeRecordModulePreferences({
|
||||
recordId: userId,
|
||||
modulePreferences: merged,
|
||||
...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}),
|
||||
});
|
||||
|
||||
// The same trail the dedicated modules route writes, under the same event
|
||||
// name, because it is the same column and the activity panel is where
|
||||
// somebody goes to ask why their modules changed. The flow was writing
|
||||
// three audited settings columns and leaving no trail at all, which made
|
||||
// the answer to that question "nothing did it".
|
||||
const stored = normalisePrefs(row?.modulePreferencesJson);
|
||||
const changed = [
|
||||
...Object.keys(merged).filter((key) => merged[key] !== stored[key]),
|
||||
...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []),
|
||||
];
|
||||
await auditLog("user.modules.update", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { changed, keptForData: [...holdsData], source: "onboarding" },
|
||||
});
|
||||
|
||||
keptForData = [...holdsData];
|
||||
derived = true;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const confirmed = steps.map((step) =>
|
||||
step.id === "confirm" && step.status === "pending"
|
||||
? { ...step, status: "done" as const }
|
||||
: step,
|
||||
);
|
||||
const written = await prisma.onboardingRecord.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
stepsJson: toJson(confirmed),
|
||||
// Stamped once. The derivation is latched and the stamp should be too,
|
||||
// or a replay of the confirm quietly moves the instant the setup
|
||||
// finished at.
|
||||
completedAt: record.completedAt ?? now,
|
||||
...(derived ? { modulesDerivedAt: now } : {}),
|
||||
},
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.complete" },
|
||||
meta: {
|
||||
outcome: derived ? "derived" : "already_derived",
|
||||
keptForData: keptForData.length,
|
||||
},
|
||||
});
|
||||
|
||||
return toOnboardingStateDto(written, held);
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
|
||||
|
||||
/**
|
||||
@@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => {
|
||||
|
||||
const parsed = z.safeParse(bodySchema, rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The server pins the canonical version it stamps — the body version is
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* v1.39 (C1) — `POST /api/onboarding/restart`.
|
||||
*
|
||||
* "Set up again", from Settings. It puts the nine steps back to `pending`,
|
||||
* clears the flow's completion stamp and clears the derivation marker so the
|
||||
* next confirm may derive a module map again.
|
||||
*
|
||||
* What it deliberately does NOT do:
|
||||
*
|
||||
* - touch a single module. The design spec's ordering-versus-removal
|
||||
* decision says a restart never turns off a module the person turned on by
|
||||
* hand, and the safest way to hold that is for this route to write no
|
||||
* module state at all. The protection against the SECOND confirm is
|
||||
* elsewhere, in `mergeDerivedModulePreferences`.
|
||||
* - clear the answers. They come back as the prefill for the re-run, which
|
||||
* is what "never re-ask a value the account holds" means here.
|
||||
* - clear `User.onboardingCompletedAt`. That column gates the first-run
|
||||
* redirect in `src/proxy.ts`; clearing it would drop the person back into
|
||||
* the wizard of today rather than into the questions they asked for.
|
||||
* - forget a first result that really happened. The task produced a reading,
|
||||
* a medication or a connection; a re-run of the questions does not unmake
|
||||
* it.
|
||||
*
|
||||
* `requireAuth()` for the reason the answers route gives: the caller's own
|
||||
* record, refused outright under an acting-account switch.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
defaultOnboardingSteps,
|
||||
readHeldUnitPreferences,
|
||||
} from "@/lib/onboarding/needs";
|
||||
import {
|
||||
ONBOARDING_RECORD_SELECT,
|
||||
toOnboardingStateDto,
|
||||
} from "@/lib/onboarding/needs-store";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { onboardingRestartSchema } from "@/lib/validations/onboarding-needs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "onboarding.needs.restart" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`onboarding-restart:${user.id}`,
|
||||
10,
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.restart" },
|
||||
meta: { outcome: "rate_limited" },
|
||||
});
|
||||
return apiError("Too many onboarding writes, try again later", 429, {
|
||||
errorCode: "onboarding.restart.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = onboardingRestartSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.restart" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "onboarding.restart.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const steps = defaultOnboardingSteps();
|
||||
const written = await prisma.onboardingRecord.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, stepsJson: toJson(steps) },
|
||||
update: {
|
||||
stepsJson: toJson(steps),
|
||||
completedAt: null,
|
||||
modulesDerivedAt: null,
|
||||
},
|
||||
select: ONBOARDING_RECORD_SELECT,
|
||||
});
|
||||
|
||||
await auditLog("onboarding.restart", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { steps: steps.length },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.needs.restart" },
|
||||
meta: { outcome: "reset" },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
onboarding: toOnboardingStateDto(written, readHeldUnitPreferences(user)),
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
@@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid step payload", 422, {
|
||||
errorCode: "onboarding.step.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid step payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.step.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const { step, goals } = parsed.data;
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = z.safeParse(tourBodySchema, body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Build the update field-by-field (no mass assignment). `completed`
|
||||
|
||||
@@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
// The parse-refusal pair the routes now answer with: the same envelope
|
||||
// plus the per-field issue list.
|
||||
sanitiseZodIssues: (
|
||||
issues: { path: (string | number)[]; code: string; message: string }[],
|
||||
) =>
|
||||
issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
apiValidationError: (
|
||||
error: string,
|
||||
issues: unknown[],
|
||||
status: number,
|
||||
options?: { errorCode?: string },
|
||||
) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
details: { issues },
|
||||
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
safeJson: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(ouraCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
return apiValidationError(
|
||||
"Client ID and Client Secret are required",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
await storeOuraClientCredentials(
|
||||
|
||||
@@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({
|
||||
clearPolarClientCredentials: clearMock,
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
// The parse-refusal pair the routes now answer with: the same envelope
|
||||
// plus the per-field issue list.
|
||||
sanitiseZodIssues: (
|
||||
issues: { path: (string | number)[]; code: string; message: string }[],
|
||||
) =>
|
||||
issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
apiValidationError: (
|
||||
error: string,
|
||||
issues: unknown[],
|
||||
status: number,
|
||||
options?: { errorCode?: string },
|
||||
) => ({
|
||||
data: null,
|
||||
error,
|
||||
status,
|
||||
details: { issues },
|
||||
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
|
||||
}),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
safeJson: vi.fn(async (req: { _body: unknown }) => ({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import {
|
||||
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(polarCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
return apiValidationError(
|
||||
"Client ID and Client Secret are required",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
await storePolarClientCredentials(
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserHealthScore,
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { writeRecordModulePreferences } from "@/lib/record-settings/modules";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
@@ -251,34 +250,25 @@ export const PATCH = apiHandler(
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
if (patch.modulePreferences !== undefined) {
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
}
|
||||
// The delegated key writes its own column. An explicit boolean is
|
||||
// One write path, shared with the setup flow's derivation. The
|
||||
// delegated key writes its own column there: an explicit boolean is
|
||||
// what the cycle gate honours over the sex-derived default, so a
|
||||
// guardian turning Cycle off for a record whose sex says otherwise
|
||||
// is recorded as the deliberate answer it is.
|
||||
if (patch.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(access.recordId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId: access.recordId },
|
||||
data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
|
||||
});
|
||||
}
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
const written = await writeRecordModulePreferences({
|
||||
recordId: access.recordId,
|
||||
...(patch.modulePreferences !== undefined
|
||||
? { modulePreferences }
|
||||
: {}),
|
||||
...(patch.cycleTrackingEnabled !== undefined
|
||||
? { cycleTrackingEnabled: patch.cycleTrackingEnabled }
|
||||
: {}),
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = {
|
||||
modulePreferences,
|
||||
cycleTrackingEnabled: isCycleEnabled(
|
||||
current?.gender ?? null,
|
||||
cycleProfile,
|
||||
),
|
||||
cycleTrackingEnabled: isCycleEnabled(current?.gender ?? null, {
|
||||
cycleTrackingEnabled: written.cycleTrackingEnabled,
|
||||
}),
|
||||
};
|
||||
// The module keys themselves, not the wrapper field: the record's
|
||||
// trail should say which module moved. `cycleTrackingEnabled` is
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
emailSettingsSchema,
|
||||
notificationChannelEnabledSchema,
|
||||
@@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = emailSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const { recipient, enabled } = parsed.data;
|
||||
|
||||
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user