Files
temetro/backend/src/routes/ai.ts
T
Khalid Abdi f9615fa74e feat: edit parsed records before importing
The import preview only showed counts + skipped-row errors with no way to
fix anything. Now every parsed record is editable before import:

- backend: shared validatePatientImport() (used by the previewImport tool)
  + POST /api/ai/import/validate for dry-run re-validation; the import
  preview payload now carries the original records (and the source record on
  each invalid row) so the UI can edit them.
- frontend: the import card gets a "Review & edit" dialog listing every
  record with a ready / needs-fixing badge; opening one reuses the full
  PatientFormDialog in a new non-persisting review mode (editable file
  number) and re-validates on save, so skipped rows can be fixed and
  included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:03:33 +03:00

217 lines
6.8 KiB
TypeScript

import { Router } from "express";
import { HttpError } from "../lib/http-error.js";
import {
aiConfigInputSchema,
aiTestInputSchema,
} from "../lib/ai-validation.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import { isReceptionOnly } from "../lib/role-scope.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import {
getAiSettings,
saveAiConfig,
toAiConfig,
} from "../services/ai/config.js";
import { validatePatientImport } from "../services/ai/import.js";
import { getPolicy, savePolicy } from "../services/ai/policy.js";
import * as patients from "../services/patients.js";
export const aiRouter = Router();
// --- Clinic-wide AI policy (admin-controlled kill-switch) -------------------
// Any member can READ the policy (the frontend needs it to gate nav/routes);
// only owners/admins can change it.
aiRouter.get("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
res.json(await getPolicy(req.organizationId!));
} catch (err) {
next(err);
}
});
aiRouter.put("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
const roles = String(req.memberRole ?? "")
.split(",")
.map((s) => s.trim());
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
if (!isAdmin) {
throw new HttpError(403, "Only owners and admins can change this.");
}
const body = req.body as {
aiEnabled?: unknown;
disabledForEmployees?: unknown;
};
const saved = await savePolicy(req.organizationId!, {
aiEnabled: Boolean(body.aiEnabled),
disabledForEmployees: Boolean(body.disabledForEmployees),
});
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: saved.aiEnabled
? saved.disabledForEmployees
? "Restricted AI to owners and admins"
: "Enabled the AI assistant clinic-wide"
: "Disabled the AI assistant clinic-wide",
entityType: "patient",
});
res.json(saved);
} catch (err) {
next(err);
}
});
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
aiRouter.get("/config", requireAuth, async (req, res, next) => {
try {
const row = await getAiSettings(req.user!.id);
res.json({ config: toAiConfig(row) });
} catch (err) {
next(err);
}
});
aiRouter.put("/config", requireAuth, async (req, res, next) => {
try {
const input = aiConfigInputSchema.parse(req.body);
const config = await saveAiConfig(req.user!.id, input);
res.json({ config });
} catch (err) {
next(err);
}
});
// Lightweight connectivity probe before saving. For local mode we ping Ollama's
// tag list; for API mode we just confirm a key is stored (real validation
// happens on first use to avoid spending a token here).
aiRouter.post("/test", requireAuth, async (req, res, next) => {
try {
const input = aiTestInputSchema.parse(req.body);
if (input.mode === "local") {
const base = (input.ollamaBaseUrl ?? "").replace(/\/$/, "");
if (!base) throw new HttpError(400, "Ollama base URL is required.");
try {
const ping = await fetch(`${base}/api/tags`, {
signal: AbortSignal.timeout(4000),
});
if (!ping.ok) throw new Error(String(ping.status));
res.json({ ok: true, message: "Connected to Ollama." });
} catch {
throw new HttpError(
502,
"Could not reach Ollama at that URL. Is it running?",
);
}
return;
}
const row = await getAiSettings(req.user!.id);
const provider = input.provider ?? row.provider;
const ok = Boolean(row.apiKeysCipher[provider]);
res.json({
ok,
message: ok
? "API key is set."
: "No API key stored for this provider yet.",
});
} catch (err) {
next(err);
}
});
// --- Migration import commit ------------------------------------------------
// Inserts records the clinician approved in the chat import preview. Re-validates
// server-side (never trusts the client) and reuses the audited patient service.
aiRouter.post(
"/import",
requireAuth,
requireOrg,
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const records = (req.body as { records?: unknown[] }).records;
if (!Array.isArray(records) || records.length === 0) {
throw new HttpError(400, "No records to import.");
}
if (records.length > 500) {
throw new HttpError(400, "Too many records in one import (max 500).");
}
const demographicsOnly = isReceptionOnly(req.memberRole);
const created: string[] = [];
const failed: { fileNumber?: string; error: string }[] = [];
for (const rec of records) {
const parsed = patientInputSchema.safeParse(rec);
if (!parsed.success) {
failed.push({
fileNumber:
(rec as { fileNumber?: string } | null)?.fileNumber ?? undefined,
error: parsed.error.issues
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
.join("; "),
});
continue;
}
try {
const patient = await patients.createPatient(
req.organizationId!,
req.user!.id,
{ ...parsed.data, source: "ai" },
demographicsOnly,
);
created.push(patient.fileNumber);
} catch (err) {
failed.push({
fileNumber: parsed.data.fileNumber,
error: err instanceof Error ? err.message : "Insert failed.",
});
}
}
if (created.length > 0) {
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `${req.user!.name} imported ${created.length} patient record(s) via AI`,
entityType: "patient",
});
}
res.json({ created, failed });
} catch (err) {
next(err);
}
},
);
// --- Migration import re-validation (dry run) -------------------------------
// Powers the "review & edit before import" UI: the client edits parsed records
// and calls this to refresh which are ready vs. need fixing. Writes nothing.
aiRouter.post(
"/import/validate",
requireAuth,
requireOrg,
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const records = (req.body as { records?: unknown[] }).records;
if (!Array.isArray(records)) {
throw new HttpError(400, "records must be an array.");
}
if (records.length > 500) {
throw new HttpError(400, "Too many records (max 500).");
}
res.json(validatePatientImport(records));
} catch (err) {
next(err);
}
},
);