- Notifications
You must be signed in to change notification settings - Fork 0
Wire Services/Forms to a Supabase registry (hybrid); label Differentials demo content#209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
8b3e6383998f715d57145d865280ce255c64f4cf40faa302b32fd31ac71766ac7f66c42d25f088f094adFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import { loadEnvConfig } from "@next/env"; | ||
| import { confirm } from "./cli-utils"; | ||
| import { recordToRow, type RegistryRecordKind } from "@/lib/registry-records"; | ||
| import { serviceRecords } from "@/lib/services"; | ||
| import { formRecords } from "@/lib/forms"; | ||
| import type { ServiceRecord } from "@/lib/services"; | ||
| loadEnvConfig(process.cwd()); | ||
| type SeedArgs = { | ||
| ownerId?: string; | ||
| kind: RegistryRecordKind | "all"; | ||
| write: boolean; | ||
| confirmed: boolean; | ||
| }; | ||
| async function loadAdminClient() { | ||
| const { createAdminClient } = await import("@/lib/supabase/admin"); | ||
| return createAdminClient(); | ||
| } | ||
| function parseArgs(argv: string[]): SeedArgs { | ||
| const args: SeedArgs = { | ||
| ownerId: process.env.LOCAL_NO_AUTH_OWNER_ID, | ||
| kind: "all", | ||
| write: false, | ||
| confirmed: false, | ||
| }; | ||
| for (let index = 0; index < argv.length; index += 1) { | ||
| const token = argv[index]; | ||
| if (token === "--write") { | ||
| args.write = true; | ||
| continue; | ||
| } | ||
| if (token === "--confirm") { | ||
| args.confirmed = true; | ||
| continue; | ||
| } | ||
| if (token === "--owner-id") { | ||
| args.ownerId = argv[index + 1]; | ||
| index += 1; | ||
| continue; | ||
| } | ||
| if (token === "--kind") { | ||
| const value = argv[index + 1]; | ||
| if (value !== "service" && value !== "form" && value !== "all") { | ||
| throw new Error(`Invalid --kind value: ${value ?? "(missing)"}. Use service | form | all.`); | ||
| } | ||
| args.kind = value; | ||
| index += 1; | ||
| continue; | ||
| } | ||
| throw new Error(`Unknown argument: ${token}`); | ||
| } | ||
| return args; | ||
| } | ||
| function seedSets(kind: SeedArgs["kind"]): Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> { | ||
| const sets: Array<{ kind: RegistryRecordKind; records: ServiceRecord[] }> = []; | ||
| if (kind === "service" || kind === "all") sets.push({ kind: "service", records: serviceRecords }); | ||
| if (kind === "form" || kind === "all") sets.push({ kind: "form", records: formRecords }); | ||
| return sets; | ||
| } | ||
| async function main() { | ||
| const args = parseArgs(process.argv.slice(2)); | ||
| if (!args.ownerId) { | ||
| throw new Error("No owner id. Pass --owner-id <uuid> or set LOCAL_NO_AUTH_OWNER_ID."); | ||
| } | ||
| const sets = seedSets(args.kind); | ||
| const rows = sets.flatMap((set) => set.records.map((record) => recordToRow(record, args.ownerId!, set.kind))); | ||
| console.log(`[registry:seed] owner ${args.ownerId}`); | ||
| for (const row of rows) { | ||
| console.log( | ||
| ` ${row.kind.padEnd(7)} ${String(row.slug).padEnd(42)} source_status=${row.source_status} validation_status=${row.validation_status}`, | ||
| ); | ||
| } | ||
| console.log( | ||
| `[registry:seed] ${rows.length} records (${sets.map((s) => `${s.records.length} ${s.kind}`).join(", ")})`, | ||
| ); | ||
| if (!args.write) { | ||
| console.log("[registry:seed] Dry run. Re-run with --write --confirm to upsert."); | ||
| return; | ||
| } | ||
| if (!args.confirmed) { | ||
| const proceed = await confirm(`Upsert ${rows.length} registry records for owner ${args.ownerId}?`); | ||
| if (!proceed) { | ||
| console.log("[registry:seed] Aborted."); | ||
| return; | ||
| } | ||
| } | ||
| const supabase = await loadAdminClient(); | ||
| const { error } = await supabase.from("clinical_registry_records").upsert(rows, { onConflict: "owner_id,kind,slug" }); | ||
| 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; | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { z } from "zod"; | ||
| import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; | ||
| import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; | ||
| import { jsonError } from "@/lib/http"; | ||
| import { getFormRecord } from "@/lib/forms"; | ||
| import { | ||
| normalizeRegistrySlug, | ||
| rowGovernance, | ||
| rowToServiceRecord, | ||
| type RegistryRecordRow, | ||
| } from "@/lib/registry-records"; | ||
| import { getServiceRecord } from "@/lib/services"; | ||
| import { createAdminClient } from "@/lib/supabase/admin"; | ||
| import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; | ||
| import { parseRequestQuery } from "@/lib/validation/query"; | ||
| export const runtime = "nodejs"; | ||
| const registryDetailQuerySchema = z.object({ | ||
| kind: z.enum(["service", "form"]), | ||
| }); | ||
| function registryResponse(payload: Record<string, unknown>, init?: { status?: number }) { | ||
| return NextResponse.json(payload, { | ||
| status: init?.status ?? 200, | ||
| headers: { "Cache-Control": "private, no-store" }, | ||
| }); | ||
| } | ||
| function notFoundResponse(slug: string) { | ||
| return registryResponse({ error: `No registry record found for "${slug}".` }, { status: 404 }); | ||
| } | ||
| export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { | ||
| try { | ||
| const { slug } = await context.params; | ||
| const normalizedSlug = normalizeRegistrySlug(slug); | ||
| const { kind } = parseRequestQuery(request, registryDetailQuerySchema, "Invalid registry detail query."); | ||
| if (isDemoMode()) { | ||
| const record = kind === "form" ? getFormRecord(normalizedSlug) : getServiceRecord(normalizedSlug); | ||
| if (!record) return notFoundResponse(normalizedSlug); | ||
| return registryResponse({ record, linkedDocuments: [], demoMode: true }); | ||
| } | ||
| const supabase = createAdminClient(); | ||
| const user = await requireAuthenticatedUser(request, supabase); | ||
| const rateLimit = await consumeApiRateLimit({ | ||
| supabase, | ||
| ownerId: user.id, | ||
| bucket: "registry", | ||
| allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), | ||
| }); | ||
| if (rateLimit.limited) { | ||
| return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); | ||
| } | ||
| const { data, error } = await supabase | ||
| .from("clinical_registry_records") | ||
| .select("*") | ||
| .eq("owner_id", user.id) | ||
| .eq("kind", kind) | ||
| .eq("slug", normalizedSlug) | ||
| .maybeSingle(); | ||
| if (error) throw new Error(error.message); | ||
| if (!data) return notFoundResponse(normalizedSlug); | ||
| const row = data as RegistryRecordRow; | ||
| const { data: links, error: linksError } = await supabase | ||
| .from("clinical_registry_record_sources") | ||
| .select("document_id, note") | ||
| .eq("owner_id", user.id) | ||
| .eq("record_id", row.id); | ||
| if (linksError) throw new Error(linksError.message); | ||
| let linkedDocuments: Array<{ id: string; title: string; file_name: string; status: string }> = []; | ||
| const documentIds = (links ?? []).map((link) => link.document_id); | ||
| if (documentIds.length > 0) { | ||
| const { data: documents, error: documentsError } = await supabase | ||
| .from("documents") | ||
| .select("id, title, file_name, status") | ||
| .eq("owner_id", user.id) | ||
| .in("id", documentIds); | ||
| if (documentsError) throw new Error(documentsError.message); | ||
| linkedDocuments = (documents ?? []) as typeof linkedDocuments; | ||
| } | ||
| return registryResponse({ | ||
| record: rowToServiceRecord(row), | ||
| governance: rowGovernance(row), | ||
| linkedDocuments, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof AuthenticationError) { | ||
| return unauthorizedResponse(); | ||
| } | ||
| return jsonError(error); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { z } from "zod"; | ||
| import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; | ||
| import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; | ||
| import { jsonError } from "@/lib/http"; | ||
| import { rankFormRecords, formRecords } from "@/lib/forms"; | ||
| import { | ||
| deriveGovernanceColumns, | ||
| rowGovernance, | ||
| rowToServiceRecord, | ||
| type RegistryRecordKind, | ||
| type RegistryRecordRow, | ||
| } from "@/lib/registry-records"; | ||
| import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/services"; | ||
| import { createAdminClient } from "@/lib/supabase/admin"; | ||
| import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; | ||
| import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; | ||
| export const runtime = "nodejs"; | ||
| // The list is a small curated per-owner set that clients fetch in full and | ||
| // rank client-side, so the whole set must be returned (never truncated) or | ||
| // rows past the cap become invisible to Services/Forms search and undercount | ||
| // the home footers. This ceiling is a defensive bound well above realistic | ||
| // registry sizes; `limit` only bounds the ranked `matches` for a `q` query. | ||
| const REGISTRY_MAX_RECORDS = 500; | ||
| const registryListQuerySchema = z.object({ | ||
| kind: z.enum(["service", "form"]), | ||
| q: z | ||
| .string() | ||
| .trim() | ||
| .max(200) | ||
| .optional() | ||
| .transform((value) => (value ? value : undefined)), | ||
| limit: queryInteger({ fallback: 100, min: 1, max: 200 }), | ||
| }); | ||
| function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: string, limit: number) { | ||
| return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit); | ||
| } | ||
| function registryResponse(payload: Record<string, unknown>) { | ||
| return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); | ||
| } | ||
| function matchesPayload(matches: ServiceSearchMatch[]) { | ||
| return matches.map((match) => ({ record: match.service, score: match.score, reasons: match.reasons })); | ||
| } | ||
| export async function GET(request: Request) { | ||
| try { | ||
| const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query."); | ||
| if (isDemoMode()) { | ||
| const records = kind === "form" ? formRecords : serviceRecords; | ||
| const governance = Object.fromEntries( | ||
| records.map((record) => { | ||
| const derived = deriveGovernanceColumns(record); | ||
| return [record.slug, { sourceStatus: derived.source_status, validationStatus: derived.validation_status }]; | ||
| }), | ||
| ); | ||
| return registryResponse({ | ||
| records, | ||
| matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, | ||
| total: records.length, | ||
| governance, | ||
| demoMode: true, | ||
| }); | ||
| } | ||
| const supabase = createAdminClient(); | ||
| const user = await requireAuthenticatedUser(request, supabase); | ||
| const rateLimit = await consumeApiRateLimit({ | ||
| supabase, | ||
| ownerId: user.id, | ||
| bucket: "registry", | ||
| allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), | ||
| }); | ||
| if (rateLimit.limited) { | ||
| return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); | ||
| } | ||
| const { data, error } = await supabase | ||
| .from("clinical_registry_records") | ||
| .select("*") | ||
| .eq("owner_id", user.id) | ||
| .eq("kind", kind) | ||
| .order("title") | ||
| .limit(REGISTRY_MAX_RECORDS); | ||
| if (error) throw new Error(error.message); | ||
| const rows = (data ?? []) as RegistryRecordRow[]; | ||
| const records = rows.map(rowToServiceRecord); | ||
| const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); | ||
| const recordIds = rows.map((row) => row.id); | ||
| const linkedDocumentIdsByRecordId: Record<string, string[]> = {}; | ||
| if (recordIds.length > 0) { | ||
| const { data: links, error: linksError } = await supabase | ||
| .from("clinical_registry_record_sources") | ||
| .select("record_id, document_id") | ||
| .eq("owner_id", user.id) | ||
| .in("record_id", recordIds); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an owner has hundreds of registry rows, this Useful? React with 👍 / 👎. | ||
| if (linksError) throw new Error(linksError.message); | ||
| for (const link of links ?? []) { | ||
| const existing = linkedDocumentIdsByRecordId[link.record_id] ?? []; | ||
| existing.push(link.document_id); | ||
| linkedDocumentIdsByRecordId[link.record_id] = existing; | ||
| } | ||
| } | ||
| const linkedDocumentIdsBySlug = Object.fromEntries( | ||
| rows.map((row) => [row.slug, linkedDocumentIdsByRecordId[row.id] ?? []]), | ||
| ); | ||
| return registryResponse({ | ||
| records, | ||
| matches: q ? matchesPayload(rankRecords(kind, records, q, limit)) : undefined, | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| total: rows.length, | ||
| governance: governanceBySlug, | ||
| linkedDocumentIds: linkedDocumentIdsBySlug, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof AuthenticationError) { | ||
| return unauthorizedResponse(); | ||
| } | ||
| return jsonError(error); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.