Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,3 +113,9 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons

- `GlobalMockupSearchShell` (aka `GlobalSearchShell`, used by the `forms`/`services`/`favourites`/`medications` layouts) wrapped `GlobalMockupSearchShellClient` in a `<Suspense>` whose **fallback also rendered `props.children` inside `#main-content`** — the same subtree the client body renders. Because `useSearchParams()` forces that boundary to the fallback on the server, the page subtree was emitted twice and both copies could persist, producing duplicate `id="main-content"` and duplicate `data-testid` on every shell page. It surfaced as `ui-smoke.spec.ts:1103` failing with a strict-mode violation (two `data-testid="acamprosate-medication-page"` `<main>` elements on `/medications/acamprosate`).
- Fix: the Suspense fallback renders a **neutral placeholder only** — never `props.children`. Rule: do not render the resolved content inside its own Suspense fallback; the fallback is a loading state, not a second copy of the page.

## Clinical registry tables applied live (2026-07-03)

- **Migration `20260703020000_clinical_registry_records.sql` (applied live 2026-07-03 with explicit user approval)** created `public.clinical_registry_records` (owner-scoped structured Services/Forms records — 30 cols, JSONB render payloads, conservative `source_status`/`validation_status` governance columns) and the `public.clinical_registry_record_sources` join table (record ↔ verifying corpus document, FK cascade). Both are service-role-only RLS (enabled + revoked from anon/authenticated + a single `for all to service_role` policy); ownership is enforced in the API layer, matching the documents model. Verified post-apply: both tables present with correct columns, RLS enabled, 0 rows; `get_advisors(security)` returns no lints. `supabase/schema.sql` mirrors the migration and `tests/supabase-schema.test.ts` asserts the shape.
- **Version-recording nuance:** the MCP `apply_migration` timestamps the history row in UTC, so live recorded the migration as version `20260702183308` (name `clinical_registry_records`) while the repo file is `20260703020000_...` (Australia/Perth date). Because the migration is fully idempotent (`create table/index if not exists`, `drop trigger/policy if exists` + create), a later `supabase db push` re-applying the repo file is a harmless no-op that only adds a second history row — the same known duplicate-version churn already present in live history. Do not rewrite history to reconcile; treat as a caution.
- **Remaining step (user):** seed the registry per owner with `npm run registry:seed -- --owner-id <uuid> --write --confirm`. Until seeded, authenticated users see the honest empty-registry state; demo/env-less deployments are unaffected. See [[PR #209]].
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@
"check:supabase-project": "tsx scripts/check-supabase-project.ts",
"check:indexing": "tsx scripts/check-indexing.ts",
"recover:ingestion": "tsx scripts/recover-ingestion-queue.ts",
"registry:seed": "tsx scripts/seed-registry-records.ts",
"reindex": "tsx scripts/reindex.ts",
"reindex:health": "tsx scripts/reindex-health.ts",
"reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts",
Expand Down
119 changes: 119 additions & 0 deletions scripts/seed-registry-records.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { loadEnvConfig } from "@next/env";

import { confirm } from "./cli-utils";
import { recordToRow, type RegistryRecordKind } from "@/lib/registry-records";
import { serviceRecords } from "@/lib/services";
import { formRecords } from "@/lib/forms";
import type { ServiceRecord } from "@/lib/services";

loadEnvConfig(process.cwd());

type SeedArgs = {
ownerId?: string;
kind: RegistryRecordKind | "all";
write: boolean;
confirmed: boolean;
};

async function loadAdminClient() {
const { createAdminClient } = await import("@/lib/supabase/admin");
return createAdminClient();
}

function parseArgs(argv: string[]): SeedArgs {
const args: SeedArgs = {
ownerId: process.env.LOCAL_NO_AUTH_OWNER_ID,
kind: "all",
write: false,
confirmed: false,
};

for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--write") {
args.write = true;
continue;
}
if (token === "--confirm") {
args.confirmed = true;
continue;
}
if (token === "--owner-id") {
args.ownerId = argv[index + 1];
index += 1;
continue;
}
if (token === "--kind") {
const value = argv[index + 1];
if (value !== "service" && value !== "form" && value !== "all") {
throw new Error(`Invalid --kind value: ${value ?? "(missing)"}. Use service | form | all.`);
}
args.kind = value;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}

return args;
}

function seedSets(kind: SeedArgs["kind"]): Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> {
const sets: Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> = [];
if (kind === "service" || kind === "all") sets.push({ kind: "service", records: serviceRecords });
if (kind === "form" || kind === "all") sets.push({ kind: "form", records: formRecords });
return sets;
}

async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.ownerId) {
throw new Error("No owner id. Pass --owner-id <uuid> or set LOCAL_NO_AUTH_OWNER_ID.");
}

