mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 18:20:00 +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
|
||||
|
||||
Reference in New Issue
Block a user