From cb0657f61a064546cecb243bfee451fc39b135ee Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:29:32 +0800 Subject: [PATCH 1/5] feat(registry): auto-seed default Services/Forms for new owners Registry records are owner-scoped and were only seeded via the CLI for one owner, so any other authenticated account saw the empty "run the seed" state. Add ensureRegistrySeeded (src/lib/registry-seed.ts) and call it lazily from the registry API: the list route seeds when the owner has no records, and the detail route seeds on a miss when the owner has zero rows of that kind (covers deep-linking a saved favourite before loading home). The upsert conflict target (owner_id,kind,slug) makes it idempotent and race-safe for concurrent first requests; a seed failure falls back to the empty set rather than 500-ing the read. The seed CLI now shares the row builder (keeping its reseed governance-preservation). Demo / local-no-auth behaviour is unchanged, so env-less e2e stays green. Co-Authored-By: Claude Fable 5 --- scripts/seed-registry-records.ts | 13 ++--- src/app/api/registry/records/[slug]/route.ts | 44 ++++++++++++---- src/app/api/registry/records/route.ts | 34 +++++++++---- src/lib/registry-seed.ts | 51 +++++++++++++++++++ tests/registry-records-route.test.ts | 53 ++++++++++++++++++++ 5 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 src/lib/registry-seed.ts diff --git a/scripts/seed-registry-records.ts b/scripts/seed-registry-records.ts index c4468b6f16..c0e5c3acc4 100644 --- a/scripts/seed-registry-records.ts +++ b/scripts/seed-registry-records.ts @@ -1,9 +1,8 @@ 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 RegistryRecordKind } from "@/lib/registry-records"; +import { buildDefaultRegistryRows, defaultRegistryRecords } from "@/lib/registry-seed"; import type { ServiceRecord } from "@/lib/services"; loadEnvConfig(process.cwd()); @@ -59,10 +58,8 @@ function parseArgs(argv: string[]): SeedArgs { } 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; + const kinds: RegistryRecordKind[] = kind === "all" ? ["service", "form"] : [kind]; + return kinds.map((seedKind) => ({ kind: seedKind, records: defaultRegistryRecords(seedKind) })); } async function main() { @@ -72,7 +69,7 @@ async function main() { } const sets = seedSets(args.kind); - const rows = sets.flatMap((set) => set.records.map((record) => recordToRow(record, args.ownerId!, set.kind))); + const rows = sets.flatMap((set) => buildDefaultRegistryRows(args.ownerId!, set.kind)); console.log(`[registry:seed] owner ${args.ownerId}`); for (const row of rows) { diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index c9d8aad733..c8bf3aa0c1 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -12,6 +12,7 @@ import { rowToServiceRecord, type RegistryRecordRow, } from "@/lib/registry-records"; +import { ensureRegistrySeeded } from "@/lib/registry-seed"; import { getServiceRecord } from "@/lib/services"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -65,17 +66,40 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s 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 fetchRecord = async () => { + 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); + return (data as RegistryRecordRow | null) ?? null; + }; - const row = data as RegistryRecordRow; + let row = await fetchRecord(); + if (!row) { + // A new owner may deep-link a default record (e.g. a saved favourite) + // before ever loading the Services/Forms home list that seeds them. If + // this owner has no records of this kind at all, lazily seed the curated + // defaults and retry once. Non-fatal — fall through to 404 on failure. + const { count, error: countError } = await supabase + .from("clinical_registry_records") + .select("id", { count: "exact", head: true }) + .eq("owner_id", user.id) + .eq("kind", kind); + if (countError) throw new Error(countError.message); + if ((count ?? 0) === 0) { + try { + await ensureRegistrySeeded(supabase, user.id, kind); + row = await fetchRecord(); + } catch (seedError) { + console.error(`[registry] auto-seed failed for owner ${user.id} (${kind})`, seedError); + } + } + } + if (!row) return notFoundResponse(normalizedSlug); const { data: links, error: linksError } = await supabase .from("clinical_registry_record_sources") diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 83fd73cf13..9206daca6e 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -12,6 +12,7 @@ import { type RegistryRecordKind, type RegistryRecordRow, } from "@/lib/registry-records"; +import { ensureRegistrySeeded } from "@/lib/registry-seed"; import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/services"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -83,16 +84,31 @@ export async function GET(request: Request) { 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 fetchRecords = async () => { + 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); + return (data ?? []) as RegistryRecordRow[]; + }; + + let rows = await fetchRecords(); + if (rows.length === 0) { + // First visit for this owner: lazily seed the curated defaults so new + // accounts get populated Services/Forms instead of the empty state. + // Non-fatal — a seed failure falls back to the empty set, never a 500. + try { + await ensureRegistrySeeded(supabase, user.id, kind); + rows = await fetchRecords(); + } catch (seedError) { + console.error(`[registry] auto-seed failed for owner ${user.id} (${kind})`, seedError); + } + } - const rows = (data ?? []) as RegistryRecordRow[]; const records = rows.map(rowToServiceRecord); const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); diff --git a/src/lib/registry-seed.ts b/src/lib/registry-seed.ts new file mode 100644 index 0000000000..62c45407b6 --- /dev/null +++ b/src/lib/registry-seed.ts @@ -0,0 +1,51 @@ +import { formRecords } from "@/lib/forms"; +import { + recordToRow, + type RegistryRecordInsert, + type RegistryRecordKind, + type RegistryRecordRow, +} from "@/lib/registry-records"; +import { serviceRecords } from "@/lib/services"; + +// Type-only reference to the admin client so this module carries no runtime +// dependency on the Supabase admin singleton — the CLI can import the row +// builders without pulling in service-role env, and callers pass their own +// client into `ensureRegistrySeeded`. +type AdminClient = ReturnType; + +/** The curated default registry fixtures for a kind — the same set the CLI + * seeds and the API falls back to when an owner has no records yet. */ +export function defaultRegistryRecords(kind: RegistryRecordKind) { + return kind === "form" ? formRecords : serviceRecords; +} + +/** Build insertable rows for an owner from the default fixtures. Shared by the + * CLI (`scripts/seed-registry-records.ts`) and the lazy API auto-seed so both + * map fixtures → rows identically. */ +export function buildDefaultRegistryRows(ownerId: string, kind: RegistryRecordKind): RegistryRecordInsert[] { + return defaultRegistryRecords(kind).map((record) => recordToRow(record, ownerId, kind)); +} + +/** + * Idempotently seed the curated default registry records for an owner + kind + * and return the stored rows. Called lazily by the registry API when an + * authenticated owner has no records yet, so new accounts get populated + * Services/Forms instead of the empty state. Safe under concurrent first + * requests — the (owner_id, kind, slug) conflict target dedupes the upsert. + * + * First-seed helper only: it does NOT preserve post-seed governance edits, so + * the reseed path (the CLI) layers its own preservation on top. + */ +export async function ensureRegistrySeeded( + supabase: AdminClient, + ownerId: string, + kind: RegistryRecordKind, +): Promise { + const rows = buildDefaultRegistryRows(ownerId, kind); + const { data, error } = await supabase + .from("clinical_registry_records") + .upsert(rows, { onConflict: "owner_id,kind,slug" }) + .select("*"); + if (error) throw new Error(`Registry seed failed: ${error.message}`); + return (data ?? []) as RegistryRecordRow[]; +} diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index fa636c591d..638e60c7cb 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -12,6 +12,8 @@ type QueryCall = { filters: QueryFilter[]; inFilters: Array<{ column: string; values: unknown[] }>; maybeSingle: boolean; + upsert?: boolean; + upsertRows?: unknown[]; }; type QueryResolver = (call: QueryCall) => QueryResult; @@ -83,6 +85,12 @@ class QueryBuilder implements PromiseLike { return this; } + upsert(rows: unknown) { + this.call.upsert = true; + this.call.upsertRows = Array.isArray(rows) ? rows : [rows]; + return this; + } + maybeSingle() { this.call.maybeSingle = true; return Promise.resolve(this.resolver(this.call)); @@ -299,4 +307,49 @@ describe("registry records API", () => { expect(payload.record.slug).toBe("transport-crisis-form"); expect(client.from).not.toHaveBeenCalled(); }); + + it("seeds the curated default set for an owner with an empty registry", async () => { + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "clinical_registry_records") return ok([]); + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + return ok(stored); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/registry/records/route"); + const { serviceRecords } = await import("../src/lib/services"); + + const response = await GET(authedRequest("/api/registry/records?kind=service")); + const payload = (await response.json()) as { records: Array<{ slug: string }>; total: number }; + + expect(response.status).toBe(200); + // The empty owner is seeded once, owner-scoped, with the full default set. + const upsertCall = client.calls.find((call) => call.table === "clinical_registry_records" && call.upsert); + expect(upsertCall).toBeDefined(); + expect(upsertCall?.upsertRows).toHaveLength(serviceRecords.length); + expect( + (upsertCall?.upsertRows ?? []).every((row) => { + const typed = row as { owner_id: string; kind: string }; + return typed.owner_id === userId && typed.kind === "service"; + }), + ).toBe(true); + expect(payload.records).toHaveLength(serviceRecords.length); + expect(payload.total).toBe(serviceRecords.length); + }); + + it("does not seed when the owner already has registry records", 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")); + + expect(response.status).toBe(200); + expect(client.calls.some((call) => call.upsert)).toBe(false); + }); }); From f4fa3bcdef2bfb00e7a443969970f3171be1135c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:32:01 +0800 Subject: [PATCH 2/5] chore(db): prepare storage_cleanup_jobs index reconciliation (do not apply) Live carries legacy auto-named indexes (storage_cleanup_jobs_document_id_idx, _owner_id_idx, a non-partial _status_created_idx) that diverge from the names / definitions in supabase/schema.sql, even though the hardening migration that supersedes them is recorded as applied. Add an idempotent migration that drops the legacy names and (re)creates the intended named/partial indexes, plus a docs/process-hardening.md debt note. Functional-not-broken (the document_id FK is covered), so this is prepared for review only -- APPLY TO LIVE with explicit approval. Safe/no-op on a fresh db reset. schema.sql already matches the intended shape, so there is no schema change and the duplicate-stem schema test is unaffected. Co-Authored-By: Claude Fable 5 --- docs/process-hardening.md | 1 + ...reconcile_storage_cleanup_jobs_indexes.sql | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 supabase/migrations/20260703030000_reconcile_storage_cleanup_jobs_indexes.sql diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 58fdb831b6..01b8aa4672 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -89,6 +89,7 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons - **Known follow-up debts (documented, not actioned):** - Live migration history has duplicate-version churn (two each of `api_rate_limits`, `audit_logs`, `rag_queries_retention`, `audit_logs_service_role_policy`, `indexing_reliability_recovery`) from the same raw-apply habit. Do not rewrite history; treat as a caution for future applies. - Auth server is capped at 10 absolute DB connections (Supabase advisor); switch to percentage-based allocation in the dashboard before scaling instance size (not settable via SQL/MCP). + - `storage_cleanup_jobs` live indexes drifted from `supabase/schema.sql`: live carries legacy auto-names (`storage_cleanup_jobs_document_id_idx`, `storage_cleanup_jobs_owner_id_idx`, a non-partial `storage_cleanup_jobs_status_created_idx`) that the hardening defs superseded. Migration `20260703030000_reconcile_storage_cleanup_jobs_indexes` is **prepared but NOT applied** — it drops the legacy names and (re)creates the intended named/partial indexes to match schema.sql. Functional-not-broken (the document_id FK is covered), so apply to live only with explicit approval. `20260703000000`/`010000` are also absent from live `schema_migrations` and will self-heal on the next `supabase db push`. ## PR merge gate: tiered CI + required checks (2026-07-02) diff --git a/supabase/migrations/20260703030000_reconcile_storage_cleanup_jobs_indexes.sql b/supabase/migrations/20260703030000_reconcile_storage_cleanup_jobs_indexes.sql new file mode 100644 index 0000000000..3a2726796c --- /dev/null +++ b/supabase/migrations/20260703030000_reconcile_storage_cleanup_jobs_indexes.sql @@ -0,0 +1,48 @@ +-- Reconcile storage_cleanup_jobs indexes on the LIVE database with the names / +-- definitions already declared in supabase/schema.sql (and migration +-- 20260528007000_database_hardening_before_import.sql). +-- +-- Why: the live project (sjrfecxgysukkwxsowpy) carries legacy auto-generated +-- index names that predate the hardening migration, even though that migration +-- is recorded as applied in supabase_migrations.schema_migrations: +-- +-- live (legacy) intended (schema.sql) +-- storage_cleanup_jobs_document_id_idx -> storage_cleanup_jobs_document_idx +-- storage_cleanup_jobs_owner_id_idx -> storage_cleanup_jobs_owner_status_idx +-- (owner_id, status, created_at desc) +-- storage_cleanup_jobs_status_created_idx -> storage_cleanup_jobs_status_created_idx +-- (full) (partial: status in pending/failed) +-- +-- All three still cover their columns on live, so this is a cosmetic + +-- optimisation reconciliation, NOT a functional fix (the document_id FK is +-- already covered). It is prepared for review only. +-- +-- >>> DO NOT APPLY TO LIVE without explicit approval. <<< +-- +-- Idempotent and safe on a fresh `supabase db reset` too: the legacy names +-- never exist there so the drops are no-ops, and the intended indexes already +-- match schema.sql so the creates are no-ops. The status index shares its name +-- across both shapes, so it is dropped and recreated to guarantee the partial +-- form (a negligible rebuild of a small index). +-- +-- NOTE for the applier: these are plain (transactional) statements. If you want +-- a lock-free rebuild on a busy table, run the DROP/CREATE steps manually with +-- CONCURRENTLY *outside* a transaction instead -- CONCURRENTLY cannot run inside +-- the migration transaction. storage_cleanup_jobs is small, so the brief lock +-- from the plain form is normally fine. + +-- document_id FK covering index: legacy auto-name -> intended name +drop index if exists storage_cleanup_jobs_document_id_idx; +create index if not exists storage_cleanup_jobs_document_idx + on public.storage_cleanup_jobs(document_id); + +-- owner index: legacy single-column -> intended composite +drop index if exists storage_cleanup_jobs_owner_id_idx; +create index if not exists storage_cleanup_jobs_owner_status_idx + on public.storage_cleanup_jobs(owner_id, status, created_at desc); + +-- status index: same name, ensure the partial (pending/failed) form +drop index if exists storage_cleanup_jobs_status_created_idx; +create index if not exists storage_cleanup_jobs_status_created_idx + on public.storage_cleanup_jobs(status, created_at) + where status in ('pending', 'failed'); From 85d4f4f1e733eec2972789d73953902ebec89ed6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:56:57 +0800 Subject: [PATCH 3/5] feat(favourites): surface saved services & forms in the hub The Favourites hub rendered only prototype data, so a user's saved services/forms (localStorage clinical-kb-saved-*) never appeared. Add "services"/"forms" favourite types + tabs (additive; the mockup uses its own local fixtures, so no blast radius) and a useSavedRegistryFavourites hook that hydrates the saved slugs via useRegistryRecords -- fetch-gated on there being saved items, so the empty case makes no request. Prototype items remain for the not-yet-backed categories. Adds a Chromium test that seeds a saved slug and asserts the hydrated title appears. Co-Authored-By: Claude Fable 5 --- .../clinical-dashboard/favourites-hub.tsx | 23 ++++-- .../favourites-prototype-data.ts | 6 +- .../use-saved-registry-favourites.ts | 77 +++++++++++++++++++ tests/ui-smoke.spec.ts | 24 ++++++ 4 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 src/components/clinical-dashboard/use-saved-registry-favourites.ts diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index d1ee15172e..47d6a08575 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -1,7 +1,7 @@ "use client"; import { ArrowUpDown, ChevronDown, Filter, Folder, FolderInput, Heart, Plus, Search, X } from "lucide-react"; -import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import { useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { ModeHomeHero } from "@/components/mode-home-template"; import { cn, floatingControl, iconTilePremium, panelSubtle, primaryControl } from "@/components/ui-primitives"; @@ -9,11 +9,11 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, } from "@/components/clinical-dashboard/favourites-prototype-data"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; function favouriteMatchesQuery(value: { title: string; meta?: string; set?: string; keywords: string }, query: string) { const normalized = query.trim().toLowerCase(); @@ -45,11 +45,20 @@ export function FavouritesHub({ const tabButtonRef = useRef(null); const tabOptionRefs = useRef>([]); const normalizedQuery = query.trim(); + // Real saved services/forms (localStorage slugs hydrated via the registry + // API) merged in alongside the prototype items for the not-yet-backed types. + const savedRegistryItems = useSavedRegistryFavourites(); + const allItems = useMemo(() => [...favouriteItems, ...savedRegistryItems], [savedRegistryItems]); + const countType = (type: FavouriteTabId) => { + if (type === "all") return allItems.length + favouriteSets.length; + if (type === "sets") return favouriteSets.length; + return allItems.filter((item) => item.type === type).length; + }; const selectedSet = selectedSetId ? favouriteSets.find((set) => set.id === selectedSetId) : null; const tabItems = selectedTab === "all" || selectedTab === "sets" - ? favouriteItems - : favouriteItems.filter((item) => item.type === selectedTab); + ? allItems + : allItems.filter((item) => item.type === selectedTab); const visibleItems = tabItems .filter((item) => favouriteMatchesQuery(item, normalizedQuery)) .filter((item) => !selectedSet || item.set === selectedSet.title); @@ -59,9 +68,9 @@ export function FavouritesHub({ const empty = (!showItems || visibleItems.length === 0) && (!showSets || visibleSets.length === 0); const selectedTabMeta = favouriteTabs.find((tab) => tab.id === selectedTab) ?? favouriteTabs[0]; const selectedTabLabel = selectedTabMeta.label; - const selectedTabCount = favouriteTypeCount(selectedTab); + const selectedTabCount = countType(selectedTab); const SelectedTabIcon = selectedTabMeta.icon; - const itemCount = favouriteItems.length; + const itemCount = allItems.length; const setCount = favouriteSets.length; const activeFilterCount = (normalizedQuery ? 1 : 0) + (selectedSet ? 1 : 0); const selectedTabIndex = Math.max( @@ -199,7 +208,7 @@ export function FavouritesHub({ {favouriteTabs.map((tab, index) => { const Icon = tab.icon; const selected = selectedTab === tab.id; - const count = favouriteTypeCount(tab.id); + const count = countType(tab.id); return (