mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>), 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
|
||||
|
||||
@@ -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 = (
|
||||
<Badge variant="secondary">{t("settings.records.comingSoon")}</Badge>
|
||||
);
|
||||
const fileRef = useRef<HTMLInputElement>(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 (
|
||||
<>
|
||||
<SettingsSection
|
||||
@@ -79,18 +155,70 @@ export function RecordsPanel() {
|
||||
description={t("settings.records.exportDesc")}
|
||||
icon={<Download className="size-4" />}
|
||||
right={
|
||||
<Button disabled size="sm" variant="outline">
|
||||
{t("settings.records.export")}
|
||||
<Button
|
||||
disabled={exporting}
|
||||
onClick={runExport}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{exporting
|
||||
? t("settings.records.exporting")
|
||||
: t("settings.records.export")}
|
||||
</Button>
|
||||
}
|
||||
title={t("settings.records.export")}
|
||||
/>
|
||||
<RecordRow
|
||||
description={t("settings.records.importDesc")}
|
||||
description={t("settings.records.importTemetroDesc")}
|
||||
icon={<Import className="size-4" />}
|
||||
right={comingSoon}
|
||||
title={t("settings.records.import")}
|
||||
right={
|
||||
<>
|
||||
<input
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onFile(f);
|
||||
}}
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.records.importChoose")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
title={t("settings.records.importTemetro")}
|
||||
/>
|
||||
{staged ? (
|
||||
<div className="flex items-center justify-between gap-4 bg-muted/30 px-4 py-3.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate text-sm font-medium">{staged.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.records.importPreview", { count: staged.count })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
disabled={importing}
|
||||
onClick={() => setStaged(null)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{t("settings.records.importCancel")}
|
||||
</Button>
|
||||
<Button disabled={importing} onClick={runImport} size="sm">
|
||||
{importing
|
||||
? t("settings.records.importing")
|
||||
: t("settings.records.importApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<RecordsExport> {
|
||||
return apiFetch<RecordsExport>("/api/settings/records/export");
|
||||
}
|
||||
|
||||
export function importRecords(patients: unknown[]): Promise<ImportResult> {
|
||||
return apiFetch<ImportResult>("/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);
|
||||
}
|
||||
Reference in New Issue
Block a user