From 8b3e638feb22b266d5f94ec933cde3e624d7f55d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:03:01 +0800 Subject: [PATCH 1/7] feat(registry): clinical registry schema, types, and seed script (phase 1) Adds the curated registry backing the Services and Forms modes: - clinical_registry_records (kind service|form, full ServiceRecord shape, conservative governance columns) + clinical_registry_record_sources join table for source-document linkage, service-role-only RLS - schema.sql mirror + supabase-schema test assertions - database.types.ts entries for both tables - src/lib/registry-records.ts bidirectional mappers with conservative governance derivation (never emits approved from seeding) - scripts/seed-registry-records.ts (registry:seed) upserting the mock fixtures per owner; dry-run by default Migration is a repo file only - NOT applied to the live project. Co-Authored-By: Claude Fable 5 --- package.json | 1 + scripts/seed-registry-records.ts | 119 +++++++++++++++++ src/lib/registry-records.ts | 122 +++++++++++++++++ src/lib/supabase/database.types.ts | 126 ++++++++++++++++++ ...260703020000_clinical_registry_records.sql | 87 ++++++++++++ supabase/schema.sql | 86 ++++++++++++ tests/supabase-schema.test.ts | 34 +++++ 7 files changed, 575 insertions(+) create mode 100644 scripts/seed-registry-records.ts create mode 100644 src/lib/registry-records.ts create mode 100644 supabase/migrations/20260703020000_clinical_registry_records.sql diff --git a/package.json b/package.json index 5dd0f81261..cbfb2f301b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/seed-registry-records.ts b/scripts/seed-registry-records.ts new file mode 100644 index 0000000000..6375ae04d9 --- /dev/null +++ b/scripts/seed-registry-records.ts @@ -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 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" }); + 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; +}); diff --git a/src/lib/registry-records.ts b/src/lib/registry-records.ts new file mode 100644 index 0000000000..2a776b1456 --- /dev/null +++ b/src/lib/registry-records.ts @@ -0,0 +1,122 @@ +import type { Database } from "@/lib/supabase/database.types"; +import type { + ServiceContact, + ServiceCriterion, + ServiceInfoRow, + ServiceRecord, + ServiceSource, + ServiceStatusChip, + ServiceSummaryCard, + ServiceVerification, +} from "@/lib/services"; + +export type RegistryRecordKind = "service" | "form"; +export type RegistrySourceStatus = "current" | "review_due" | "outdated" | "unknown"; +export type RegistryValidationStatus = "unverified" | "locally_reviewed" | "approved"; + +export type RegistryRecordRow = Database["public"]["Tables"]["clinical_registry_records"]["Row"]; +export type RegistryRecordInsert = Database["public"]["Tables"]["clinical_registry_records"]["Insert"]; + +const sourceStatuses: readonly RegistrySourceStatus[] = ["current", "review_due", "outdated", "unknown"]; +const validationStatuses: readonly RegistryValidationStatus[] = ["unverified", "locally_reviewed", "approved"]; + +export function normalizeRegistrySlug(value: string) { + return value.trim().toLowerCase(); +} + +export function registrySourceStatus(value: string | null | undefined): RegistrySourceStatus { + return sourceStatuses.find((status) => status === value) ?? "unknown"; +} + +export function registryValidationStatus(value: string | null | undefined): RegistryValidationStatus { + return validationStatuses.find((status) => status === value) ?? "unverified"; +} + +/** Conservative governance derivation from the human-readable fixture fields. + * Seeding never emits "approved" — that requires an explicit review step. */ +export function deriveGovernanceColumns(record: ServiceRecord): { + source_status: RegistrySourceStatus; + validation_status: RegistryValidationStatus; +} { + const status = record.source?.status?.toLowerCase() ?? ""; + const sourceStatus: RegistrySourceStatus = status.includes("checked") + ? "current" + : status.includes("required") || status.includes("review") + ? "review_due" + : "unknown"; + const validationStatus: RegistryValidationStatus = + record.verification?.locallyVerified === true ? "locally_reviewed" : "unverified"; + return { source_status: sourceStatus, validation_status: validationStatus }; +} + +export function recordToRow(record: ServiceRecord, ownerId: string, kind: RegistryRecordKind): RegistryRecordInsert { + const governance = deriveGovernanceColumns(record); + return { + owner_id: ownerId, + kind, + slug: normalizeRegistrySlug(record.slug), + title: record.title, + subtitle: record.subtitle ?? null, + route: record.route ?? null, + eligibility: record.eligibility ?? null, + cost: record.cost ?? null, + referral: record.referral ?? null, + location: record.location ?? null, + best_use: record.bestUse ?? null, + catalogue_label: record.catalogueLabel ?? null, + navigator_query: record.navigatorQuery ?? null, + tags: record.tags ?? [], + catchments: record.catchments ?? [], + status_chips: record.statusChips ?? [], + primary_contact: record.primaryContact ?? null, + contacts: record.contacts ?? [], + summary_cards: record.summaryCards ?? [], + referral_info: record.referralInfo ?? [], + criteria: record.criteria ?? [], + verification: record.verification ?? {}, + source: record.source ?? {}, + source_status: governance.source_status, + validation_status: governance.validation_status, + }; +} + +export function rowToServiceRecord(row: RegistryRecordRow): ServiceRecord { + return { + slug: row.slug, + title: row.title, + subtitle: row.subtitle ?? undefined, + statusChips: (row.status_chips ?? []) as ServiceStatusChip[], + primaryContact: (row.primary_contact ?? undefined) as ServiceContact | undefined, + contacts: (row.contacts ?? []) as ServiceContact[], + route: row.route ?? undefined, + eligibility: row.eligibility ?? undefined, + cost: row.cost ?? undefined, + referral: row.referral ?? undefined, + location: row.location ?? undefined, + summaryCards: (row.summary_cards ?? []) as ServiceSummaryCard[], + referralInfo: (row.referral_info ?? []) as ServiceInfoRow[], + bestUse: row.best_use ?? undefined, + criteria: (row.criteria ?? []) as ServiceCriterion[], + verification: (row.verification ?? undefined) as ServiceVerification | undefined, + tags: row.tags ?? [], + catchments: row.catchments ?? [], + catalogueLabel: row.catalogue_label ?? undefined, + navigatorQuery: row.navigator_query ?? undefined, + source: (row.source ?? undefined) as ServiceSource | undefined, + }; +} + +/** Governance metadata surfaced alongside a registry record in API responses. */ +export function rowGovernance(row: RegistryRecordRow): { + sourceStatus: RegistrySourceStatus; + validationStatus: RegistryValidationStatus; + lastReviewedAt: string | null; + reviewDueAt: string | null; +} { + return { + sourceStatus: registrySourceStatus(row.source_status), + validationStatus: registryValidationStatus(row.validation_status), + lastReviewedAt: row.last_reviewed_at, + reviewDueAt: row.review_due_at, + }; +} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 6fcc2ed1cd..54b054f37a 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -93,6 +93,132 @@ export type Database = { } Relationships: [] } + clinical_registry_record_sources: { + Row: { + created_at: string + document_id: string + id: string + note: string | null + owner_id: string + record_id: string + } + Insert: { + created_at?: string + document_id: string + id?: string + note?: string | null + owner_id: string + record_id: string + } + Update: { + created_at?: string + document_id?: string + id?: string + note?: string | null + owner_id?: string + record_id?: string + } + Relationships: [] + } + clinical_registry_records: { + Row: { + best_use: string | null + catalogue_label: string | null + catchments: string[] + contacts: Json + cost: string | null + created_at: string + criteria: Json + eligibility: string | null + id: string + kind: string + last_reviewed_at: string | null + location: string | null + navigator_query: string | null + owner_id: string + primary_contact: Json | null + referral: string | null + referral_info: Json + review_due_at: string | null + route: string | null + slug: string + source: Json + source_status: string + status_chips: Json + subtitle: string | null + summary_cards: Json + tags: string[] + title: string + updated_at: string + validation_status: string + verification: Json + } + Insert: { + best_use?: string | null + catalogue_label?: string | null + catchments?: string[] + contacts?: Json + cost?: string | null + created_at?: string + criteria?: Json + eligibility?: string | null + id?: string + kind: string + last_reviewed_at?: string | null + location?: string | null + navigator_query?: string | null + owner_id: string + primary_contact?: Json | null + referral?: string | null + referral_info?: Json + review_due_at?: string | null + route?: string | null + slug: string + source?: Json + source_status?: string + status_chips?: Json + subtitle?: string | null + summary_cards?: Json + tags?: string[] + title: string + updated_at?: string + validation_status?: string + verification?: Json + } + Update: { + best_use?: string | null + catalogue_label?: string | null + catchments?: string[] + contacts?: Json + cost?: string | null + created_at?: string + criteria?: Json + eligibility?: string | null + id?: string + kind?: string + last_reviewed_at?: string | null + location?: string | null + navigator_query?: string | null + owner_id?: string + primary_contact?: Json | null + referral?: string | null + referral_info?: Json + review_due_at?: string | null + route?: string | null + slug?: string + source?: Json + source_status?: string + status_chips?: Json + subtitle?: string | null + summary_cards?: Json + tags?: string[] + title?: string + updated_at?: string + validation_status?: string + verification?: Json + } + Relationships: [] + } document_chunks: { Row: { anchor_id: string | null diff --git a/supabase/migrations/20260703020000_clinical_registry_records.sql b/supabase/migrations/20260703020000_clinical_registry_records.sql new file mode 100644 index 0000000000..f4a4d6ccfa --- /dev/null +++ b/supabase/migrations/20260703020000_clinical_registry_records.sql @@ -0,0 +1,87 @@ +set search_path = public, pg_catalog, pg_temp; + +-- Curated clinical registry backing the Services and Forms modes: structured +-- records (contacts, eligibility, referral pathways, criteria) for real WA +-- entities, seeded from reviewed fixtures and linkable to verifying source +-- documents in the indexed corpus. Owner-scoped like every other app table; +-- ownership is enforced at the API layer via the service-role client. +create table if not exists public.clinical_registry_records ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + kind text not null check (kind in ('service', 'form')), + slug text not null check (btrim(slug) <> ''), + title text not null check (btrim(title) <> ''), + subtitle text, + route text, + eligibility text, + cost text, + referral text, + location text, + best_use text, + catalogue_label text, + navigator_query text, + tags text[] not null default '{}', + catchments text[] not null default '{}', + status_chips jsonb not null default '[]'::jsonb, + primary_contact jsonb, + contacts jsonb not null default '[]'::jsonb, + summary_cards jsonb not null default '[]'::jsonb, + referral_info jsonb not null default '[]'::jsonb, + criteria jsonb not null default '[]'::jsonb, + verification jsonb not null default '{}'::jsonb, + source jsonb not null default '{}'::jsonb, + -- Governance columns mirror the search-scope enums so registry records carry + -- the same conservative source metadata as documents (missing -> unknown). + source_status text not null default 'unknown' + check (source_status in ('current', 'review_due', 'outdated', 'unknown')), + validation_status text not null default 'unverified' + check (validation_status in ('unverified', 'locally_reviewed', 'approved')), + last_reviewed_at date, + review_due_at date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (owner_id, kind, slug) +); + +-- Source-document linkage: which indexed corpus documents verify a registry +-- record. FK integrity keeps links honest when documents are deleted. +create table if not exists public.clinical_registry_record_sources ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + record_id uuid not null references public.clinical_registry_records(id) on delete cascade, + document_id uuid not null references public.documents(id) on delete cascade, + note text, + created_at timestamptz not null default now(), + unique (record_id, document_id) +); + +create index if not exists clinical_registry_records_owner_kind_idx + on public.clinical_registry_records(owner_id, kind, title); +create index if not exists clinical_registry_record_sources_record_idx + on public.clinical_registry_record_sources(record_id); +create index if not exists clinical_registry_record_sources_document_idx + on public.clinical_registry_record_sources(document_id); + +drop trigger if exists clinical_registry_records_updated_at on public.clinical_registry_records; +create trigger clinical_registry_records_updated_at + before update on public.clinical_registry_records + for each row execute function public.set_updated_at(); + +-- Service-role only: reads and writes go through the API layer, which enforces +-- owner scoping on every query (application-layer model, same as documents). +alter table public.clinical_registry_records enable row level security; +alter table public.clinical_registry_record_sources enable row level security; + +revoke all on public.clinical_registry_records from anon, authenticated; +revoke all on public.clinical_registry_record_sources from anon, authenticated; + +grant select, insert, update, delete on table public.clinical_registry_records to service_role; +grant select, insert, update, delete on table public.clinical_registry_record_sources to service_role; + +drop policy if exists "registry records service role all" on public.clinical_registry_records; +create policy "registry records service role all" on public.clinical_registry_records + for all to service_role using (true) with check (true); + +drop policy if exists "registry record sources service role all" on public.clinical_registry_record_sources; +create policy "registry record sources service role all" on public.clinical_registry_record_sources + for all to service_role using (true) with check (true); diff --git a/supabase/schema.sql b/supabase/schema.sql index ccbb715169..134c33a1be 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -4006,3 +4006,89 @@ comment on index public.documents_indexing_v3_agent_claim_idx is comment on table public.indexing_v3_agent_jobs is 'Dedicated worker-state table for the v3 indexing / enrichment agent. Replaces JSONB state in documents.metadata. claim_indexing_v3_agent_jobs uses SKIP LOCKED here; update_indexing_v3_agent_job_status completes/fails a job. See migration 20260702190000 for transition notes.'; + +-- Curated clinical registry backing the Services and Forms modes: structured +-- records (contacts, eligibility, referral pathways, criteria) for real WA +-- entities, seeded from reviewed fixtures and linkable to verifying source +-- documents in the indexed corpus. Owner-scoped like every other app table; +-- ownership is enforced at the API layer via the service-role client. +create table if not exists public.clinical_registry_records ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + kind text not null check (kind in ('service', 'form')), + slug text not null check (btrim(slug) <> ''), + title text not null check (btrim(title) <> ''), + subtitle text, + route text, + eligibility text, + cost text, + referral text, + location text, + best_use text, + catalogue_label text, + navigator_query text, + tags text[] not null default '{}', + catchments text[] not null default '{}', + status_chips jsonb not null default '[]'::jsonb, + primary_contact jsonb, + contacts jsonb not null default '[]'::jsonb, + summary_cards jsonb not null default '[]'::jsonb, + referral_info jsonb not null default '[]'::jsonb, + criteria jsonb not null default '[]'::jsonb, + verification jsonb not null default '{}'::jsonb, + source jsonb not null default '{}'::jsonb, + -- Governance columns mirror the search-scope enums so registry records carry + -- the same conservative source metadata as documents (missing -> unknown). + source_status text not null default 'unknown' + check (source_status in ('current', 'review_due', 'outdated', 'unknown')), + validation_status text not null default 'unverified' + check (validation_status in ('unverified', 'locally_reviewed', 'approved')), + last_reviewed_at date, + review_due_at date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (owner_id, kind, slug) +); + +-- Source-document linkage: which indexed corpus documents verify a registry +-- record. FK integrity keeps links honest when documents are deleted. +create table if not exists public.clinical_registry_record_sources ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users(id) on delete cascade, + record_id uuid not null references public.clinical_registry_records(id) on delete cascade, + document_id uuid not null references public.documents(id) on delete cascade, + note text, + created_at timestamptz not null default now(), + unique (record_id, document_id) +); + +create index if not exists clinical_registry_records_owner_kind_idx + on public.clinical_registry_records(owner_id, kind, title); +create index if not exists clinical_registry_record_sources_record_idx + on public.clinical_registry_record_sources(record_id); +create index if not exists clinical_registry_record_sources_document_idx + on public.clinical_registry_record_sources(document_id); + +drop trigger if exists clinical_registry_records_updated_at on public.clinical_registry_records; +create trigger clinical_registry_records_updated_at + before update on public.clinical_registry_records + for each row execute function public.set_updated_at(); + +-- Service-role only: reads and writes go through the API layer, which enforces +-- owner scoping on every query (application-layer model, same as documents). +alter table public.clinical_registry_records enable row level security; +alter table public.clinical_registry_record_sources enable row level security; + +revoke all on public.clinical_registry_records from anon, authenticated; +revoke all on public.clinical_registry_record_sources from anon, authenticated; + +grant select, insert, update, delete on table public.clinical_registry_records to service_role; +grant select, insert, update, delete on table public.clinical_registry_record_sources to service_role; + +drop policy if exists "registry records service role all" on public.clinical_registry_records; +create policy "registry records service role all" on public.clinical_registry_records + for all to service_role using (true) with check (true); + +drop policy if exists "registry record sources service role all" on public.clinical_registry_record_sources; +create policy "registry record sources service role all" on public.clinical_registry_record_sources + for all to service_role using (true) with check (true); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index d9d7d05561..f682f3c3e5 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -68,6 +68,10 @@ const indexingV3AgentJobsMigration = readFileSync( new URL("../supabase/migrations/20260702190000_indexing_v3_agent_jobs_table.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const clinicalRegistryRecordsMigration = readFileSync( + new URL("../supabase/migrations/20260703020000_clinical_registry_records.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); function extractTextChunkFunction(sql: string) { const start = sql.indexOf("function public.match_document_chunks_text"); @@ -646,4 +650,34 @@ describe("Supabase schema Data API grants", () => { "grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role;", ); }); + + it("defines the clinical registry tables identically in migration and schema", () => { + for (const sql of [schema, clinicalRegistryRecordsMigration]) { + expect(sql).toContain("create table if not exists public.clinical_registry_records"); + expect(sql).toContain("kind text not null check (kind in ('service', 'form'))"); + expect(sql).toContain("owner_id uuid not null references auth.users(id) on delete cascade"); + expect(sql).toContain( + "source_status text not null default 'unknown' check (source_status in ('current', 'review_due', 'outdated', 'unknown'))", + ); + expect(sql).toContain( + "validation_status text not null default 'unverified' check (validation_status in ('unverified', 'locally_reviewed', 'approved'))", + ); + expect(sql).toContain("unique (owner_id, kind, slug)"); + expect(sql).toContain("create table if not exists public.clinical_registry_record_sources"); + expect(sql).toContain( + "record_id uuid not null references public.clinical_registry_records(id) on delete cascade", + ); + expect(sql).toContain("document_id uuid not null references public.documents(id) on delete cascade"); + expect(sql).toContain("unique (record_id, document_id)"); + expect(sql).toContain("create index if not exists clinical_registry_records_owner_kind_idx"); + expect(sql).toContain("create trigger clinical_registry_records_updated_at"); + expect(sql).toContain("alter table public.clinical_registry_records enable row level security"); + expect(sql).toContain("revoke all on public.clinical_registry_records from anon, authenticated"); + expect(sql).toContain( + "grant select, insert, update, delete on table public.clinical_registry_records to service_role", + ); + expect(sql).toContain('create policy "registry records service role all"'); + expect(sql).toContain('create policy "registry record sources service role all"'); + } + }); }); From 3998f713717d6144f771bf9ee87ba23e539ff22c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:12:23 +0800 Subject: [PATCH 2/7] feat(registry): owner-scoped registry API routes + ranker extraction (phase 2) - GET /api/registry/records?kind=service|form&q=&limit= and /api/registry/records/[slug]?kind= following the documents-route pipeline: demo-mode mock short-circuit, admin client, authenticated owner scoping on every query, new 'registry' rate-limit bucket (120/min), governance metadata + linked-document passthrough, Cache-Control private/no-store - non-breaking ranker extraction: rankServiceRecords/rankFormRecords operate on an injected records array; searchServiceRecords/ searchFormRecords delegate with the mock fixtures as defaults - tests/registry-records-route.test.ts covers the same per-route contract the aggregate suites check (demo short-circuit, 401, 400 invalid kind, owner scoping, 429, 404, demo detail) so no duplicate blocks were added to api-route-coverage/api-validation-contract Co-Authored-By: Claude Fable 5 --- src/app/api/registry/records/[slug]/route.ts | 103 +++++++ src/app/api/registry/records/route.ts | 115 ++++++++ src/lib/api-rate-limit.ts | 4 +- src/lib/forms.ts | 13 +- src/lib/services.ts | 12 +- tests/registry-records-route.test.ts | 282 +++++++++++++++++++ 6 files changed, 520 insertions(+), 9 deletions(-) create mode 100644 src/app/api/registry/records/[slug]/route.ts create mode 100644 src/app/api/registry/records/route.ts create mode 100644 tests/registry-records-route.test.ts diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts new file mode 100644 index 0000000000..18ef828948 --- /dev/null +++ b/src/app/api/registry/records/[slug]/route.ts @@ -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, 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); + } +} diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts new file mode 100644 index 0000000000..e00651d6d7 --- /dev/null +++ b/src/app/api/registry/records/route.ts @@ -0,0 +1,115 @@ +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 { + 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"; + +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) { + 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; + return registryResponse({ + records: records.slice(0, limit), + matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, + total: records.length, + 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"); + 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 = {}; + 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); + 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: records.slice(0, limit), + matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, + total: rows.length, + governance: governanceBySlug, + linkedDocumentIds: linkedDocumentIdsBySlug, + }); + } catch (error) { + if (error instanceof AuthenticationError) { + return unauthorizedResponse(); + } + return jsonError(error); + } +} diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 09df5b0847..3d83e837cc 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server"; import { PublicApiError } from "@/lib/http"; import type { createAdminClient } from "@/lib/supabase/admin"; -export type ApiRateLimitBucket = "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex"; +export type ApiRateLimitBucket = + "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -18,6 +19,7 @@ const apiRateLimitDefaults = { document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, + registry: { limit: 120, windowSeconds: 60 }, } as const satisfies Record; type SupabaseAdmin = ReturnType; diff --git a/src/lib/forms.ts b/src/lib/forms.ts index 290a522230..13becf3bc8 100644 --- a/src/lib/forms.ts +++ b/src/lib/forms.ts @@ -284,7 +284,7 @@ export function formNavigatorQuery(form: FormRecord) { ); } -export function searchFormRecords(query: string, limit = formRecords.length): FormSearchMatch[] { +export function rankFormRecords(records: FormRecord[], query: string, limit = records.length): FormSearchMatch[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) return []; if (/^services?$/.test(normalizedQuery)) return []; @@ -307,7 +307,7 @@ export function searchFormRecords(query: string, limit = formRecords.length): Fo ].includes(term), ); - return formRecords + return records .map((form) => { const title = normalizeSearchText(form.title); const slug = normalizeSearchText(form.slug); @@ -338,9 +338,10 @@ export function searchFormRecords(query: string, limit = formRecords.length): Fo return { service: form, score, reasons }; }) .filter((match) => match.score > 0) - .sort( - (left, right) => - right.score - left.score || formRecords.indexOf(left.service) - formRecords.indexOf(right.service), - ) + .sort((left, right) => right.score - left.score || records.indexOf(left.service) - records.indexOf(right.service)) .slice(0, limit); } + +export function searchFormRecords(query: string, limit = formRecords.length): FormSearchMatch[] { + return rankFormRecords(formRecords, query, limit); +} diff --git a/src/lib/services.ts b/src/lib/services.ts index 502b88c1df..da9a80734e 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -572,7 +572,11 @@ export function serviceRecordSearchText(service: ServiceRecord) { return normalizeSearchText(serviceRecordSearchParts(service).join(" ")); } -export function searchServiceRecords(query: string, limit = serviceRecords.length): ServiceSearchMatch[] { +export function rankServiceRecords( + records: ServiceRecord[], + query: string, + limit = records.length, +): ServiceSearchMatch[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) return []; @@ -580,7 +584,7 @@ export function searchServiceRecords(query: string, limit = serviceRecords.lengt const terms = Array.from(new Set(normalizedQuery.split(/\s+/).filter((term) => term.length > 1))); const broadServicesQuery = terms.some((term) => ["service", "services", "pathway", "pathways"].includes(term)); - return serviceRecords + return records .map((service) => { const title = normalizeSearchText(service.title); const slug = normalizeSearchText(service.slug); @@ -617,3 +621,7 @@ export function searchServiceRecords(query: string, limit = serviceRecords.lengt .sort((left, right) => right.score - left.score || left.service.title.localeCompare(right.service.title)) .slice(0, limit); } + +export function searchServiceRecords(query: string, limit = serviceRecords.length): ServiceSearchMatch[] { + return rankServiceRecords(serviceRecords, query, limit); +} diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts new file mode 100644 index 0000000000..bb6558c4f5 --- /dev/null +++ b/tests/registry-records-route.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const token = "valid-token"; +const recordId = "11111111-1111-4111-8111-111111111111"; + +type QueryError = { message: string }; +type QueryResult = { data: unknown; error: QueryError | null }; +type QueryFilter = { column: string; value: unknown }; +type QueryCall = { + table: string; + filters: QueryFilter[]; + inFilters: Array<{ column: string; values: unknown[] }>; + maybeSingle: boolean; +}; +type QueryResolver = (call: QueryCall) => QueryResult; + +function ok(data: unknown): QueryResult { + return { data, error: null }; +} + +function registryRow(overrides: Partial> = {}) { + return { + id: recordId, + owner_id: userId, + kind: "service", + slug: "13yarn", + title: "13YARN", + subtitle: "Crisis support line", + route: null, + eligibility: null, + cost: null, + referral: null, + location: null, + best_use: null, + catalogue_label: null, + navigator_query: null, + tags: ["crisis"], + catchments: [], + status_chips: [], + primary_contact: { label: "Phone", value: "13 92 76", kind: "phone" }, + contacts: [], + summary_cards: [], + referral_info: [], + criteria: [], + verification: { locallyVerified: true }, + source: { status: "Source checked" }, + source_status: "current", + validation_status: "locally_reviewed", + last_reviewed_at: null, + review_due_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +class QueryBuilder implements PromiseLike { + constructor( + private readonly call: QueryCall, + private readonly resolver: QueryResolver, + ) {} + + select() { + return this; + } + + eq(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + in(column: string, values: unknown[]) { + this.call.inFilters.push({ column, values }); + return this; + } + + order() { + return this; + } + + maybeSingle() { + this.call.maybeSingle = true; + return Promise.resolve(this.resolver(this.call)); + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve(this.resolver(this.call)).then(onfulfilled, onrejected); + } +} + +function createSupabaseMock(resolve: QueryResolver = () => ok([]), options: { limited?: boolean } = {}) { + const calls: QueryCall[] = []; + const getUser = vi.fn(async (receivedToken?: string) => + receivedToken === token + ? { data: { user: { id: userId } }, error: null } + : { data: { user: null }, error: { message: "Invalid token" } }, + ); + const rpc = vi.fn(async (name: string) => + name === "consume_api_rate_limit" + ? { + data: [ + { + limited: Boolean(options.limited), + limit_value: 120, + remaining: options.limited ? 0 : 119, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + } + : ok([]), + ); + return { + calls, + auth: { getUser }, + rpc, + from: vi.fn((table: string) => { + const call: QueryCall = { table, filters: [], inFilters: [], maybeSingle: false }; + calls.push(call); + return new QueryBuilder(call, resolve); + }), + }; +} + +function mockRuntime(client: ReturnType, options: { demoMode?: boolean } = {}) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ + env: {}, + isDemoMode: () => Boolean(options.demoMode), + isLocalNoAuthMode: () => false, + requireOpenAIEnv: () => undefined, + requireServerEnv: () => undefined, + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => client, + })); +} + +function request(path: string, init?: RequestInit) { + return new Request(`http://localhost${path}`, init); +} + +function authedRequest(path: string) { + return request(path, { headers: { Authorization: `Bearer ${token}` } }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("registry records API", () => { + it("serves mock records in demo mode without touching Supabase", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/registry/records/route"); + + const response = await GET(request("/api/registry/records?kind=service")); + const payload = (await response.json()) as { records: Array<{ slug: string }>; demoMode?: boolean }; + + expect(response.status).toBe(200); + expect(payload.demoMode).toBe(true); + expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); + expect(client.from).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("rejects unauthenticated list requests outside demo mode", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/route"); + + const response = await GET(request("/api/registry/records?kind=service")); + + expect(response.status).toBe(401); + expect(client.from).not.toHaveBeenCalled(); + }); + + it("rejects an invalid kind with a validation error", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/route"); + + const response = await GET(authedRequest("/api/registry/records?kind=differentials")); + + expect(response.status).toBe(400); + }); + + it("scopes every registry query to the authenticated owner", async () => { + const client = createSupabaseMock((call) => + call.table === "clinical_registry_records" ? ok([registryRow()]) : ok([]), + ); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/route"); + + const response = await GET(authedRequest("/api/registry/records?kind=service&q=yarn")); + const payload = (await response.json()) as { + records: Array<{ slug: string }>; + matches?: Array<{ record: { slug: string }; score: number; reasons: string[] }>; + governance: Record; + }; + + expect(response.status).toBe(200); + expect(payload.records[0]?.slug).toBe("13yarn"); + expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); + expect(payload.governance["13yarn"]?.validationStatus).toBe("locally_reviewed"); + for (const call of client.calls) { + expect(call.filters).toContainEqual({ column: "owner_id", value: userId }); + } + }); + + it("returns 429 when the registry rate limit is exhausted", async () => { + const client = createSupabaseMock(() => ok([]), { limited: true }); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/route"); + + const response = await GET(authedRequest("/api/registry/records?kind=form")); + + expect(response.status).toBe(429); + expect(client.from).not.toHaveBeenCalled(); + }); + + it("returns a single owner-scoped record with governance metadata", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "clinical_registry_records") return ok(registryRow()); + if (call.table === "clinical_registry_record_sources") return ok([]); + return ok([]); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/[slug]/route"); + + const response = await GET(authedRequest("/api/registry/records/13YARN?kind=service"), { + params: Promise.resolve({ slug: "13YARN" }), + }); + const payload = (await response.json()) as { + record: { slug: string }; + governance: { sourceStatus: string; validationStatus: string }; + }; + + expect(response.status).toBe(200); + expect(payload.record.slug).toBe("13yarn"); + expect(payload.governance.sourceStatus).toBe("current"); + for (const call of client.calls) { + expect(call.filters).toContainEqual({ column: "owner_id", value: userId }); + } + const recordCall = client.calls.find((call) => call.table === "clinical_registry_records"); + expect(recordCall?.filters).toContainEqual({ column: "slug", value: "13yarn" }); + }); + + it("returns 404 for an unknown slug", async () => { + const client = createSupabaseMock((call) => (call.table === "clinical_registry_records" ? ok(null) : ok([]))); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/[slug]/route"); + + const response = await GET(authedRequest("/api/registry/records/unknown-service?kind=service"), { + params: Promise.resolve({ slug: "unknown-service" }), + }); + + expect(response.status).toBe(404); + }); + + it("serves the mock detail record in demo mode", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/registry/records/[slug]/route"); + + const response = await GET(request("/api/registry/records/transport-crisis-form?kind=form"), { + params: Promise.resolve({ slug: "transport-crisis-form" }), + }); + const payload = (await response.json()) as { record: { slug: string }; demoMode?: boolean }; + + expect(response.status).toBe(200); + expect(payload.demoMode).toBe(true); + expect(payload.record.slug).toBe("transport-crisis-form"); + expect(client.from).not.toHaveBeenCalled(); + }); +}); From 5d571451ac25a3ad28fee15d99170f1422859cef Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:51:01 +0800 Subject: [PATCH 3/7] feat(registry): wire services/forms UI to the registry API (phase 3) - detail routes drop generateStaticParams/mock lookups; thin server pages render client loaders (RegistryRecordLoader) with skeleton, sign-in, not-found, and error states; ServiceDetailPage/FormDetailPage and saved-slug localStorage behaviour unchanged - services/forms home verification footers now count fetched registry records via useRegistryRecords (hidden until ready) - ClinicalDashboard record matches rank registry-fetched records client-side (one fetch per active mode; live-typing behaviour unchanged); forms search results page same treatment - searchScopeFiltersSchema gains labelTypesAny (match any label of the requested types); services/forms searches default the corpus scope to service-labelled / form-type documents with user filters winning - browser QA (demo mode): 13YARN detail, unknown-slug not-found state, services search shows the registry-backed verified record card, forms search best-matches table, home footers Co-Authored-By: Claude Fable 5 --- src/app/forms/[slug]/page.tsx | 27 +--- src/app/services/[slug]/page.tsx | 27 +--- src/components/ClinicalDashboard.tsx | 28 +++- src/components/forms/form-detail-client.tsx | 12 ++ src/components/forms/forms-home-page.tsx | 26 ++-- .../forms/forms-search-results-page.tsx | 6 +- src/components/registry-record-loader.tsx | 105 +++++++++++++ .../services/service-detail-client.tsx | 12 ++ .../services/services-home-page.tsx | 26 ++-- src/lib/search-scope.ts | 17 ++- src/lib/use-registry-records.ts | 140 ++++++++++++++++++ tests/search-scope.test.ts | 11 ++ 12 files changed, 363 insertions(+), 74 deletions(-) create mode 100644 src/components/forms/form-detail-client.tsx create mode 100644 src/components/registry-record-loader.tsx create mode 100644 src/components/services/service-detail-client.tsx create mode 100644 src/lib/use-registry-records.ts diff --git a/src/app/forms/[slug]/page.tsx b/src/app/forms/[slug]/page.tsx index 1d509c6dcc..1f30542e43 100644 --- a/src/app/forms/[slug]/page.tsx +++ b/src/app/forms/[slug]/page.tsx @@ -1,32 +1,17 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { FormDetailPage } from "@/components/forms/form-detail-page"; -import { getFormRecord, formStaticParams } from "@/lib/forms"; +import { FormDetailClient } from "@/components/forms/form-detail-client"; type FormRouteProps = { params: Promise<{ slug: string }>; }; -export function generateStaticParams() { - return formStaticParams(); -} - -export async function generateMetadata({ params }: FormRouteProps): Promise { - const { slug } = await params; - const form = getFormRecord(slug); - if (!form) return { title: "Form not found - Clinical KB" }; - - return { - title: `${form.title} - Forms - Clinical KB`, - description: form.subtitle ?? "Psychiatry form and workflow details.", - }; -} +export const metadata: Metadata = { + title: "Form record - Forms - Clinical KB", + description: "Psychiatry form and workflow details.", +}; export default async function FormRoute({ params }: FormRouteProps) { const { slug } = await params; - const form = getFormRecord(slug); - if (!form) return notFound(); - - return ; + return ; } diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx index 0b1f28dd0b..9f934c0b97 100644 --- a/src/app/services/[slug]/page.tsx +++ b/src/app/services/[slug]/page.tsx @@ -1,32 +1,17 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { ServiceDetailPage } from "@/components/ServiceDetailPage"; -import { getServiceRecord, serviceStaticParams } from "@/lib/services"; +import { ServiceDetailClient } from "@/components/services/service-detail-client"; type ServiceRouteProps = { params: Promise<{ slug: string }>; }; -export function generateStaticParams() { - return serviceStaticParams(); -} - -export async function generateMetadata({ params }: ServiceRouteProps): Promise { - const { slug } = await params; - const service = getServiceRecord(slug); - if (!service) return { title: "Service record not found - Clinical KB" }; - - return { - title: `${service.title} - Services - Clinical KB`, - description: service.subtitle ?? "Clinical service record details and referral information.", - }; -} +export const metadata: Metadata = { + title: "Service record - Services - Clinical KB", + description: "Clinical service record details and referral information.", +}; export default async function ServiceRoute({ params }: ServiceRouteProps) { const { slug } = await params; - const service = getServiceRecord(slug); - if (!service) return notFound(); - - return ; + return ; } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index cb685f7379..f460fb435c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -217,8 +217,9 @@ import { type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; -import { searchFormRecords } from "@/lib/forms"; -import { searchServiceRecords } from "@/lib/services"; +import { rankFormRecords } from "@/lib/forms"; +import { rankServiceRecords } from "@/lib/services"; +import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerRenderModel, type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { SourceActionRow, sourceResultHref } from "@/components/clinical-dashboard/source-actions"; import { @@ -395,6 +396,7 @@ function compactScopeFilters(filters: SearchScopeFilters) { if (filters.locality) next.locality = filters.locality; if (filters.importBatchIds?.length) next.importBatchIds = filters.importBatchIds; if (filters.collections?.length) next.collections = filters.collections; + if (filters.labelTypesAny?.length) next.labelTypesAny = filters.labelTypesAny; return next; } @@ -6497,13 +6499,19 @@ export function ClinicalDashboard({ const activeModeSearch = appModeSearchConfig(searchMode); const activeModeResultKind = appModeResultKind(searchMode); const requestQueryMode = appModeQueryMode(searchMode, queryMode); + // Record matches come from the owner-scoped registry API (mock fixtures in + // demo mode); ranking stays client-side so live-typing behaviour is + // unchanged and the registry is fetched once per active mode. + const registryRecords = useRegistryRecords(searchMode === "forms" ? "form" : "service", { + enabled: searchMode === "services" || searchMode === "forms", + }); const serviceSearchMatches = useMemo( - () => (searchMode === "services" ? searchServiceRecords(query) : []), - [query, searchMode], + () => (searchMode === "services" ? rankServiceRecords(registryRecords.records, query) : []), + [query, searchMode, registryRecords.records], ); const formSearchMatches = useMemo( - () => (searchMode === "forms" ? searchFormRecords(query) : []), - [query, searchMode], + () => (searchMode === "forms" ? rankFormRecords(registryRecords.records, query) : []), + [query, searchMode, registryRecords.records], ); const recordSearchMatches = useMemo( () => (searchMode === "forms" ? formSearchMatches : searchMode === "services" ? serviceSearchMatches : []), @@ -7432,6 +7440,14 @@ export function ClinicalDashboard({ const modeSearch = appModeSearchConfig(targetMode); const targetQueryMode = appModeQueryMode(targetMode, queryMode); const isDifferentialsMode = modeSearch.resultKind === "differentials"; + // Services/Forms default the corpus scope to documents that carry the + // mode-relevant labels (any 'service' label; document_type 'form'). + // User-selected filters always win over the mode default. + if (targetMode === "services" && !filtersOverride.labelTypesAny?.length) { + filtersOverride = { ...filtersOverride, labelTypesAny: ["service"] }; + } else if (targetMode === "forms" && !filtersOverride.documentTypes?.length) { + filtersOverride = { ...filtersOverride, documentTypes: ["form"] }; + } setSearchMode(targetMode); setQuery(trimmedQuery); diff --git a/src/components/forms/form-detail-client.tsx b/src/components/forms/form-detail-client.tsx new file mode 100644 index 0000000000..74a766edda --- /dev/null +++ b/src/components/forms/form-detail-client.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { RegistryRecordLoader } from "@/components/registry-record-loader"; +import { FormDetailPage } from "@/components/forms/form-detail-page"; + +export function FormDetailClient({ slug }: { slug: string }) { + return ( + + {(record) => } + + ); +} diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 6a3251cbeb..afa8ff7f4a 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ArrowLeftRight, ClipboardCheck, FileText, Route, Search, ShieldCheck, Truck, UserRound } from "lucide-react"; import { @@ -8,8 +10,9 @@ import { type ModeHomePill, } from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; -import { defaultFormSlug, formRecords } from "@/lib/forms"; +import { defaultFormSlug } from "@/lib/forms"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { useRegistryRecords } from "@/lib/use-registry-records"; const taskCards: ModeHomeAction[] = [ { @@ -59,11 +62,10 @@ const commonTasks: ModeHomePill[] = [ }, ]; -function verifiedCount() { - return formRecords.filter((form) => form.verification?.locallyVerified).length; -} - export function FormsHomePage() { + const registry = useRegistryRecords("form"); + const verifiedCount = registry.records.filter((form) => form.verification?.locallyVerified).length; + return ( + registry.status === "ready" ? ( + + ) : null } /> diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 3fd42cbd59..1578ac22bd 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -27,7 +27,8 @@ import { } from "lucide-react"; import { useMemo, useState, type FormEvent } from "react"; -import { searchFormRecords, type FormSearchMatch } from "@/lib/forms"; +import { rankFormRecords, type FormSearchMatch } from "@/lib/forms"; +import { useRegistryRecords } from "@/lib/use-registry-records"; import { cn, codeText } from "@/components/ui-primitives"; type FormsSearchResultsPageProps = { @@ -839,7 +840,8 @@ export function FormsSearchResultsPage({ query, focusSearch = false }: FormsSear const [prevQuery, setPrevQuery] = useState(query); const [draftQuery, setDraftQuery] = useState(query); const [mobileQuery, setMobileQuery] = useState(""); - const matches = useMemo(() => searchFormRecords(query), [query]); + const registry = useRegistryRecords("form"); + const matches = useMemo(() => rankFormRecords(registry.records, query), [registry.records, query]); // Reset the editable draft when the incoming query prop changes (new // navigation) during render rather than in an effect, to avoid a diff --git a/src/components/registry-record-loader.tsx b/src/components/registry-record-loader.tsx new file mode 100644 index 0000000000..330aff42f6 --- /dev/null +++ b/src/components/registry-record-loader.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Link from "next/link"; +import { FileQuestion, Loader2, ShieldAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn, textMuted } from "@/components/ui-primitives"; +import type { RegistryRecordKind } from "@/lib/registry-records"; +import type { ServiceRecord } from "@/lib/services"; +import { useRegistryRecord } from "@/lib/use-registry-records"; + +const kindCopy: Record = { + service: { noun: "service record", homeHref: "/services", homeLabel: "Back to services" }, + form: { noun: "form record", homeHref: "/forms", homeLabel: "Back to forms" }, +}; + +function StatePanel({ + icon, + title, + body, + kind, +}: { + icon: ReactNode; + title: string; + body: string; + kind: RegistryRecordKind; +}) { + const copy = kindCopy[kind]; + return ( +
+
+ + {icon} + +

{title}

+

{body}

+ + {copy.homeLabel} + +
+
+ ); +} + +export function RegistryRecordLoader({ + kind, + slug, + children, +}: { + kind: RegistryRecordKind; + slug: string; + children: (record: ServiceRecord) => ReactNode; +}) { + const { status, record } = useRegistryRecord(kind, slug); + const copy = kindCopy[kind]; + + if (status === "loading") { + return ( +
+
+ + Loading {copy.noun}... +
+
+ ); + } + + if (status === "unauthorized") { + return ( + } + title="Sign in required" + body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`} + /> + ); + } + + if (status === "not_found" || !record) { + return ( + } + title={`No ${copy.noun} found`} + body={`"${slug}" is not in your registry. It may not be seeded yet, or the link may be out of date.`} + /> + ); + } + + if (status === "error") { + return ( + } + title="Could not load the record" + body="Something went wrong fetching this registry record. Try again shortly." + /> + ); + } + + return <>{children(record)}; +} diff --git a/src/components/services/service-detail-client.tsx b/src/components/services/service-detail-client.tsx new file mode 100644 index 0000000000..c65efbcad9 --- /dev/null +++ b/src/components/services/service-detail-client.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { RegistryRecordLoader } from "@/components/registry-record-loader"; +import { ServiceDetailPage } from "@/components/services/service-detail-page"; + +export function ServiceDetailClient({ slug }: { slug: string }) { + return ( + + {(record) => } + + ); +} diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 7965126840..054ddb1350 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -1,3 +1,5 @@ +"use client"; + import { FileSearch, MapPinned, Route, Users } from "lucide-react"; import { @@ -9,7 +11,8 @@ import { } from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { defaultServiceSlug, serviceRecords } from "@/lib/services"; +import { defaultServiceSlug } from "@/lib/services"; +import { useRegistryRecords } from "@/lib/use-registry-records"; const taskCards: ModeHomeAction[] = [ { @@ -73,11 +76,10 @@ const commonPathways: ModeHomePill[] = [ }, ]; -function verifiedCount() { - return serviceRecords.filter((service) => service.verification?.locallyVerified).length; -} - export function ServicesHomePage() { + const registry = useRegistryRecords("service"); + const verifiedCount = registry.records.filter((service) => service.verification?.locallyVerified).length; + return ( + registry.status === "ready" ? ( + + ) : null } /> diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index 21ddc223e1..6805e4cfb9 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -52,6 +52,10 @@ export const searchScopeFiltersSchema = z locality: z.enum(["local", "non_local"]).optional(), importBatchIds: z.array(z.string().uuid()).max(25).optional(), collections: z.array(z.string().trim().min(1).max(120)).max(20).optional(), + // Match documents carrying ANY label of the requested type(s), without + // enumerating label values (e.g. "any document with a service label"). + // Used by mode-default scopes for the Services/Forms surfaces. + labelTypesAny: z.array(z.enum(labelTypes)).max(13).optional(), }) .default({}); @@ -142,9 +146,16 @@ export function activeScopeFilterCount(filters: SearchScopeFilters) { filters.locality ? [filters.locality] : [], filters.importBatchIds, filters.collections, + filters.labelTypesAny, ].filter((values) => values && values.length > 0).length; } +function labelTypeAnyMatches(labels: ScopeLabelRow[], requestedTypes?: SearchScopeFilters["labelTypesAny"]) { + if (!hasValues(requestedTypes)) return true; + const wanted = new Set(requestedTypes!); + return labels.some((label) => wanted.has(label.label_type as (typeof labelTypes)[number])); +} + function labelMatches(labels: ScopeLabelRow[], type: DocumentLabelType, requested?: string[]) { if (!hasValues(requested)) return true; const wanted = new Set(requested!.map(normalizeFilterText)); @@ -292,7 +303,8 @@ export async function resolveSearchScope(args: { hasValues(filters.clinicalActions) || hasValues(filters.carePhases) || hasValues(filters.documentIntents) || - hasValues(filters.contentFeatures); + hasValues(filters.contentFeatures) || + hasValues(filters.labelTypesAny); let labelsByDocument = new Map(); if (needsLabels) { const { data: labelRows, error: labelError } = await args.supabase @@ -322,7 +334,8 @@ export async function resolveSearchScope(args: { labelMatches(labels, "clinical_action", filters.clinicalActions) && labelMatches(labels, "care_phase", filters.carePhases) && labelMatches(labels, "document_intent", filters.documentIntents) && - labelMatches(labels, "content_feature", filters.contentFeatures) + labelMatches(labels, "content_feature", filters.contentFeatures) && + labelTypeAnyMatches(labels, filters.labelTypesAny) ); }); diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts new file mode 100644 index 0000000000..8e0334596c --- /dev/null +++ b/src/lib/use-registry-records.ts @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { RegistryRecordKind } from "@/lib/registry-records"; +import type { ServiceRecord } from "@/lib/services"; +import { useAuthSession } from "@/lib/supabase/client"; + +export type RegistryRequestStatus = "loading" | "ready" | "unauthorized" | "not_found" | "error"; + +export type RegistryRecordsState = { + status: RegistryRequestStatus; + records: ServiceRecord[]; + total: number; + demoMode: boolean; +}; + +export type RegistryRecordState = { + status: RegistryRequestStatus; + record: ServiceRecord | null; + linkedDocuments: Array<{ id: string; title: string; file_name: string; status: string }>; + demoMode: boolean; +}; + +/** Owner-scoped registry list (Services/Forms home and search surfaces). The + * API serves mock fixtures in demo mode, so callers never branch on demo + * themselves. Pass enabled:false to skip fetching until the mode is active. */ +export function useRegistryRecords( + kind: RegistryRecordKind, + options: { enabled?: boolean } = {}, +): RegistryRecordsState { + const enabled = options.enabled ?? true; + const { authorizationHeader, markSessionExpired } = useAuthSession(); + const [state, setState] = useState({ + status: "loading", + records: [], + total: 0, + demoMode: false, + }); + + useEffect(() => { + if (!enabled) return undefined; + let active = true; + fetch(`/api/registry/records?kind=${kind}`, { headers: authorizationHeader }) + .then(async (response) => { + if (!active) return; + if (response.status === 401) { + markSessionExpired(); + setState({ status: "unauthorized", records: [], total: 0, demoMode: false }); + return; + } + if (!response.ok) { + setState({ status: "error", records: [], total: 0, demoMode: false }); + return; + } + const payload = (await response.json()) as { + records?: ServiceRecord[]; + total?: number; + demoMode?: boolean; + }; + setState({ + status: "ready", + records: payload.records ?? [], + total: payload.total ?? payload.records?.length ?? 0, + demoMode: Boolean(payload.demoMode), + }); + }) + .catch(() => { + if (active) setState({ status: "error", records: [], total: 0, demoMode: false }); + }); + return () => { + active = false; + }; + }, [enabled, kind, authorizationHeader, markSessionExpired]); + + return state; +} + +/** Single owner-scoped registry record (detail pages). */ +export function useRegistryRecord(kind: RegistryRecordKind, slug: string): RegistryRecordState { + const { authorizationHeader, markSessionExpired } = useAuthSession(); + const requestKey = `${kind}:${slug}`; + const [state, setState] = useState({ + status: "loading", + record: null, + linkedDocuments: [], + demoMode: false, + }); + // Reset to loading during render when the target record changes, instead of + // synchronously inside the effect (react-hooks/set-state-in-effect). + const [lastRequestKey, setLastRequestKey] = useState(requestKey); + if (lastRequestKey !== requestKey) { + setLastRequestKey(requestKey); + setState({ status: "loading", record: null, linkedDocuments: [], demoMode: false }); + } + + useEffect(() => { + let active = true; + fetch(`/api/registry/records/${encodeURIComponent(slug)}?kind=${kind}`, { headers: authorizationHeader }) + .then(async (response) => { + if (!active) return; + if (response.status === 401) { + markSessionExpired(); + setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false }); + return; + } + if (response.status === 404) { + setState({ status: "not_found", record: null, linkedDocuments: [], demoMode: false }); + return; + } + if (!response.ok) { + setState({ status: "error", record: null, linkedDocuments: [], demoMode: false }); + return; + } + const payload = (await response.json()) as { + record?: ServiceRecord; + linkedDocuments?: Array<{ id: string; title: string; file_name: string; status: string }>; + demoMode?: boolean; + }; + if (!payload.record) { + setState({ status: "not_found", record: null, linkedDocuments: [], demoMode: false }); + return; + } + setState({ + status: "ready", + record: payload.record, + linkedDocuments: payload.linkedDocuments ?? [], + demoMode: Boolean(payload.demoMode), + }); + }) + .catch(() => { + if (active) setState({ status: "error", record: null, linkedDocuments: [], demoMode: false }); + }); + return () => { + active = false; + }; + }, [kind, slug, authorizationHeader, markSessionExpired]); + + return state; +} diff --git a/tests/search-scope.test.ts b/tests/search-scope.test.ts index 2c85c19db9..8d3331e4c1 100644 --- a/tests/search-scope.test.ts +++ b/tests/search-scope.test.ts @@ -26,4 +26,15 @@ describe("search scope filters", () => { }); expect(activeScopeFilterCount(filters)).toBe(8); }); + + it("accepts label-type-any filters used by mode-default scopes", () => { + const filters = searchScopeFiltersSchema.parse({ labelTypesAny: ["service"] }); + + expect(filters.labelTypesAny).toEqual(["service"]); + expect(activeScopeFilterCount(filters)).toBe(1); + }); + + it("rejects unknown label types in labelTypesAny", () => { + expect(() => searchScopeFiltersSchema.parse({ labelTypesAny: ["not-a-label-type"] })).toThrow(); + }); }); From d8652807f09076fc98720ef2cde53fcd437d1eac Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:16:25 +0800 Subject: [PATCH 4/7] fix(differentials): label synthetic ranking as demonstration content (phase 4) - warning banner (differentials-demo-content-notice) above the results grid: ranked diagnoses are synthetic demonstration content; source counts reflect real library matches - replace the misleading 'Local results only / Reviewed content prioritised' strip with 'Demonstration ranking - synthetic content' - SourceStatusCard: real library-match count kept, fabricated '312 sources' row replaced with an explicit Demonstration row - honest wording for the home subtitle, no-evidence notice, and evidence pills ('Library matches'); dynamic demo result count - Playwright: differentials search flow now asserts the demo notice renders with the results view Co-Authored-By: Claude Fable 5 --- .../clinical-dashboard/differentials-home.tsx | 43 ++++++++++++------- tests/ui-tools.spec.ts | 6 +++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index c43c79bd60..b9931580cc 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -511,8 +511,6 @@ function UrgencyCard({ results }: { results: DifferentialResult[] }) { } function SourceStatusCard({ sourceCount }: { sourceCount: number }) { - const workflow = acuteConfusionPresentationWorkflow; - return (

@@ -522,19 +520,24 @@ function SourceStatusCard({ sourceCount }: { sourceCount: number }) {

- Local only + Library matches + + + {sourceCount.toLocaleString()} source{sourceCount === 1 ? "" : "s"} - {sourceCount.toLocaleString()} sources

- {workflow.sourceStatus.label} + Ranked diagnoses - 312 sources + Demonstration

-

{workflow.sourceStatus.version}

+

+ Matched sources come from your indexed document library. The diagnosis ranking is synthetic demonstration + content. +

); } @@ -606,16 +609,26 @@ function SearchResultsView({ data-testid="differentials-search-results" className="mx-auto grid w-full max-w-[86rem] gap-4 overflow-x-hidden px-3 sm:px-4 lg:px-0" > +

+ + + The ranked diagnoses below are synthetic demonstration content, not clinically authored guidance. Source + counts reflect real matches from your indexed library. + +

- - - Local results only + + + Demonstration ranking - Reviewed content prioritised. Review before use. + Synthetic content. Not for clinical use.

Diagnosis pages (ranked) @@ -712,7 +725,7 @@ function SearchResultsView({ type="button" className="hidden min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-extrabold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] lg:inline-flex" > - View all results (14) + View all demonstration results ({results.length}) @@ -837,13 +850,13 @@ export function DifferentialsHome({ > No source-backed matches for “{trimmedQuery}” yet. Run the search or refine the presentation to - see reviewed differentials. + see the demonstration ranking alongside matches from your library.

) : null} handleAction(action), disabled: loading, }))} - pillsTitle={hasEvidenceMatches ? "Reviewed matches" : "Recent work"} + pillsTitle={hasEvidenceMatches ? "Library matches" : "Recent work"} pillsAction={