From e656eae3628f7cbed313a05a28e7f9cf89d56752 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 20 Jun 2026 18:44:37 +0300 Subject: [PATCH] settings: Records import & export for temetro data Backend: GET /api/settings/records/export downloads the clinic's full patient archive as JSON; POST /api/settings/records/import creates new patients (skips existing file numbers, drops cross-clinic provider links). Both admin-gated. Frontend: Records settings now has a working Export button and an Import flow (choose file -> preview count -> apply) wired to the new endpoints. Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/settings.ts | 110 +++++++++++++- .../components/settings/settings-records.tsx | 138 +++++++++++++++++- frontend/lib/i18n/locales/en/translation.json | 20 ++- frontend/lib/records.ts | 46 ++++++ 4 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/records.ts diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 445b69e..77066e2 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,10 +1,18 @@ import { eq } from "drizzle-orm"; import { Router } from "express"; +import { z } from "zod"; import { db } from "../db/index.js"; import { userSettings } from "../db/schema/settings.js"; +import { patientInputSchema } from "../lib/patient-validation.js"; import { settingsInputSchema } from "../lib/settings-validation.js"; -import { requireAuth } from "../middleware/auth.js"; +import { + requireAuth, + requireOrg, + requirePermission, +} from "../middleware/auth.js"; +import { recordActivity } from "../services/activity.js"; +import { createPatient, listPatients } from "../services/patients.js"; export const settingsRouter = Router(); @@ -12,6 +20,106 @@ export const settingsRouter = Router(); // no active organization or RBAC permission. settingsRouter.use(requireAuth); +// --- Records import / export (clinic-wide, admin-only) ------------------- +// Gated by `member: ["create"]` — the same admin/owner marker the staff route +// uses — so only clinic admins can bulk-move records. + +// Download every patient record in the active clinic as one JSON archive. +settingsRouter.get( + "/records/export", + requireOrg, + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + const patients = await listPatients(req.organizationId!); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Exported ${patients.length} patient record(s)`, + entityType: "patient", + entityId: "export", + }); + res.json({ + temetroExport: true, + version: 1, + exportedAt: new Date().toISOString(), + organizationId: req.organizationId, + patientCount: patients.length, + patients, + }); + } catch (err) { + next(err); + } + }, +); + +// Import a previously exported archive. Creates new patients and skips any whose +// file number already exists in this clinic (idempotent re-imports). Cross-clinic +// provider links are dropped — they reference users this clinic doesn't have. +const importBodySchema = z.object({ + patients: z.array(z.unknown()).max(10_000), +}); + +settingsRouter.post( + "/records/import", + requireOrg, + requirePermission({ member: ["create"] }), + async (req, res, next) => { + try { + const { patients: incoming } = importBodySchema.parse(req.body); + const existing = new Set( + (await listPatients(req.organizationId!)).map((p) => p.fileNumber), + ); + let created = 0; + let skipped = 0; + const errors: string[] = []; + + for (const raw of incoming) { + // Provider links are clinic-specific; drop them so the FK holds. + const candidate = + raw && typeof raw === "object" + ? { ...(raw as Record), primaryProviderId: null } + : raw; + const parsed = patientInputSchema.safeParse(candidate); + if (!parsed.success) { + if (errors.length < 20) { + const name = + (candidate as { name?: string })?.name ?? "(unknown)"; + errors.push(`${name}: ${parsed.error.issues[0]?.message ?? "invalid"}`); + } + continue; + } + if (parsed.data.fileNumber && existing.has(parsed.data.fileNumber)) { + skipped += 1; + continue; + } + try { + const made = await createPatient( + req.organizationId!, + req.user!.id, + parsed.data, + ); + existing.add(made.fileNumber); + created += 1; + } catch { + skipped += 1; + } + } + + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Imported ${created} patient record(s)`, + entityType: "patient", + entityId: "import", + }); + res.json({ created, skipped, total: incoming.length, errors }); + } catch (err) { + next(err); + } + }, +); + settingsRouter.get("/", async (req, res, next) => { try { const rows = await db diff --git a/frontend/components/settings/settings-records.tsx b/frontend/components/settings/settings-records.tsx index 50c045d..af4626e 100644 --- a/frontend/components/settings/settings-records.tsx +++ b/frontend/components/settings/settings-records.tsx @@ -1,6 +1,7 @@ "use client"; import { Database, Download, Import, Smartphone } from "lucide-react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; @@ -9,6 +10,13 @@ import { SettingsCard, SettingsSection, } from "@/components/settings/settings-parts"; +import { + downloadJson, + exportRecords, + importRecords, + type RecordsExport, +} from "@/lib/records"; +import { notify } from "@/lib/toast"; // A single titled row inside a records card: icon + title/description on the // left, a status badge or action on the right. @@ -44,6 +52,74 @@ export function RecordsPanel() { const comingSoon = ( {t("settings.records.comingSoon")} ); + const fileRef = useRef(null); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + // A parsed archive staged for import (preview before applying). + const [staged, setStaged] = useState<{ name: string; count: number; patients: unknown[] } | null>( + null, + ); + + const runExport = async () => { + setExporting(true); + try { + const data = await exportRecords(); + const stamp = new Date().toISOString().slice(0, 10); + downloadJson(data, `temetro-records-${stamp}.json`); + notify.success( + t("settings.records.exportDoneTitle"), + t("settings.records.exportDoneBody", { count: data.patientCount }), + ); + } catch { + notify.error( + t("settings.records.exportFailedTitle"), + t("settings.records.exportFailedBody"), + ); + } finally { + setExporting(false); + } + }; + + const onFile = async (file: File) => { + try { + const parsed = JSON.parse(await file.text()) as RecordsExport; + const patients = Array.isArray(parsed?.patients) ? parsed.patients : null; + if (!patients) throw new Error("bad"); + setStaged({ name: file.name, count: patients.length, patients }); + } catch { + setStaged(null); + notify.error( + t("settings.records.importInvalidTitle"), + t("settings.records.importInvalidBody"), + ); + } finally { + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const runImport = async () => { + if (!staged) return; + setImporting(true); + try { + const result = await importRecords(staged.patients); + notify.success( + t("settings.records.importDoneTitle"), + t("settings.records.importDoneBody", { + created: result.created, + skipped: result.skipped, + }), + ); + setStaged(null); + } catch { + notify.error( + t("settings.records.importFailedTitle"), + t("settings.records.importFailedBody"), + ); + } finally { + setImporting(false); + } + }; + return ( <> } right={ - } title={t("settings.records.export")} /> } - right={comingSoon} - title={t("settings.records.import")} + right={ + <> + { + const f = e.target.files?.[0]; + if (f) onFile(f); + }} + ref={fileRef} + type="file" + /> + + + } + title={t("settings.records.importTemetro")} /> + {staged ? ( +
+
+

{staged.name}

+

+ {t("settings.records.importPreview", { count: staged.count })} +

+
+
+ + +
+
+ ) : null}
diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index f97b5ff..bbd6db4 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -1496,7 +1496,25 @@ "importDesc": "Connect an existing patient database and browse it in the same card UI.", "retentionTitle": "Retention", "retentionDescription": "How long records are kept", - "retentionBody": "Records are kept for as long as your clinic exists. Deleting a patient permanently removes their chart from the clinic database, and the activity log keeps an audit trail of every change — who made it and when." + "retentionBody": "Records are kept for as long as your clinic exists. Deleting a patient permanently removes their chart from the clinic database, and the activity log keeps an audit trail of every change — who made it and when.", + "exporting": "Exporting…", + "exportDoneTitle": "Records exported", + "exportDoneBody": "Downloaded {{count}} patient record(s).", + "exportFailedTitle": "Export failed", + "exportFailedBody": "Couldn't export records. Please try again.", + "importTemetro": "Import a temetro archive", + "importTemetroDesc": "Load patients from a temetro export file. Existing file numbers are skipped.", + "importChoose": "Choose file", + "importPreview": "{{count}} patient record(s) ready to import.", + "importCancel": "Cancel", + "importApply": "Import", + "importing": "Importing…", + "importInvalidTitle": "Invalid file", + "importInvalidBody": "That doesn't look like a temetro export.", + "importDoneTitle": "Import complete", + "importDoneBody": "Created {{created}}, skipped {{skipped}}.", + "importFailedTitle": "Import failed", + "importFailedBody": "Couldn't import records. Please try again." }, "developers": { "description": "Access tokens for the temetro API", diff --git a/frontend/lib/records.ts b/frontend/lib/records.ts new file mode 100644 index 0000000..5d20ef9 --- /dev/null +++ b/frontend/lib/records.ts @@ -0,0 +1,46 @@ +import { apiFetch } from "@/lib/api-client"; +import type { Patient } from "@/lib/patients"; + +// Shape of a temetro records archive (GET /api/settings/records/export). +export type RecordsExport = { + temetroExport: true; + version: number; + exportedAt: string; + organizationId: string; + patientCount: number; + patients: Patient[]; +}; + +// Summary returned by POST /api/settings/records/import. +export type ImportResult = { + created: number; + skipped: number; + total: number; + errors: string[]; +}; + +export function exportRecords(): Promise { + return apiFetch("/api/settings/records/export"); +} + +export function importRecords(patients: unknown[]): Promise { + return apiFetch("/api/settings/records/import", { + method: "POST", + body: JSON.stringify({ patients }), + }); +} + +// Trigger a browser download of a JSON archive. +export function downloadJson(data: unknown, filename: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}