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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)

Expand Down
13 changes: 5 additions & 8 deletions scripts/seed-registry-records.ts
Original file line numberDiff line numberDiff line change
@@ -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());
Expand DownExpand Up@@ -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() {
Expand All@@ -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) {
Expand Down
46 changes: 36 additions & 10 deletions src/app/api/registry/records/[slug]/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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")
Expand Down
36 changes: 27 additions & 9 deletions src/app/api/registry/records/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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)]));

Expand Down
23 changes: 15 additions & 8 deletions src/components/clinical-dashboard/favourites-hub.tsx
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
"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";
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();
Expand DownExpand Up@@ -45,11 +45,18 @@ export function FavouritesHub({
const tabButtonRef = useRef<HTMLButtonElement | null>(null);
const tabOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
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);
Expand All@@ -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(
Expand DownExpand Up@@ -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 (
<button
key={tab.id}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react";
import { ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search, Stethoscope } from "lucide-react";

export type FavouriteType = "medications" | "documents" | "sources" | "sets";
export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "sets";
export type FavouriteTabId = "all" | FavouriteType;

export type FavouriteItem = {
Expand DownExpand Up@@ -33,6 +33,8 @@ export const favouriteTabs: Array<{
{ id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill },
{ id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText },
{ id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote },
{ id: "services", label: "Services", shortLabel: "Services", icon: Stethoscope },
{ id: "forms", label: "Forms", shortLabel: "Forms", icon: ClipboardList },
{ id: "sets", label: "Sets", shortLabel: "Sets", icon: Folder },
];

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
"use client";

import { ClipboardList, Stethoscope } from "lucide-react";
import { useEffect, useMemo, useState } from "react";

import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data";
import type { ServiceRecord } from "@/lib/services";
import { useRegistryRecords } from "@/lib/use-registry-records";

// localStorage keys written by the service/form detail pages when a record is
// saved (see service-detail-page.tsx / form-detail-page.tsx).
const savedServicesKey = "clinical-kb-saved-services";
const savedFormsKey = "clinical-kb-saved-forms";

function readSavedSlugs(key: string): string[] {
try {
const raw = window.localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
}

function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): FavouriteItem {
return {
id: `${type}:${record.slug}`,
title: record.title,
type,
set: "",
meta: record.subtitle ?? (type === "services" ? "Saved service" : "Saved form"),
sourceMeta: type === "services" ? "Service" : "Form",
primaryAction: "Open",
Comment on lines +27 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wire saved favourites to detail routes

For saved services/forms this new item only carries id: ${type}:${record.slug} and labels the action as Open; FavouriteItemRow renders that as a plain button with no handler, and the mobile chevron is also inert. When a user opens /favourites after saving a service or form, the hydrated item appears but cannot navigate back to /services/{slug} or /forms/{slug}, so the new saved-registry favourite is display-only. Carry an href/slug through this shape and route the primary/mobile action based on type.

Useful? React with 👍 / 👎.

icon: type === "services" ? Stethoscope : ClipboardList,
keywords: [record.title, record.subtitle, ...(record.tags ?? [])].filter(Boolean).join(" ").toLowerCase(),
};
}

/**
* Hydrate the user's saved services/forms into the FavouriteItem shape the hub
* renders. Slugs live in localStorage; titles/metadata come from the owner's
* registry via useRegistryRecords (demo mode serves fixtures, so this also
* works env-less). Fetching is gated on there being saved slugs, so the common
* "nothing saved" case makes no request.
*/
export function useSavedRegistryFavourites(): FavouriteItem[] {
const [savedServices, setSavedServices] = useState<string[]>([]);
const [savedForms, setSavedForms] = useState<string[]>([]);

useEffect(() => {
const refresh = () => {
setSavedServices(readSavedSlugs(savedServicesKey));
setSavedForms(readSavedSlugs(savedFormsKey));
Comment on lines +52 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope saved registry favourites by user

These fixed localStorage keys are read without considering the authenticated user, so if two accounts use the same browser profile the second account's /favourites page hydrates slugs saved by the first account (and the registry API will even seed/hydrate those defaults for the second owner). This makes saved services/forms leak across account switches on a shared workstation; include the current user id in the storage key or clear/reload these values on auth changes, and update the detail-page writers consistently.

Useful? React with 👍 / 👎.

};
refresh();
window.addEventListener("storage", refresh);
return () => window.removeEventListener("storage", refresh);
}, []);

const services = useRegistryRecords("service", { enabled: savedServices.length > 0 });
const forms = useRegistryRecords("form", { enabled: savedForms.length > 0 });

return useMemo(() => {
const savedServiceSet = new Set(savedServices);
const savedFormSet = new Set(savedForms);
const serviceItems = services.records
.filter((record) => savedServiceSet.has(record.slug))
.map((record) => recordToFavourite(record, "services"));
const formItems = forms.records
.filter((record) => savedFormSet.has(record.slug))
.map((record) => recordToFavourite(record, "forms"));
return [...serviceItems, ...formItems];
}, [services.records, forms.records, savedServices, savedForms]);
}
51 changes: 51 additions & 0 deletions src/lib/registry-seed.ts
Original file line numberDiff line numberDiff line change
@@ -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<typeof import("@/lib/supabase/admin").createAdminClient>;

/** 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<RegistryRecordRow[]> {
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[];
}
Loading
Loading