const sets = seedSets(args.kind);
const rows = sets.flatMap((set) => set.records.map((record) => recordToRow(record, args.ownerId!, set.kind)));

console.log(`[registry:seed] owner ${args.ownerId}`);
for (const row of rows) {
console.log(
` ${row.kind.padEnd(7)} ${String(row.slug).padEnd(42)} source_status=${row.source_status} validation_status=${row.validation_status}`,
);
}
console.log(
`[registry:seed] ${rows.length} records (${sets.map((s) => `${s.records.length} ${s.kind}`).join(", ")})`,
);

if (!args.write) {
console.log("[registry:seed] Dry run. Re-run with --write --confirm to upsert.");
return;
}
if (!args.confirmed) {
const proceed = await confirm(`Upsert ${rows.length} registry records for owner ${args.ownerId}?`);
if (!proceed) {
console.log("[registry:seed] Aborted.");
return;
}
}

const supabase = await loadAdminClient();
const { error } = await supabase.from("clinical_registry_records").upsert(rows, { onConflict: "owner_id,kind,slug" });
Comment thread
BigSimmo marked this conversation as resolved.
if (error) {
throw new Error(`Upsert failed: ${error.message}`);
}

const { count, error: countError } = await supabase
.from("clinical_registry_records")
.select("id", { count: "exact", head: true })
.eq("owner_id", args.ownerId);
if (countError) {
console.warn(`[registry:seed] Upsert succeeded but count check failed: ${countError.message}`);
} else {
console.log(`[registry:seed] Done. Owner now has ${count ?? "?"} registry records.`);
}
}

