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:
Khalid Abdi
2026-06-20 18:44:37 +03:00
parent bbc6869745
commit e656eae362
4 changed files with 307 additions and 7 deletions
+109 -1
View File
@@ -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