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/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..b095ae26f2 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,42 @@ 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) { + // Only the seed write is best-effort; the re-read stays outside the try + // so a genuine read failure surfaces rather than a misleading 404. + try { + await ensureRegistrySeeded(supabase, user.id, kind); + } catch (seedError) { + console.error(`[registry] auto-seed failed for owner ${user.id} (${kind})`, seedError); + } + row = await fetchRecord(); + } + } + 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..b2e1d666da 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,33 @@ 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. Only + // the seed write is best-effort (a failure falls back to the empty set); + // the re-read stays outside the try so a genuine read failure still + // surfaces as an error rather than a misleading empty registry. + try { + await ensureRegistrySeeded(supabase, user.id, kind); + } catch (seedError) { + console.error(`[registry] auto-seed failed for owner ${user.id} (${kind})`, seedError); + } + rows = await fetchRecords(); + } - 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/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index d1ee15172e..9a3080d784 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,18 @@ 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); + selectedTab === "all" || selectedTab === "sets" ? allItems : allItems.filter((item) => item.type === selectedTab); const visibleItems = tabItems .filter((item) => favouriteMatchesQuery(item, normalizedQuery)) .filter((item) => !selectedSet || item.set === selectedSet.title); @@ -59,9 +66,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 +206,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 (