main().catch((error) => {
console.error(`[registry:seed] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
103 changes: 103 additions & 0 deletions src/app/api/registry/records/[slug]/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import { z } from "zod";

import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { getFormRecord } from "@/lib/forms";
import {
normalizeRegistrySlug,
rowGovernance,
rowToServiceRecord,
type RegistryRecordRow,
} from "@/lib/registry-records";
import { getServiceRecord } from "@/lib/services";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery } from "@/lib/validation/query";

export const runtime = "nodejs";

const registryDetailQuerySchema = z.object({
kind: z.enum(["service", "form"]),
});

function registryResponse(payload: Record<string, unknown>, init?: { status?: number }) {
return NextResponse.json(payload, {
status: init?.status ?? 200,
headers: { "Cache-Control": "private, no-store" },
});
}

function notFoundResponse(slug: string) {
return registryResponse({ error: `No registry record found for "${slug}".` }, { status: 404 });
}

export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) {
try {
const { slug } = await context.params;
const normalizedSlug = normalizeRegistrySlug(slug);
const { kind } = parseRequestQuery(request, registryDetailQuerySchema, "Invalid registry detail query.");

if (isDemoMode()) {
const record = kind === "form" ? getFormRecord(normalizedSlug) : getServiceRecord(normalizedSlug);
if (!record) return notFoundResponse(normalizedSlug);
return registryResponse({ record, linkedDocuments: [], demoMode: true });
}

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "registry",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit);
}

const { data, error } = await supabase
.from("clinical_registry_records")
.select("*")
.eq("owner_id", user.id)
.eq("kind", kind)
.eq("slug", normalizedSlug)
.maybeSingle();
if (error) throw new Error(error.message);
if (!data) return notFoundResponse(normalizedSlug);

const row = data as RegistryRecordRow;

const { data: links, error: linksError } = await supabase
.from("clinical_registry_record_sources")
.select("document_id, note")
.eq("owner_id", user.id)
.eq("record_id", row.id);
if (linksError) throw new Error(linksError.message);

let linkedDocuments: Array<{ id: string; title: string; file_name: string; status: string }> = [];
const documentIds = (links ?? []).map((link) => link.document_id);
if (documentIds.length > 0) {
const { data: documents, error: documentsError } = await supabase
.from("documents")
.select("id, title, file_name, status")
.eq("owner_id", user.id)
.in("id", documentIds);
if (documentsError) throw new Error(documentsError.message);
linkedDocuments = (documents ?? []) as typeof linkedDocuments;
}

return registryResponse({
record: rowToServiceRecord(row),
governance: rowGovernance(row),
linkedDocuments,
});
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
return jsonError(error);
}
}
131 changes: 131 additions & 0 deletions src/app/api/registry/records/route.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
import { NextResponse } from "next/server";
import { z } from "zod";

import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { rankFormRecords, formRecords } from "@/lib/forms";
import {
deriveGovernanceColumns,
rowGovernance,
rowToServiceRecord,
type RegistryRecordKind,
type RegistryRecordRow,
} from "@/lib/registry-records";
import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/services";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth";
import { parseRequestQuery, queryInteger } from "@/lib/validation/query";

export const runtime = "nodejs";

// The list is a small curated per-owner set that clients fetch in full and
// rank client-side, so the whole set must be returned (never truncated) or
// rows past the cap become invisible to Services/Forms search and undercount
// the home footers. This ceiling is a defensive bound well above realistic
// registry sizes; `limit` only bounds the ranked `matches` for a `q` query.
const REGISTRY_MAX_RECORDS = 500;

const registryListQuerySchema = z.object({
kind: z.enum(["service", "form"]),
q: z
.string()
.trim()
.max(200)
.optional()
.transform((value) => (value ? value : undefined)),
limit: queryInteger({ fallback: 100, min: 1, max: 200 }),
});

function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: string, limit: number) {
return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit);
}

function registryResponse(payload: Record<string, unknown>) {
return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
}

function matchesPayload(matches: ServiceSearchMatch[]) {
return matches.map((match) => ({ record: match.service, score: match.score, reasons: match.reasons }));
}

export async function GET(request: Request) {
try {
const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query.");

if (isDemoMode()) {
const records = kind === "form" ? formRecords : serviceRecords;
const governance = Object.fromEntries(
records.map((record) => {
const derived = deriveGovernanceColumns(record);
return [record.slug, { sourceStatus: derived.source_status, validationStatus: derived.validation_status }];
}),
);
return registryResponse({
records,
matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined,
total: records.length,
governance,
demoMode: true,
});
}

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "registry",
allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit);
}

const { data, error } = await supabase
.from("clinical_registry_records")
.select("*")
.eq("owner_id", user.id)
.eq("kind", kind)
.order("title")
.limit(REGISTRY_MAX_RECORDS);
if (error) throw new Error(error.message);

const rows = (data ?? []) as RegistryRecordRow[];
const records = rows.map(rowToServiceRecord);
const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)]));

const recordIds = rows.map((row) => row.id);
const linkedDocumentIdsByRecordId: Record<string, string[]> = {};
if (recordIds.length > 0) {
const { data: links, error: linksError } = await supabase
.from("clinical_registry_record_sources")
.select("record_id, document_id")
.eq("owner_id", user.id)
.in("record_id", recordIds);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Batch optional registry source lookups

When an owner has hundreds of registry rows, this .in("record_id", recordIds) serializes every row UUID into one PostgREST request (roughly 20KB at the 500-row cap) before the list response can be returned. The current list hook does not consume linkedDocumentIds, so this optional source-link lookup can hit URL/proxy limits or otherwise fail the whole Services/Forms registry load even though the records themselves were fetched successfully; omit it from the list endpoint or fetch the links in smaller pages/batches.

Useful? React with 👍 / 👎.

if (linksError) throw new Error(linksError.message);
for (const link of links ?? []) {
const existing = linkedDocumentIdsByRecordId[link.record_id] ?? [];
existing.push(link.document_id);
linkedDocumentIdsByRecordId[link.record_id] = existing;
}
}
const linkedDocumentIdsBySlug = Object.fromEntries(
rows.map((row) => [row.slug, linkedDocumentIdsByRecordId[row.id] ?? []]),
);

return registryResponse({
records,
matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined,
Comment thread
BigSimmo marked this conversation as resolved.
total: rows.length,
governance: governanceBySlug,
linkedDocumentIds: linkedDocumentIdsBySlug,
});
} catch (error) {
if (error instanceof AuthenticationError) {
return unauthorizedResponse();
}
return jsonError(error);
}
}
Loading
Loading