feat: HL7/FHIR, e-prescribing & insurance claims integrations

Real, standards-compliant integration clients that the clinic points at
its own (sandbox or production) endpoints — no mock data.

backend:
- `integrations` table (per org+type) storing endpoint + encrypted
  credentials (reusing the AI-key crypto) + status; Drizzle migration
- services/integrations:
  - fhir.ts — FHIR R4 REST client (pull lab Observations → patient
    record), HL7 v2 ORU parsing, capability-statement connection test
  - eprescribe.ts — NCPDP SCRIPT NewRx message build + transmit
  - claims.ts — X12 837P claim generation + 835 remittance parsing
- `/api/integrations` route: config GET/PUT (owner/admin), connection
  test, and the FHIR sync / HL7 ingest / e-Rx send / claim submit actions,
  RBAC-gated (lab/patient, prescription, invoice)

frontend:
- lib/integrations.ts client
- Settings → Integrations tab to configure endpoints/credentials/enable
  + test each integration
- on-page actions, shown only when the integration is enabled:
  Lab page → FHIR "Sync results" card; prescription sheet → "Send to
  pharmacy"; invoice sheet → "Submit claim"

Production e-Rx/claims routing requires the clinic's own Surescripts /
clearinghouse credentials; the code transmits real messages once supplied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-18 20:15:00 +03:00
parent b1abb29108
commit 43eaccb97e
20 changed files with 5474 additions and 5 deletions
@@ -0,0 +1,115 @@
"use client";
import { FileCheck, Send } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ApiError } from "@/lib/api-client";
import {
getIntegration,
sendEprescription,
submitInsuranceClaim,
} from "@/lib/integrations";
import { notify } from "@/lib/toast";
// Renders only when the e-prescribing integration is enabled. Transmits the
// prescription as an NCPDP SCRIPT NewRx to the configured pharmacy gateway.
export function SendToPharmacyButton({ rxId }: { rxId: string }) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [sending, setSending] = useState(false);
useEffect(() => {
let active = true;
getIntegration("eprescribe").then(
(c) => active && setEnabled(Boolean(c?.enabled)),
);
return () => {
active = false;
};
}, []);
if (!enabled) return null;
const send = async () => {
setSending(true);
try {
await sendEprescription(rxId);
notify.success(
t("integrations.eRx.sentTitle"),
t("integrations.eRx.sentBody"),
);
} catch (err) {
notify.error(
t("integrations.eRx.failedTitle"),
err instanceof ApiError ? err.message : t("integrations.eRx.failedBody"),
);
} finally {
setSending(false);
}
};
return (
<Button disabled={sending} onClick={send} type="button">
<Send className="size-4" />
{sending ? t("integrations.eRx.sending") : t("integrations.eRx.send")}
</Button>
);
}
// Renders only when the claims integration is enabled. Submits an X12 837P claim
// for the invoice to the configured clearinghouse and reports the remittance.
export function SubmitClaimButton({ invoiceId }: { invoiceId: string }) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
getIntegration("claims").then(
(c) => active && setEnabled(Boolean(c?.enabled)),
);
return () => {
active = false;
};
}, []);
if (!enabled) return null;
const submit = async () => {
setSubmitting(true);
try {
const result = await submitInsuranceClaim(invoiceId);
notify.success(
t("integrations.claims.submittedTitle"),
t("integrations.claims.submittedBody", {
status: result.claimStatus,
}),
);
} catch (err) {
notify.error(
t("integrations.claims.failedTitle"),
err instanceof ApiError
? err.message
: t("integrations.claims.failedBody"),
);
} finally {
setSubmitting(false);
}
};
return (
<Button
disabled={submitting}
onClick={submit}
type="button"
variant="outline"
>
<FileCheck className="size-4" />
{submitting
? t("integrations.claims.submitting")
: t("integrations.claims.submit")}
</Button>
);
}
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { SubmitClaimButton } from "@/components/integrations/integration-actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -357,6 +358,7 @@ export function InvoiceDetailSheet({
<Download className="size-4" />
{t("invoices.sheet.download")}
</Button>
<SubmitClaimButton invoiceId={invoice.id} />
{isSettled ? null : (
<Button disabled={busy} onClick={payAll} type="button">
<CircleCheck className="size-4" />
@@ -0,0 +1,176 @@
"use client";
import { Cable, RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ApiError } from "@/lib/api-client";
import {
getIntegration,
type IntegrationConfig,
syncFhirLabs,
} from "@/lib/integrations";
import type { Patient } from "@/lib/patients";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
// The Lab page's HL7/FHIR connection panel: shows the connection status and, when
// enabled, lets staff pull a patient's results from the configured lab system.
export function LabIntegrationCard({
patients,
onSynced,
}: {
patients: Patient[];
onSynced: () => void;
}) {
const { t } = useTranslation();
const [config, setConfig] = useState<IntegrationConfig | null>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
let active = true;
getIntegration("fhir").then((c) => active && setConfig(c));
return () => {
active = false;
};
}, []);
const search = query.trim().toLowerCase();
const matches = useMemo(() => {
if (!search) return [];
return patients
.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.fileNumber.includes(search),
)
.slice(0, 5);
}, [patients, search]);
// Hide entirely until we know the config (avoids a flash); render a muted
// "configure me" card when present but disabled.
if (!config) return null;
const sync = async () => {
if (!selected) return;
setSyncing(true);
try {
const { imported } = await syncFhirLabs(selected.fileNumber);
notify.success(
t("integrations.fhir.syncedTitle"),
t("integrations.fhir.syncedBody", {
count: imported,
name: selected.name,
}),
);
setSelected(null);
setQuery("");
onSynced();
} catch (err) {
notify.error(
t("integrations.fhir.failedTitle"),
err instanceof ApiError
? err.message
: t("integrations.fhir.failedBody"),
);
} finally {
setSyncing(false);
}
};
return (
<section className="flex flex-col gap-3 rounded-2xl border bg-card/30 p-4">
<div className="flex items-center gap-2">
<Cable className="size-4 text-muted-foreground" />
<h2 className="font-medium text-foreground text-sm">
{t("integrations.fhir.cardTitle")}
</h2>
<Badge
className="ml-auto"
variant={
config.status === "connected"
? "secondary"
: config.status === "error"
? "destructive"
: "outline"
}
>
{t(`settings.integrations.status.${config.status}`)}
</Badge>
</div>
{!config.enabled ? (
<p className="text-muted-foreground text-sm">
{t("integrations.fhir.disabledHint")}
</p>
) : selected ? (
<div className="flex items-center gap-3 rounded-xl border bg-background/40 px-3 py-2">
<Avatar className="size-8">
<AvatarFallback>{selected.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
#{selected.fileNumber}
</span>
</div>
<Button
disabled={syncing}
onClick={() => setSelected(null)}
size="sm"
type="button"
variant="ghost"
>
{t("integrations.fhir.change")}
</Button>
<Button disabled={syncing} onClick={sync} size="sm" type="button">
<RefreshCw className={cn("size-4", syncing && "animate-spin")} />
{syncing
? t("integrations.fhir.syncing")
: t("integrations.fhir.sync")}
</Button>
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="pl-9"
onChange={(e) => setQuery(e.target.value)}
placeholder={t("integrations.fhir.searchPlaceholder")}
value={query}
/>
</div>
{matches.length > 0 && (
<div className="flex flex-col gap-1">
{matches.map((p) => (
<button
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => setSelected(p)}
type="button"
>
<Avatar className="size-7">
<AvatarFallback>{p.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 truncate text-sm">{p.name}</span>
<span className="ms-auto text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))}
</div>
)}
</div>
)}
</section>
);
}
+13
View File
@@ -39,6 +39,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { LabIntegrationCard } from "@/components/lab/lab-integration-card";
import { StagedFilesField } from "@/components/patients/patient-files";
import { uploadAttachment } from "@/lib/attachments";
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
@@ -609,6 +610,18 @@ export function LabView() {
</Button>
</div>
<LabIntegrationCard
onSynced={() =>
listPatients()
.then((data) => {
setPatients(data);
setRecent(buildRecent(data));
})
.catch(() => {})
}
patients={patients}
/>
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">
@@ -3,6 +3,7 @@
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendToPharmacyButton } from "@/components/integrations/integration-actions";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -142,12 +143,20 @@ export function PrescriptionDetailSheet({
</div>
)}
</SheetPanel>
{rx && onDelete && (
{rx && (
<SheetFooter>
<Button onClick={onDelete} type="button" variant="destructive">
<Trash2 className="size-4" />
{t("prescriptions.detail.delete")}
</Button>
{onDelete && (
<Button
className="sm:mr-auto"
onClick={onDelete}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
{t("prescriptions.detail.delete")}
</Button>
)}
<SendToPharmacyButton rxId={rx.id} />
</SheetFooter>
)}
</SheetPopup>
@@ -0,0 +1,236 @@
"use client";
import { CheckCircle2, CircleDashed, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
FieldLabel,
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
type IntegrationConfig,
type IntegrationType,
listIntegrations,
saveIntegration,
testIntegration,
} from "@/lib/integrations";
import { notify } from "@/lib/toast";
const TYPES: IntegrationType[] = ["fhir", "eprescribe", "claims"];
function StatusBadge({ config }: { config: IntegrationConfig }) {
const { t } = useTranslation();
if (config.status === "connected") {
return (
<Badge className="gap-1" variant="secondary">
<CheckCircle2 className="size-3" />
{t("settings.integrations.status.connected")}
</Badge>
);
}
if (config.status === "error") {
return (
<Badge className="gap-1" variant="destructive">
<XCircle className="size-3" />
{t("settings.integrations.status.error")}
</Badge>
);
}
return (
<Badge className="gap-1" variant="outline">
<CircleDashed className="size-3" />
{t("settings.integrations.status.unconfigured")}
</Badge>
);
}
function IntegrationCard({
type,
initial,
}: {
type: IntegrationType;
initial: IntegrationConfig;
}) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState(initial.endpoint);
const [enabled, setEnabled] = useState(initial.enabled);
// Empty = leave the stored secret untouched; typing replaces it.
const [credentials, setCredentials] = useState("");
const [config, setConfig] = useState(initial);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const dirty =
endpoint !== config.endpoint ||
enabled !== config.enabled ||
credentials.length > 0;
const save = async () => {
setSaving(true);
try {
const saved = await saveIntegration(type, {
endpoint,
enabled,
...(credentials ? { credentials } : {}),
});
setConfig(saved);
setEndpoint(saved.endpoint);
setEnabled(saved.enabled);
setCredentials("");
notify.success(
t("settings.integrations.savedTitle"),
t(`settings.integrations.${type}.title`),
);
} catch {
notify.error(
t("settings.integrations.saveFailedTitle"),
t("settings.integrations.saveFailedBody"),
);
} finally {
setSaving(false);
}
};
const test = async () => {
setTesting(true);
try {
const result = await testIntegration(type);
if (result.ok) {
notify.success(t("settings.integrations.testOk"), result.message);
} else {
notify.error(t("settings.integrations.testFailed"), result.message);
}
} catch {
notify.error(
t("settings.integrations.testFailed"),
t("settings.integrations.testError"),
);
} finally {
setTesting(false);
}
};
return (
<SettingsSection
action={<StatusBadge config={config} />}
description={t(`settings.integrations.${type}.description`)}
title={t(`settings.integrations.${type}.title`)}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.endpoint")}</FieldLabel>
<Input
onChange={(e) => setEndpoint(e.target.value)}
placeholder={t(`settings.integrations.${type}.endpointPlaceholder`)}
value={endpoint}
/>
</div>
<div className="space-y-1.5">
<FieldLabel>{t("settings.integrations.credentials")}</FieldLabel>
<Input
autoComplete="off"
onChange={(e) => setCredentials(e.target.value)}
placeholder={
config.hasCredentials
? t("settings.integrations.credentialsSet")
: t(`settings.integrations.${type}.credentialsPlaceholder`)
}
type="password"
value={credentials}
/>
<p className="text-xs text-muted-foreground">
{t(`settings.integrations.${type}.credentialsHint`)}
</p>
</div>
<label className="flex items-center justify-between gap-4 rounded-2xl border bg-card/30 px-4 py-3">
<span className="space-y-0.5">
<span className="block text-sm font-medium">
{t("settings.integrations.enable")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.integrations.enableHint")}
</span>
</span>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</label>
<div className="flex items-center gap-2">
<Button disabled={saving || !dirty} onClick={save} size="sm">
{saving
? t("settings.integrations.saving")
: t("settings.integrations.save")}
</Button>
<Button
disabled={testing}
onClick={test}
size="sm"
variant="outline"
>
{testing
? t("settings.integrations.testing")
: t("settings.integrations.test")}
</Button>
{config.lastSyncAt ? (
<span className="ml-auto text-xs text-muted-foreground">
{t("settings.integrations.lastSync", {
when: new Date(config.lastSyncAt).toLocaleString(),
})}
</span>
) : null}
</div>
</SettingsCard>
</SettingsSection>
);
}
export function IntegrationsPanel() {
const { t } = useTranslation();
const [configs, setConfigs] = useState<IntegrationConfig[] | null>(null);
useEffect(() => {
let active = true;
listIntegrations()
.then((rows) => active && setConfigs(rows))
.catch(() => active && setConfigs([]));
return () => {
active = false;
};
}, []);
if (configs === null) {
return (
<p className="text-sm text-muted-foreground">
{t("settings.integrations.loading")}
</p>
);
}
return (
<div className="space-y-8">
<p className="text-sm text-muted-foreground">
{t("settings.integrations.intro")}
</p>
{TYPES.map((type) => {
const initial =
configs.find((c) => c.type === type) ??
({
type,
endpoint: "",
enabled: false,
status: "unconfigured",
hasCredentials: false,
lastSyncAt: null,
} satisfies IntegrationConfig);
return <IntegrationCard initial={initial} key={type} type={type} />;
})}
</div>
);
}
@@ -8,6 +8,7 @@ import { AIPanel } from "@/components/settings/settings-ai";
import { SigningPanel } from "@/components/settings/settings-billing";
import { CareTeamPanel } from "@/components/settings/settings-care-team";
import { DevelopersPanel } from "@/components/settings/settings-developers";
import { IntegrationsPanel } from "@/components/settings/settings-integrations";
import { ProfilePanel } from "@/components/settings/settings-preferences";
import { RecordsPanel } from "@/components/settings/settings-records";
import { useActiveRole } from "@/lib/roles";
@@ -18,6 +19,7 @@ const TABS = [
{ id: "records", labelKey: "settings.tabs.records" },
{ id: "signing", labelKey: "settings.tabs.signing" },
{ id: "careTeam", labelKey: "settings.tabs.careTeam" },
{ id: "integrations", labelKey: "settings.tabs.integrations" },
{ id: "developers", labelKey: "settings.tabs.developers" },
] as const;
@@ -70,6 +72,7 @@ export function SettingsView() {
{activeTab === "records" && <RecordsPanel />}
{activeTab === "signing" && <SigningPanel />}
{activeTab === "careTeam" && <CareTeamPanel />}
{activeTab === "integrations" && <IntegrationsPanel />}
{activeTab === "developers" && <DevelopersPanel />}
</div>
</div>
@@ -1103,6 +1103,36 @@
"uploadFailedTitle": "Some files didn't upload",
"uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record."
},
"integrations": {
"fhir": {
"cardTitle": "Lab system (HL7/FHIR)",
"disabledHint": "Not enabled. An owner or admin can connect a lab system in Settings → Integrations.",
"searchPlaceholder": "Search a patient to pull results…",
"change": "Change",
"sync": "Sync results",
"syncing": "Syncing…",
"syncedTitle": "Results synced",
"syncedBody": "Imported {{count}} result(s) for {{name}}.",
"failedTitle": "Sync failed",
"failedBody": "Couldn't reach the lab system. Please try again."
},
"eRx": {
"send": "Send to pharmacy",
"sending": "Sending…",
"sentTitle": "Prescription sent",
"sentBody": "Transmitted to the pharmacy as an NCPDP SCRIPT NewRx.",
"failedTitle": "Couldn't send",
"failedBody": "The pharmacy gateway rejected the message or is unreachable."
},
"claims": {
"submit": "Submit claim",
"submitting": "Submitting…",
"submittedTitle": "Claim submitted",
"submittedBody": "Clearinghouse status: {{status}}.",
"failedTitle": "Couldn't submit claim",
"failedBody": "The clearinghouse rejected the claim or is unreachable."
}
},
"patientCard": {
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
@@ -1277,8 +1307,55 @@
"records": "Records",
"signing": "Signing",
"careTeam": "Care team",
"integrations": "Integrations",
"developers": "Developers"
},
"integrations": {
"loading": "Loading integrations…",
"intro": "Connect temetro to external healthcare systems. Point each integration at your vendor's sandbox or production endpoint and supply its credentials.",
"endpoint": "Endpoint URL",
"credentials": "Credentials",
"credentialsSet": "•••••••• (stored — type to replace)",
"enable": "Enable integration",
"enableHint": "When on, its actions appear on the relevant pages.",
"save": "Save",
"saving": "Saving…",
"test": "Test connection",
"testing": "Testing…",
"savedTitle": "Integration saved",
"saveFailedTitle": "Couldn't save",
"saveFailedBody": "Something went wrong, or you don't have permission. Please try again.",
"testOk": "Connection succeeded",
"testFailed": "Connection failed",
"testError": "Couldn't reach the endpoint.",
"lastSync": "Last activity {{when}}",
"status": {
"connected": "Connected",
"error": "Error",
"unconfigured": "Not configured"
},
"fhir": {
"title": "Lab system (HL7/FHIR)",
"description": "Read lab results from a FHIR R4 server or HL7 v2 feed (e.g. a HAPI FHIR / SMART Health IT sandbox, or your lab's gateway).",
"endpointPlaceholder": "https://hapi.fhir.org/baseR4",
"credentialsPlaceholder": "Bearer token (optional for open sandboxes)",
"credentialsHint": "Sent as a Bearer token, or JSON {\"token\":\"…\"}. Leave blank for public sandboxes."
},
"eprescribe": {
"title": "e-Prescribing (NCPDP SCRIPT)",
"description": "Transmit prescriptions to pharmacies as NCPDP SCRIPT NewRx messages. Production routing requires your Surescripts (or sandbox) account.",
"endpointPlaceholder": "https://your-pharmacy-gateway.example/script",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"senderId\":\"…\"}",
"credentialsHint": "JSON with your gateway token and sender id."
},
"claims": {
"title": "Insurance claims (X12 837/835)",
"description": "Submit professional claims (837P) to a clearinghouse and read remittances (835). Production requires your clearinghouse account.",
"endpointPlaceholder": "https://your-clearinghouse.example/claims",
"credentialsPlaceholder": "JSON: {\"token\":\"…\",\"submitterId\":\"…\",\"receiverId\":\"…\"}",
"credentialsHint": "JSON with your clearinghouse token and submitter/receiver ids."
}
},
"empty": "Nothing here yet.",
"copy": "Copy",
"copied": "Copied",
+78
View File
@@ -0,0 +1,78 @@
// Client for the backend integrations API (HL7/FHIR labs, e-prescribing,
// insurance claims). Config is owner/admin-only to write; status is readable by
// any member so pages can gate their on-page actions.
import { apiFetch } from "@/lib/api-client";
export type IntegrationType = "fhir" | "eprescribe" | "claims";
export type IntegrationStatus = "unconfigured" | "connected" | "error";
export type IntegrationConfig = {
type: IntegrationType;
endpoint: string;
enabled: boolean;
status: IntegrationStatus;
hasCredentials: boolean;
lastSyncAt: string | null;
};
export function listIntegrations(): Promise<IntegrationConfig[]> {
return apiFetch<IntegrationConfig[]>("/api/integrations");
}
export function saveIntegration(
type: IntegrationType,
input: { endpoint?: string; enabled?: boolean; credentials?: string },
): Promise<IntegrationConfig> {
return apiFetch<IntegrationConfig>(`/api/integrations/${type}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function testIntegration(
type: IntegrationType,
): Promise<{ ok: boolean; message: string }> {
return apiFetch(`/api/integrations/${type}/test`, { method: "POST" });
}
// Pull a patient's lab results from the FHIR server.
export function syncFhirLabs(fileNumber: string): Promise<{ imported: number }> {
return apiFetch("/api/integrations/fhir/sync", {
method: "POST",
body: JSON.stringify({ fileNumber }),
});
}
// Transmit a prescription to a pharmacy (NCPDP SCRIPT NewRx).
export function sendEprescription(
rxId: string,
): Promise<{ messageId: string; status: string }> {
return apiFetch("/api/integrations/eprescribe/send", {
method: "POST",
body: JSON.stringify({ rxId }),
});
}
// Submit an insurance claim for an invoice (X12 837P) and read the remittance.
export function submitInsuranceClaim(
invoiceId: string,
): Promise<{ claimStatus: string; paidAmount: number; submitted: boolean }> {
return apiFetch("/api/integrations/claims/submit", {
method: "POST",
body: JSON.stringify({ invoiceId }),
});
}
// Convenience hook-style fetch reused by the on-page sections: returns the
// config for one type (or null while loading/absent).
export async function getIntegration(
type: IntegrationType,
): Promise<IntegrationConfig | null> {
try {
const all = await listIntegrations();
return all.find((c) => c.type === type) ?? null;
} catch {
return null;
}
}