mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its authentication, module gate and request validation, and answers 501 not_implemented until its behaviour lands. The OpenAPI document carries the new operations and schemas and the additive response fields on the changed routes. The four server feature flags are published as false. contract-stub-inventory.test.ts registers the stubbed routes; each one leaves the register with the change that fills it. The route, sharing, session, security, module-gate and outcome inventories name the new routes deliberately, and the error-code catalogue lists the codes the routes emit today.
This commit is contained in:
1 parent
6e72de1c35
commit
3786deb503
74 files changed
+6614
-282
No files matched your search
@@ -252,6 +252,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/labs/restore/route.ts",
|
||||
"src/app/api/biomarkers/route.ts",
|
||||
"src/app/api/biomarkers/[id]/route.ts",
|
||||
// v1.43 — merging markers and filing a reading against its report or visit
|
||||
// are the same row-store operations: the history and its links must stay
|
||||
// consistent while the Labs surface is hidden.
|
||||
"src/app/api/biomarkers/[id]/merge/route.ts",
|
||||
"src/app/api/labs/[id]/links/route.ts",
|
||||
// v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
|
||||
// reasoning: the labs module gates the SURFACE (the Labs page + scan
|
||||
// affordance), not the row store. The extract route is read-only vision
|
||||
@@ -315,6 +320,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// SURFACE (the detail-page tab + the insights summary), not the row store.
|
||||
"src/app/api/medications/[id]/efficacy/route.ts",
|
||||
"src/app/api/medications/[id]/efficacy/target/route.ts",
|
||||
// v1.43 — a medication's links to its visits, documents and indication are
|
||||
// raw link CRUD over the person's own rows, like the vaccination links.
|
||||
"src/app/api/medications/[id]/links/route.ts",
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
|
||||
* method (#1107 follow-up).
|
||||
*
|
||||
* GET answers the active method, when it was applied and the last dry run
|
||||
* (status and result: per-account labels only, never an id). POST
|
||||
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
|
||||
* computes both methods for every account and day and writes nothing but its
|
||||
* result; `apply` needs a successful dry run from the last seven days (409
|
||||
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
|
||||
* rebuilds the pulse rollups. 409 `maintenance.pulseDayMean.busy` while a run
|
||||
* is active. Admin only, cookie session only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented("admin.pulse_day_mean.get");
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = pulseDayMeanActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
return notImplemented("admin.pulse_day_mean.post");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `GET /api/admin/support-report` — a redacted plain-text report an operator
|
||||
* can paste into an issue: build, database, configuration as yes/no, jobs,
|
||||
* connections by health state, AI ledger and usage levels, notifications,
|
||||
* backups and the most frequent recent error actions. Never an email address,
|
||||
* a user id, a value, a token or a foreign host name; accounts appear as
|
||||
* "Account 1..n". Admin only, cookie session only.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented("admin.support_report.get");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
|
||||
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
|
||||
* lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
await requireAuth();
|
||||
await params;
|
||||
return notImplemented("anamnesis.audit_c.delete");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/audit-c` — record an AUDIT-C (#614).
|
||||
*
|
||||
* `{ items: [0..4, 0..4, 0..4], takenAt?, tz?, locale?, source, externalId? }`.
|
||||
* 201 `{ result: AuditCResult }` with the sum and the screening cut-off for
|
||||
* the entry's language and the profile's sex (null without one); a replay
|
||||
* with the same `externalId` answers 200 with the same result. Stored beside
|
||||
* the mental-health screeners but never listed by
|
||||
* `/api/mental-health/assessments`. At most 30 an hour.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
|
||||
* lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
|
||||
|
||||
async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many entries, try again later", 429, {
|
||||
errorCode: "anamnesis.auditC.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = auditCRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.audit_c.create");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* `PATCH /api/anamnesis/life-circumstances/ai-sections` — switch which
|
||||
* life-circumstance sections may reach the AI (#614).
|
||||
*
|
||||
* `{ sections: { <SECTION>: boolean }, baseUpdatedAt? }` with at least one
|
||||
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
|
||||
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
|
||||
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
|
||||
* Own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeSections.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
|
||||
* section (#614). `{ section }`; stores the confirmation instant only, never a
|
||||
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeConfirm.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("anamnesis.life_circumstances.confirm");
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `GET /api/anamnesis/life-circumstances` — the six life-circumstance
|
||||
* sections (#614) with their current answers, AI switches, review state,
|
||||
* the derived values (pack-years, activity minutes, free weekdays, screening
|
||||
* hints) and AUDIT-C with its instrument text in the account's language.
|
||||
*
|
||||
* The new fact kinds are served here and only here: `GET /api/anamnesis/facts`
|
||||
* keeps its three kinds for clients that decode that list closed. Delegates
|
||||
* read it under the `profile` grant, like the facts.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireRecordAuth("read", "profile");
|
||||
return notImplemented("anamnesis.life_circumstances.get");
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/sso` — start claiming a managed profile through
|
||||
* single sign-on (#959).
|
||||
*
|
||||
* Anonymous, like the password claim beside it. `{ token, username }`: the
|
||||
* link is checked and the username reserved for the round trip, a sealed
|
||||
* `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through
|
||||
* `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the
|
||||
* answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`.
|
||||
* The OIDC callback binds the identity provider's subject to the profile. The
|
||||
* token never rides a URL. Works on a single-sign-on-only server; 404
|
||||
* `profile_claim.sso_unavailable` when no identity provider is configured.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { claimGate } from "@/lib/managed-profiles/claim-gate";
|
||||
import { claimSsoSchema } from "@/lib/validations/claim-sso";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses a signed-in caller and rate-limits the address, as the password
|
||||
// claim does.
|
||||
const gate = await claimGate(request, "claim", (await getSession()) !== null);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimSsoSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
return notImplemented("profile_claim.sso.start");
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level (#1217).
|
||||
*
|
||||
* `{ level: "frugal" | "standard" | "full" | null }`; `null` follows the
|
||||
* instance default. A level above the operator's ceiling is refused with 422
|
||||
* `ai.usage.aboveCeiling`, never capped silently. Answers the resolved
|
||||
* `AiUsageState`, the same block `GET /api/auth/me` publishes as `ai.usage`.
|
||||
* Own account only (cookie or a full-scope token), at most 30 writes an hour.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`ai-usage:${user.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many changes, try again later", 429, {
|
||||
errorCode: "ai.usage.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = aiUsagePutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "ai.usage.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("auth.me.ai_usage.put");
|
||||
});
|
||||
@@ -83,7 +83,15 @@ export const dynamic = "force-dynamic";
|
||||
* by design: a feature the server supports is supported for every account,
|
||||
* and a per-account answer belongs in `modules` or `ai`, not here.
|
||||
*/
|
||||
const SERVER_FEATURES = { trackIntake: true } as const;
|
||||
const SERVER_FEATURES = {
|
||||
trackIntake: true,
|
||||
// v1.43 — each turns true in the change that makes its route or request
|
||||
// form work; until then a client must not send it (contract §0.6).
|
||||
cumulativeHourBuckets: false,
|
||||
syncTriggerBackfill: false,
|
||||
biomarkerMerge: false,
|
||||
lifeCircumstances: false,
|
||||
} as const;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `POST /api/biomarkers/{id}/merge` — fold other markers into this one (D1223).
|
||||
*
|
||||
* `{id}` is the marker that stays. `{ sourceIds, dryRun }`: the readings of
|
||||
* every source move onto the target in one transaction, converted where the
|
||||
* analyte's conversion table knows the unit, the source names become aliases
|
||||
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
|
||||
* are deleted. A pair of units without a known conversion refuses the whole
|
||||
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
|
||||
* answers the same result and writes nothing. There is no undo.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
|
||||
* record-write ceiling. Until the merge lands the stub resolves the caller's
|
||||
* own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(mergeBiomarkers),
|
||||
);
|
||||
|
||||
async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.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: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = biomarkerMergeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.biomarker.merge.invalid",
|
||||
});
|
||||
}
|
||||
if (parsed.data.sourceIds.includes(id)) {
|
||||
return apiError("The target cannot be one of the sources", 422, {
|
||||
errorCode: "labs.biomarker.merge.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("labs.biomarker.merge");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `POST /api/dawarich/connect` — connect a Dawarich instance (movement
|
||||
* context, daily values only).
|
||||
*
|
||||
* `{ url, apiKey }`; both sealed at rest. The URL must resolve to a public
|
||||
* host unless its origin is listed in `DAWARICH_PRIVATE_ORIGINS` (422
|
||||
* `private_origin_not_approved`). The key is checked against the instance
|
||||
* before anything is stored, then a 30-day backfill starts. Part of the
|
||||
* `environment` module; own account only, ten attempts a minute.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`dawarich-connect:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts, try again later", 429, {
|
||||
errorCode: "dawarich.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichConnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.connect");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/dawarich/disconnect` — forget the connection; with
|
||||
* `deleteData: true` the stored days go too. Answers
|
||||
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
|
||||
* own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichDisconnectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.disconnect");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
|
||||
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
|
||||
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = dawarichPlacesQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.places.get");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
|
||||
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
|
||||
* measured against. 422 `dawarich.home_place_unknown` for a place id the
|
||||
* instance does not have. Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.settings.patch");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* `GET /api/dawarich/status` — whether Dawarich is connected, the home place
|
||||
* and country state, how many days are stored and the connection `health`
|
||||
* (the same shape every connection publishes; no legacy ledger fields).
|
||||
* Part of the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.status.get");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
|
||||
* (at most 365 days back; default: the nightly window). 202
|
||||
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
|
||||
* module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = dawarichSyncSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "dawarich.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("dawarich.sync");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
|
||||
* the shared integration probe shape. Part of the `environment` module; own
|
||||
* account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("dawarich.test");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
|
||||
* this run of travel days again. Answers `{ dismissed: true }`; 404
|
||||
* `environment.travel_suggestion.not_found`. Part of the `environment`
|
||||
* module; own account only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestion.dismiss");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `GET /api/environment/travel/suggestions` — runs of consecutive Dawarich
|
||||
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
|
||||
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
|
||||
* (the person picks the place); dismissing through the sibling route. Part of
|
||||
* the `environment` module; own account only.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "environment");
|
||||
if (!gate.enabled) return gate.response;
|
||||
return notImplemented("environment.travel_suggestions.get");
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `POST /api/labs/{id}/links` — file documents (the report it was read
|
||||
* from) or visits against a lab result.
|
||||
* `DELETE /api/labs/{id}/links` — unfile them.
|
||||
*
|
||||
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
|
||||
* shared link service like the visit and vaccination link routes; an id that
|
||||
* names nothing this account owns comes back in `unknown`. Own account only,
|
||||
* as the other link routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { labResultLinkSchema } from "@/lib/validations/relation-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
async function handle(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
verb: "link" | "unlink",
|
||||
): Promise<Response> {
|
||||
await requireAuth();
|
||||
await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = labResultLinkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "labs.link.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented(`labs.link.${verb}`);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "link"),
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "unlink"),
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `POST /api/medications/{id}/ingredients/extract` — read a supplement label.
|
||||
*
|
||||
* Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). Answers 202
|
||||
* with an AI run; `GET /api/ai-runs/{id}` (`SUPPLEMENT_LABEL_EXTRACT`) carries
|
||||
* a proposal of ingredients, unmatched lines and a serving note. Nothing is
|
||||
* stored until the composition is saved through the PUT beside this route.
|
||||
* Gated by the `supplementLabel` AI capability (a `frugal` usage level makes
|
||||
* it unavailable), six runs an hour, budget reserved like the other document
|
||||
* AI runs. Needs the `nutrients` module.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
|
||||
* extraction lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
return notImplemented("medications.ingredients.extract");
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `PUT /api/medications/{id}/ingredients` — a supplement's composition per
|
||||
* unit (tablet, capsule).
|
||||
*
|
||||
* `{ ingredients: [{ nutrient, amount, unit }], source }` replaces the set.
|
||||
* Amounts are stored in the catalogue's canonical unit (an input unit such as
|
||||
* `IU` is kept beside it as `enteredAmount` / `enteredUnit`). The change
|
||||
* re-counts every taken dose of this medication into the day's micronutrients
|
||||
* (`recompute.queued`). Needs the `nutrients` module.
|
||||
*
|
||||
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
|
||||
* composition lands the stub resolves the caller's own record only.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const PUT = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(putIngredients),
|
||||
);
|
||||
|
||||
async function putIngredients(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
await params;
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = medicationIngredientsPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medications.ingredients.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented("medications.ingredients.put");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `POST /api/medications/{id}/links` — file conditions (the indication),
|
||||
* documents or visits against a medication.
|
||||
* `DELETE /api/medications/{id}/links` — unfile them.
|
||||
*
|
||||
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
|
||||
* shared link service like the visit and vaccination link routes; an id that
|
||||
* names nothing this account owns comes back in `unknown`. Own account only,
|
||||
* as the other link routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { medicationLinkSchema } from "@/lib/validations/relation-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
async function handle(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
verb: "link" | "unlink",
|
||||
): Promise<Response> {
|
||||
await requireAuth();
|
||||
await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = medicationLinkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "medication.link.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
return notImplemented(`medication.link.${verb}`);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "link"),
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, context: RouteParams) =>
|
||||
handle(request, context, "unlink"),
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* `GET /api/nutrients/catalog` — the nutrient catalogue as the forms use it.
|
||||
*
|
||||
* One entry per code: canonical unit, label in the account's language,
|
||||
* whether HealthKit may send it (`syncable`), the input units with their
|
||||
* factor to the canonical unit, and the tolerable upper intake level with its
|
||||
* scope and source (or null). Clients build the composition form from this
|
||||
* and never convert units themselves.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
return notImplemented("nutrients.catalog.get");
|
||||
});
|
||||
Reference in new issue
Block a user