diff --git a/.env.example b/.env.example index af88820..15ab490 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ STRIPE_WEBHOOK_SECRET= # Cron job authentication CRON_SECRET= +# Ops alerting — failures in background jobs (reminders, backups, webhook +# delivery) post here (Slack-style incoming webhook). Optional; logs if unset. +OPS_ALERT_WEBHOOK_URL= + # Lab Integrations IDEXX_API_KEY= ANTECH_API_KEY= diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 8bed4da..0cadc0c 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -297,7 +297,12 @@ function StaffTab() { const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ name: "", - role: "front_desk" as "admin" | "veterinarian" | "technician" | "front_desk", + role: "front_desk" as + | "admin" + | "veterinarian" + | "technician" + | "front_desk" + | "viewer", phone: "", licenseNumber: "", }); @@ -374,6 +379,7 @@ function StaffTab() { } > + @@ -472,6 +478,7 @@ function StaffTab() { } > + diff --git a/apps/web/app/api/cron/backup/route.ts b/apps/web/app/api/cron/backup/route.ts new file mode 100644 index 0000000..4561dd9 --- /dev/null +++ b/apps/web/app/api/cron/backup/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { isNull } from "drizzle-orm"; +import { db } from "@openpims/db/client"; +import { practices } from "@openpims/db"; +import { exportPracticeData, backupKey } from "@/lib/backup/export"; +import { uploadFile } from "@/lib/s3"; +import { alertOps } from "@/lib/alerts"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +// Scheduled per-practice backup → object storage. A clinic gets a daily, +// restorable JSON snapshot of its data, independent of the live DB. +export async function GET(request: Request) { + const cronSecret = request.headers.get("x-cron-secret"); + if (!cronSecret || cronSecret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const today = new Date().toISOString().slice(0, 10); + let ok = 0; + let failed = 0; + + try { + const allPractices = await db + .select({ id: practices.id }) + .from(practices) + .where(isNull(practices.deletedAt)); + + for (const p of allPractices) { + try { + const data = await exportPracticeData(db, p.id, new Date().toISOString()); + const key = backupKey(p.id, today); + await uploadFile(key, Buffer.from(JSON.stringify(data)), "application/json"); + ok++; + } catch (err) { + failed++; + void alertOps( + "Practice backup failed", + `practice ${p.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (failed > 0) { + void alertOps( + "Scheduled backup had failures", + `${failed} of ${allPractices.length} practice backups failed for ${today}.`, + ); + } + + return NextResponse.json({ date: today, practices: allPractices.length, ok, failed }); + } catch (error) { + void alertOps( + "Backup cron job crashed", + error instanceof Error ? error.message : String(error), + ); + console.error("Cron backup job failed:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/web/app/api/cron/reminders/route.ts b/apps/web/app/api/cron/reminders/route.ts index 978cb15..27ecc75 100644 --- a/apps/web/app/api/cron/reminders/route.ts +++ b/apps/web/app/api/cron/reminders/route.ts @@ -9,6 +9,7 @@ import { communications, } from "@openpims/db"; import { sendAppointmentReminder } from "@/lib/email"; +import { alertOps } from "@/lib/alerts"; export async function GET(request: Request) { // Validate the cron secret to prevent unauthorized access @@ -101,8 +102,19 @@ export async function GET(request: Request) { `Cron reminders completed: ${sent} sent, ${failed} failed out of ${upcomingAppointments.length} total`, ); + if (failed > 0) { + void alertOps( + "Appointment reminders had failures", + `${failed} of ${upcomingAppointments.length} reminders failed to send (${sent} sent).`, + ); + } + return NextResponse.json({ sent, failed }); } catch (error) { + void alertOps( + "Reminder cron job crashed", + error instanceof Error ? error.message : String(error), + ); console.error("Cron reminder job failed:", error); return NextResponse.json( { error: "Internal server error" }, diff --git a/apps/web/components/layout/sidebar.tsx b/apps/web/components/layout/sidebar.tsx index 8dd38d9..09026eb 100644 --- a/apps/web/components/layout/sidebar.tsx +++ b/apps/web/components/layout/sidebar.tsx @@ -42,9 +42,15 @@ function PawMark({ className }: { className?: string }) { ); } -type UserRole = "admin" | "veterinarian" | "technician" | "front_desk"; +type UserRole = "admin" | "veterinarian" | "technician" | "front_desk" | "viewer"; -const allRoles: UserRole[] = ["admin", "veterinarian", "technician", "front_desk"]; +const allRoles: UserRole[] = [ + "admin", + "veterinarian", + "technician", + "front_desk", + "viewer", +]; const navItems: { href: string; diff --git a/apps/web/lib/__tests__/alerts.test.ts b/apps/web/lib/__tests__/alerts.test.ts new file mode 100644 index 0000000..2576a04 --- /dev/null +++ b/apps/web/lib/__tests__/alerts.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { formatOpsAlert, alertOps } from "../alerts"; + +describe("formatOpsAlert", () => { + it("includes the subject and detail in a Slack-style text payload", () => { + const p = formatOpsAlert("Backup failed", "practice abc: timeout"); + expect(p.text).toContain("Backup failed"); + expect(p.text).toContain("practice abc: timeout"); + expect(p.text).toContain("OpenVPM"); + }); +}); + +describe("alertOps", () => { + it("never throws when no webhook is configured", async () => { + const prev = process.env.OPS_ALERT_WEBHOOK_URL; + delete process.env.OPS_ALERT_WEBHOOK_URL; + await expect(alertOps("x", "y")).resolves.toBeUndefined(); + if (prev !== undefined) process.env.OPS_ALERT_WEBHOOK_URL = prev; + }); +}); diff --git a/apps/web/lib/__tests__/audit.test.ts b/apps/web/lib/__tests__/audit.test.ts new file mode 100644 index 0000000..a1a9c8d --- /dev/null +++ b/apps/web/lib/__tests__/audit.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { parseAuditPath, redactSecrets, extractEntityId } from "../audit"; + +const UUID = "11111111-1111-1111-1111-111111111111"; + +describe("parseAuditPath", () => { + it("splits entity and action on the first dot", () => { + expect(parseAuditPath("clients.create")).toEqual({ entityType: "clients", action: "create" }); + expect(parseAuditPath("treatmentPlans.updateItemStatus")).toEqual({ + entityType: "treatmentPlans", + action: "updateItemStatus", + }); + }); + it("handles a path with no dot", () => { + expect(parseAuditPath("health")).toEqual({ entityType: "health", action: "" }); + }); +}); + +describe("redactSecrets", () => { + it("redacts secret-ish keys, keeps the rest", () => { + const out = redactSecrets({ name: "Rex", password: "hunter2", apiKey: "x", note: "ok" }); + expect(out).toEqual({ name: "Rex", password: "[redacted]", apiKey: "[redacted]", note: "ok" }); + }); + it("redacts keyHash/secret/token variants", () => { + const out = redactSecrets({ keyHash: "a", secret: "b", authToken: "c" })!; + expect(out.keyHash).toBe("[redacted]"); + expect(out.secret).toBe("[redacted]"); + expect(out.authToken).toBe("[redacted]"); + }); + it("returns null for null/undefined", () => { + expect(redactSecrets(null)).toBeNull(); + expect(redactSecrets(undefined)).toBeNull(); + }); +}); + +describe("extractEntityId", () => { + it("prefers the result row id", () => { + expect(extractEntityId({ id: "ignored" }, { id: UUID })).toBe(UUID); + }); + it("falls back to a uuid input id", () => { + expect(extractEntityId({ id: UUID }, { ok: true })).toBe(UUID); + }); + it("returns null when no uuid is present", () => { + expect(extractEntityId({ id: "not-a-uuid" }, { success: true })).toBeNull(); + expect(extractEntityId(null, null)).toBeNull(); + }); +}); diff --git a/apps/web/lib/alerts.ts b/apps/web/lib/alerts.ts new file mode 100644 index 0000000..05e817f --- /dev/null +++ b/apps/web/lib/alerts.ts @@ -0,0 +1,25 @@ +/** + * Ops alerting for background jobs (cron, webhook dispatch). These run with no + * user watching, so a silent failure means a clinic never knows a reminder + * didn't send or a backup didn't run. Posts to OPS_ALERT_WEBHOOK_URL (Slack- + * style) if configured; always logs. Never throws into the caller. + */ + +export function formatOpsAlert(subject: string, detail: string): { text: string } { + return { text: `🚨 OpenVPM ops alert — ${subject}\n${detail}` }; +} + +export async function alertOps(subject: string, detail: string): Promise { + console.error(`[ops-alert] ${subject}: ${detail}`); + const url = process.env.OPS_ALERT_WEBHOOK_URL; + if (!url) return; + try { + await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formatOpsAlert(subject, detail)), + }); + } catch (err) { + console.error("[ops-alert] failed to deliver alert:", err); + } +} diff --git a/apps/web/lib/audit.ts b/apps/web/lib/audit.ts new file mode 100644 index 0000000..bc7c9a0 --- /dev/null +++ b/apps/web/lib/audit.ts @@ -0,0 +1,68 @@ +import type { Database } from "@openpims/db/client"; +import { auditLog } from "@openpims/db"; + +/** + * Audit logging for mutations. The pure helpers (path parsing, secret + * redaction, entity-id extraction) are unit-tested; recordAuditLog performs the + * best-effort insert and must never throw into the request path. + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SECRET_KEY_RE = /pass(word)?|secret|token|keyhash|^key$|apikey/i; + +/** "clients.create" -> { entityType: "clients", action: "create" }. */ +export function parseAuditPath(path: string): { entityType: string; action: string } { + const dot = path.indexOf("."); + const entityType = (dot === -1 ? path : path.slice(0, dot)).slice(0, 64); + const action = (dot === -1 ? "" : path.slice(dot + 1)).slice(0, 64); + return { entityType, action }; +} + +/** Shallow-redact secret-ish fields so they never land in the audit trail. */ +export function redactSecrets(input: unknown): Record | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return input == null ? null : { value: "[redacted-nonobject]" }; + } + const out: Record = {}; + for (const [k, v] of Object.entries(input as Record)) { + out[k] = SECRET_KEY_RE.test(k) ? "[redacted]" : v; + } + return out; +} + +/** Best-effort entity id: prefer the created/updated row's id, else input.id. */ +export function extractEntityId(rawInput: unknown, resultData: unknown): string | null { + const fromResult = (resultData as { id?: unknown } | null)?.id; + if (typeof fromResult === "string" && UUID_RE.test(fromResult)) return fromResult; + const fromInput = (rawInput as { id?: unknown } | null)?.id; + if (typeof fromInput === "string" && UUID_RE.test(fromInput)) return fromInput; + return null; +} + +export async function recordAuditLog( + db: Database, + opts: { + practiceId: string; + userId: string; + ip?: string | null; + path: string; + rawInput: unknown; + resultData: unknown; + } +): Promise { + try { + const { entityType, action } = parseAuditPath(opts.path); + await db.insert(auditLog).values({ + practiceId: opts.practiceId, + userId: opts.userId, + action, + entityType, + entityId: extractEntityId(opts.rawInput, opts.resultData), + changes: redactSecrets(opts.rawInput), + ipAddress: opts.ip ?? null, + }); + } catch (err) { + // Auditing must never break the request it's recording. + console.error("[audit] failed to record:", err); + } +} diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index bbee8fb..06bd32d 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -11,7 +11,7 @@ declare module "next-auth" { id: string; email: string; name: string; - role: "admin" | "veterinarian" | "technician" | "front_desk"; + role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer"; practiceId: string; }; } @@ -19,7 +19,7 @@ declare module "next-auth" { id: string; email: string; name: string; - role: "admin" | "veterinarian" | "technician" | "front_desk"; + role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer"; practiceId: string; } } @@ -27,7 +27,7 @@ declare module "next-auth" { declare module "next-auth/jwt" { interface JWT { id: string; - role: "admin" | "veterinarian" | "technician" | "front_desk"; + role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer"; practiceId: string; } } diff --git a/apps/web/lib/backup/__tests__/export.test.ts b/apps/web/lib/backup/__tests__/export.test.ts new file mode 100644 index 0000000..da23678 --- /dev/null +++ b/apps/web/lib/backup/__tests__/export.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { backupKey } from "../export"; + +describe("backupKey", () => { + it("namespaces backups per practice and date", () => { + expect(backupKey("prac-1", "2026-06-07")).toBe("backups/prac-1/2026-06-07.json"); + }); + it("keeps the practice id segment isolated", () => { + const a = backupKey("a", "2026-06-07"); + const b = backupKey("b", "2026-06-07"); + expect(a).not.toBe(b); + expect(a.startsWith("backups/a/")).toBe(true); + }); +}); diff --git a/apps/web/lib/backup/export.ts b/apps/web/lib/backup/export.ts new file mode 100644 index 0000000..2a90a1c --- /dev/null +++ b/apps/web/lib/backup/export.ts @@ -0,0 +1,66 @@ +import { eq, and, isNull } from "drizzle-orm"; +import type { Database } from "@openpims/db/client"; +import { clients, patients, appointments, invoices, invoiceItems } from "@openpims/db"; + +/** + * Full, owned export of a single practice's core data — used by the scheduled + * backup cron (and reusable elsewhere). Always scoped by practiceId. + */ + +/** Object-storage key for a practice's daily backup. Pure (testable). */ +export function backupKey(practiceId: string, dateYmd: string): string { + return `backups/${practiceId}/${dateYmd}.json`; +} + +export interface PracticeExport { + practiceId: string; + exportedAt: string; + counts: Record; + clients: unknown[]; + patients: unknown[]; + appointments: unknown[]; + invoices: unknown[]; + invoiceItems: unknown[]; +} + +export async function exportPracticeData( + db: Database, + practiceId: string, + exportedAt: string +): Promise { + const scope = (table: typeof clients | typeof patients | typeof appointments | typeof invoices) => + and(eq(table.practiceId, practiceId), isNull(table.deletedAt)); + + const [clientRows, patientRows, appointmentRows, invoiceRows] = await Promise.all([ + db.select().from(clients).where(scope(clients)), + db.select().from(patients).where(scope(patients)), + db.select().from(appointments).where(scope(appointments)), + db.select().from(invoices).where(scope(invoices)), + ]); + + // Invoice items are scoped via their parent invoice (no practiceId column). + const invoiceIds = invoiceRows.map((r) => r.id); + const itemRows = + invoiceIds.length > 0 + ? (await db.select().from(invoiceItems).where(isNull(invoiceItems.deletedAt))).filter((it) => + invoiceIds.includes(it.invoiceId) + ) + : []; + + return { + practiceId, + exportedAt, + counts: { + clients: clientRows.length, + patients: patientRows.length, + appointments: appointmentRows.length, + invoices: invoiceRows.length, + invoiceItems: itemRows.length, + }, + clients: clientRows, + patients: patientRows, + appointments: appointmentRows, + invoices: invoiceRows, + invoiceItems: itemRows, + }; +} diff --git a/apps/web/lib/import/__tests__/plan.test.ts b/apps/web/lib/import/__tests__/plan.test.ts new file mode 100644 index 0000000..4374e39 --- /dev/null +++ b/apps/web/lib/import/__tests__/plan.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { planClientImport, planPatientImport } from "../plan"; + +describe("planClientImport", () => { + it("flags existing emails as duplicates (case-insensitive)", () => { + const records = [{ email: "a@x.com" }, { email: "B@x.com" }, { email: "c@x.com" }]; + const plan = planClientImport(records, new Set(["a@x.com", "b@x.com"])); + expect(plan).toEqual({ total: 3, duplicates: 2, willInsert: 1 }); + }); + it("treats records without an email as insertable", () => { + const plan = planClientImport([{}, { email: "new@x.com" }], new Set(["old@x.com"])); + expect(plan).toEqual({ total: 2, duplicates: 0, willInsert: 2 }); + }); +}); + +describe("planPatientImport", () => { + it("counts pets whose owner email is missing as unmatched", () => { + const records = [ + { clientEmail: "owner@x.com" }, + { clientEmail: "ghost@x.com" }, + { clientEmail: "OWNER@x.com" }, + ]; + const plan = planPatientImport(records, new Set(["owner@x.com"])); + expect(plan).toEqual({ total: 3, unmatchedClient: 1, willInsert: 2 }); + }); + it("all insertable when every owner exists", () => { + const plan = planPatientImport([{ clientEmail: "a@x.com" }], new Set(["a@x.com"])); + expect(plan).toEqual({ total: 1, unmatchedClient: 0, willInsert: 1 }); + }); +}); diff --git a/apps/web/lib/import/plan.ts b/apps/web/lib/import/plan.ts new file mode 100644 index 0000000..2af6b4a --- /dev/null +++ b/apps/web/lib/import/plan.ts @@ -0,0 +1,51 @@ +/** + * Pure import planning for dry-runs: given already-parsed records and the keys + * that already exist in the practice, report what an import WOULD do without + * touching the database. Lets a clinic validate a migration safely first. + */ + +export interface ClientImportPlan { + total: number; + willInsert: number; + duplicates: number; +} + +/** Duplicate = a record whose email already exists in the practice. */ +export function planClientImport( + records: { email?: string }[], + existingEmails: Set +): ClientImportPlan { + const normalized = new Set([...existingEmails].map((e) => e.toLowerCase())); + let duplicates = 0; + for (const r of records) { + if (r.email && normalized.has(r.email.toLowerCase())) duplicates++; + } + return { + total: records.length, + duplicates, + willInsert: records.length - duplicates, + }; +} + +export interface PatientImportPlan { + total: number; + willInsert: number; + unmatchedClient: number; +} + +/** Referential integrity: a pet can only import if its owner already exists. */ +export function planPatientImport( + records: { clientEmail: string }[], + existingClientEmails: Set +): PatientImportPlan { + const normalized = new Set([...existingClientEmails].map((e) => e.toLowerCase())); + let unmatched = 0; + for (const r of records) { + if (!normalized.has(r.clientEmail.toLowerCase())) unmatched++; + } + return { + total: records.length, + unmatchedClient: unmatched, + willInsert: records.length - unmatched, + }; +} diff --git a/apps/web/lib/onboarding/__tests__/defaults.test.ts b/apps/web/lib/onboarding/__tests__/defaults.test.ts new file mode 100644 index 0000000..3377c45 --- /dev/null +++ b/apps/web/lib/onboarding/__tests__/defaults.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_APPOINTMENT_TYPES, + DEFAULT_ROOMS, + DEFAULT_SERVICES, +} from "../defaults"; + +const ROOM_TYPES = ["exam", "surgery", "treatment", "boarding"]; + +describe("default appointment types", () => { + it("are non-empty and well-formed", () => { + expect(DEFAULT_APPOINTMENT_TYPES.length).toBeGreaterThan(0); + for (const t of DEFAULT_APPOINTMENT_TYPES) { + expect(t.name.trim()).not.toBe(""); + expect(t.durationMinutes).toBeGreaterThan(0); + expect(t.color).toMatch(/^#[0-9a-fA-F]{6}$/); + expect([0, 1]).toContain(t.requiresDoctor); + expect(ROOM_TYPES).toContain(t.defaultRoomType); + } + }); +}); + +describe("default rooms", () => { + it("have valid types and unique names", () => { + expect(DEFAULT_ROOMS.length).toBeGreaterThan(0); + const names = DEFAULT_ROOMS.map((r) => r.name); + expect(new Set(names).size).toBe(names.length); + for (const r of DEFAULT_ROOMS) expect(ROOM_TYPES).toContain(r.type); + }); +}); + +describe("default services", () => { + it("have parseable, non-negative prices and names", () => { + expect(DEFAULT_SERVICES.length).toBeGreaterThan(0); + for (const s of DEFAULT_SERVICES) { + expect(s.name.trim()).not.toBe(""); + const price = Number(s.defaultPrice); + expect(Number.isFinite(price)).toBe(true); + expect(price).toBeGreaterThanOrEqual(0); + expect(s.defaultPrice).toMatch(/^\d+\.\d{2}$/); + expect(typeof s.taxable).toBe("boolean"); + } + }); +}); diff --git a/apps/web/lib/onboarding/defaults.ts b/apps/web/lib/onboarding/defaults.ts new file mode 100644 index 0000000..75406fa --- /dev/null +++ b/apps/web/lib/onboarding/defaults.ts @@ -0,0 +1,98 @@ +import type { Database } from "@openpims/db/client"; +import { appointmentTypes, rooms, services } from "@openpims/db"; + +/** + * Sensible defaults seeded for a brand-new practice so it's usable immediately + * instead of landing in a blank dashboard. Data is plain/pure (easy to test); + * `seedPractice` inserts it scoped to the new practice. + */ + +export interface DefaultAppointmentType { + name: string; + durationMinutes: number; + color: string; + requiresDoctor: 0 | 1; + defaultRoomType: "exam" | "surgery" | "treatment" | "boarding"; +} + +export const DEFAULT_APPOINTMENT_TYPES: DefaultAppointmentType[] = [ + { name: "Wellness Exam", durationMinutes: 30, color: "#0d9488", requiresDoctor: 1, defaultRoomType: "exam" }, + { name: "Sick Visit", durationMinutes: 30, color: "#dc2626", requiresDoctor: 1, defaultRoomType: "exam" }, + { name: "Vaccination", durationMinutes: 15, color: "#2563eb", requiresDoctor: 0, defaultRoomType: "exam" }, + { name: "Surgery", durationMinutes: 120, color: "#7c3aed", requiresDoctor: 1, defaultRoomType: "surgery" }, + { name: "Dental Cleaning", durationMinutes: 90, color: "#0891b2", requiresDoctor: 1, defaultRoomType: "surgery" }, + { name: "Recheck / Follow-up", durationMinutes: 15, color: "#65a30d", requiresDoctor: 1, defaultRoomType: "exam" }, +]; + +export interface DefaultRoom { + name: string; + type: "exam" | "surgery" | "treatment" | "boarding"; +} + +export const DEFAULT_ROOMS: DefaultRoom[] = [ + { name: "Exam Room 1", type: "exam" }, + { name: "Exam Room 2", type: "exam" }, + { name: "Surgery Suite", type: "surgery" }, + { name: "Treatment Area", type: "treatment" }, +]; + +export interface DefaultService { + name: string; + category: string; + defaultPrice: string; // numeric column stores as string + taxable: boolean; +} + +export const DEFAULT_SERVICES: DefaultService[] = [ + { name: "Wellness Exam", category: "Exam", defaultPrice: "65.00", taxable: false }, + { name: "Sick / Problem Exam", category: "Exam", defaultPrice: "75.00", taxable: false }, + { name: "Recheck Exam", category: "Exam", defaultPrice: "45.00", taxable: false }, + { name: "Rabies Vaccine", category: "Vaccination", defaultPrice: "35.00", taxable: true }, + { name: "DHPP Vaccine", category: "Vaccination", defaultPrice: "40.00", taxable: true }, + { name: "Bordetella Vaccine", category: "Vaccination", defaultPrice: "38.00", taxable: true }, + { name: "FVRCP Vaccine", category: "Vaccination", defaultPrice: "40.00", taxable: true }, + { name: "Microchip", category: "Procedure", defaultPrice: "55.00", taxable: true }, + { name: "Nail Trim", category: "Procedure", defaultPrice: "20.00", taxable: true }, + { name: "Dental Cleaning", category: "Surgery", defaultPrice: "450.00", taxable: false }, + { name: "Spay / Neuter", category: "Surgery", defaultPrice: "350.00", taxable: false }, + { name: "Heartworm Test", category: "Diagnostics", defaultPrice: "45.00", taxable: false }, +]; + +/** + * Insert the default catalog for a freshly created practice. Idempotency is the + * caller's responsibility (only call once, at registration). + */ +export async function seedPractice( + db: Database, + opts: { practiceId: string; locationId?: string | null } +): Promise { + await db.insert(appointmentTypes).values( + DEFAULT_APPOINTMENT_TYPES.map((t) => ({ + practiceId: opts.practiceId, + name: t.name, + durationMinutes: t.durationMinutes, + color: t.color, + requiresDoctor: t.requiresDoctor, + defaultRoomType: t.defaultRoomType, + })) + ); + + await db.insert(rooms).values( + DEFAULT_ROOMS.map((r) => ({ + practiceId: opts.practiceId, + locationId: opts.locationId ?? null, + name: r.name, + type: r.type, + })) + ); + + await db.insert(services).values( + DEFAULT_SERVICES.map((s) => ({ + practiceId: opts.practiceId, + name: s.name, + category: s.category, + defaultPrice: s.defaultPrice, + taxable: s.taxable, + })) + ); +} diff --git a/apps/web/lib/webhook-dispatcher.ts b/apps/web/lib/webhook-dispatcher.ts index 0c2031f..c71ad77 100644 --- a/apps/web/lib/webhook-dispatcher.ts +++ b/apps/web/lib/webhook-dispatcher.ts @@ -2,6 +2,7 @@ import { createHmac } from "crypto"; import { eq, and, isNull } from "drizzle-orm"; import { db } from "@openpims/db/client"; import { webhooks } from "@openpims/db"; +import { alertOps } from "@/lib/alerts"; export async function dispatchWebhookEvent( practiceId: string, @@ -45,7 +46,7 @@ export async function dispatchWebhookEvent( .update(body) .digest("hex"); - await fetch(wh.url, { + const res = await fetch(wh.url, { method: "POST", headers: { "Content-Type": "application/json", @@ -54,16 +55,33 @@ export async function dispatchWebhookEvent( }, body, }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return true; } catch (err) { console.error( `[WebhookDispatcher] Failed to deliver ${event} to ${wh.url}:`, err, ); + return false; } }); - // Fire all requests in parallel (don't block on responses) - Promise.allSettled(requests).catch(() => { - // Intentionally swallowed - individual errors are logged above - }); + // Fire all requests in parallel (don't block on responses). Alert ops once + // per batch if any deliveries failed — a silently dead webhook means an + // integration stops receiving events with no signal. + Promise.allSettled(requests) + .then((results) => { + const failed = results.filter( + (r) => r.status === "rejected" || r.value === false, + ).length; + if (failed > 0) { + void alertOps( + "Webhook delivery failed", + `${failed} of ${matching.length} '${event}' webhook deliveries failed for practice ${practiceId}.`, + ); + } + }) + .catch(() => { + // Intentionally swallowed - individual errors are logged above + }); } diff --git a/apps/web/server/__tests__/guards.test.ts b/apps/web/server/__tests__/guards.test.ts new file mode 100644 index 0000000..25b7841 --- /dev/null +++ b/apps/web/server/__tests__/guards.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { appRouter } from "../routers/_app"; + +// Build a tRPC caller with a fake session. The db is a throwing proxy: any +// resolver that reaches the database fails loudly — so a passing query proves +// the guard let it through to a db-free path, and a FORBIDDEN proves the guard +// short-circuited before the resolver. +function callerFor(role: string) { + const session = { + user: { + id: "00000000-0000-0000-0000-000000000001", + email: "u@example.com", + name: "U", + role, + practiceId: "00000000-0000-0000-0000-0000000000aa", + }, + }; + return appRouter.createCaller({ db: {} as never, session } as never); +} + +describe("viewer read-only guard", () => { + it("allows queries for a viewer", async () => { + const caller = callerFor("viewer"); + // dosing.formulary is a query with no DB access. + const res = await caller.dosing.formulary(); + expect(res.drugs.length).toBeGreaterThan(0); + }); + + it("blocks mutations for a viewer with FORBIDDEN (before the resolver)", async () => { + const caller = callerFor("viewer"); + await expect( + caller.clients.create({ firstName: "A", lastName: "B" }) + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("does not block queries for non-viewer roles", async () => { + const caller = callerFor("front_desk"); + const res = await caller.dosing.formulary(); + expect(res.drugs.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/server/__tests__/tenant-scoping.test.ts b/apps/web/server/__tests__/tenant-scoping.test.ts new file mode 100644 index 0000000..553038d --- /dev/null +++ b/apps/web/server/__tests__/tenant-scoping.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * Tenant-scoping regression guard (zero-DB). The catastrophic multi-tenant bug + * is a router that queries data without filtering by practiceId. This asserts + * every router that touches the database references `practiceId`. It does NOT + * prove row-level isolation — that needs a live DB + Postgres RLS (Phase 4) — + * but it stops a whole router shipping with no tenant filter. + */ +const ROUTERS_DIR = fileURLToPath(new URL("../routers", import.meta.url)); + +// Routers that legitimately query without a practiceId filter, with reasons. +const ALLOWLIST: Record = { + "auth.ts": "operates on users by email/id before a session exists", + "_app.ts": "router aggregation only", +}; + +describe("tenant scoping", () => { + const files = readdirSync(ROUTERS_DIR).filter((f) => f.endsWith(".ts")); + + it("covers every router file", () => { + expect(files.length).toBeGreaterThan(10); + }); + + for (const file of files) { + it(`${file}: DB queries are scoped by practiceId`, () => { + const src = readFileSync(`${ROUTERS_DIR}/${file}`, "utf8"); + const touchesDb = src.includes(".from(") || src.includes(".insert("); + if (!touchesDb) return; // no DB access, nothing to scope + if (ALLOWLIST[file]) return; + expect( + src.includes("practiceId"), + `${file} queries the DB but never references practiceId — possible cross-tenant leak` + ).toBe(true); + }); + } + + it("keeps the allowlist small and intentional", () => { + expect(Object.keys(ALLOWLIST).length).toBeLessThanOrEqual(3); + }); +}); diff --git a/apps/web/server/routers/auth.ts b/apps/web/server/routers/auth.ts index ef25448..92af189 100644 --- a/apps/web/server/routers/auth.ts +++ b/apps/web/server/routers/auth.ts @@ -5,6 +5,7 @@ import { TRPCError } from "@trpc/server"; import { createRouter, publicProcedure, protectedProcedure } from "../trpc"; import { users, practices, locations } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; +import { seedPractice } from "@/lib/onboarding/defaults"; export const authRouter = createRouter({ register: publicProcedure @@ -53,11 +54,14 @@ export const authRouter = createRouter({ .returning(); // Create default location - await ctx.db.insert(locations).values({ - practiceId: practice!.id, - name: "Main Location", - isPrimary: true, - }); + const [location] = await ctx.db + .insert(locations) + .values({ + practiceId: practice!.id, + name: "Main Location", + isPrimary: true, + }) + .returning(); // Create admin user const [user] = await ctx.db @@ -71,6 +75,18 @@ export const authRouter = createRouter({ }) .returning(); + // Seed sensible defaults (appointment types, rooms, starter services) so + // the new practice is usable immediately. Non-fatal: a seed hiccup must + // not block signup — the practice still works, just emptier. + try { + await seedPractice(ctx.db, { + practiceId: practice!.id, + locationId: location?.id ?? null, + }); + } catch (err) { + console.error("[register] practice seeding failed:", err); + } + return { id: user!.id, email: user!.email }; }), diff --git a/apps/web/server/routers/data.ts b/apps/web/server/routers/data.ts index f520542..77079b2 100644 --- a/apps/web/server/routers/data.ts +++ b/apps/web/server/routers/data.ts @@ -11,6 +11,7 @@ import { users, } from "@openpims/db"; import { csvToClientRecords, csvToPatientRecords } from "@/lib/csv/import"; +import { planClientImport, planPatientImport } from "@/lib/import/plan"; const adminProcedure = protectedProcedure.use(requireRole("admin")); @@ -304,9 +305,20 @@ export const dataRouter = createRouter({ // (lib/csv); these mutations just persist the valid rows. importClientsCsv: adminProcedure - .input(z.object({ csv: z.string().min(1) })) + .input(z.object({ csv: z.string().min(1), dryRun: z.boolean().optional().default(false) })) .mutation(async ({ ctx, input }) => { const { records, errors } = csvToClientRecords(input.csv); + + if (input.dryRun) { + const existing = await ctx.db + .select({ email: clients.email }) + .from(clients) + .where(and(eq(clients.practiceId, ctx.practiceId), isNull(clients.deletedAt))); + const emails = new Set(existing.map((c) => c.email).filter((e): e is string => !!e)); + const plan = planClientImport(records, emails); + return { dryRun: true as const, ...plan, errors }; + } + if (records.length > 0) { await ctx.db.insert(clients).values( records.map((c) => ({ @@ -326,7 +338,7 @@ export const dataRouter = createRouter({ }), importPatientsCsv: adminProcedure - .input(z.object({ csv: z.string().min(1) })) + .input(z.object({ csv: z.string().min(1), dryRun: z.boolean().optional().default(false) })) .mutation(async ({ ctx, input }) => { const { records, errors } = csvToPatientRecords(input.csv); @@ -341,6 +353,11 @@ export const dataRouter = createRouter({ if (c.email) emailToClientId[c.email.toLowerCase()] = c.id; } + if (input.dryRun) { + const plan = planPatientImport(records, new Set(Object.keys(emailToClientId))); + return { dryRun: true as const, ...plan, errors }; + } + const toInsert: (typeof patients.$inferInsert)[] = []; records.forEach((p, i) => { const clientId = emailToClientId[p.clientEmail.toLowerCase()]; diff --git a/apps/web/server/routers/settings.ts b/apps/web/server/routers/settings.ts index 2a328e4..07234e4 100644 --- a/apps/web/server/routers/settings.ts +++ b/apps/web/server/routers/settings.ts @@ -69,7 +69,7 @@ export const settingsRouter = createRouter({ name: z.string().min(1), email: z.string().email(), password: z.string().min(6), - role: z.enum(["admin", "veterinarian", "technician", "front_desk"]), + role: z.enum(["admin", "veterinarian", "technician", "front_desk", "viewer"]), phone: z.string().optional(), licenseNumber: z.string().optional(), }) @@ -99,7 +99,7 @@ export const settingsRouter = createRouter({ id: z.string().uuid(), name: z.string().min(1).optional(), role: z - .enum(["admin", "veterinarian", "technician", "front_desk"]) + .enum(["admin", "veterinarian", "technician", "front_desk", "viewer"]) .optional(), phone: z.string().optional(), licenseNumber: z.string().optional(), diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index 3000785..9c22462 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -4,10 +4,16 @@ import { getServerSession } from "next-auth"; import superjson from "superjson"; import { ZodError } from "zod"; import { authOptions } from "@/lib/auth"; +import { recordAuditLog } from "@/lib/audit"; import { db } from "@openpims/db/client"; import type { Database } from "@openpims/db/client"; -type UserRole = "admin" | "veterinarian" | "technician" | "front_desk"; +type UserRole = + | "admin" + | "veterinarian" + | "technician" + | "front_desk" + | "viewer"; interface AppSession extends Session { user: { @@ -22,11 +28,21 @@ interface AppSession extends Session { export type TRPCContext = { db: Database; session: AppSession | null; + ip?: string | null; }; -export async function createTRPCContext(): Promise { +function clientIp(req?: Request): string | null { + if (!req) return null; + const xff = req.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0]!.trim().slice(0, 45); + return req.headers.get("x-real-ip")?.slice(0, 45) ?? null; +} + +export async function createTRPCContext(opts?: { + req?: Request; +}): Promise { const session = (await getServerSession(authOptions)) as AppSession | null; - return { db, session }; + return { db, session, ip: clientIp(opts?.req) }; } const t = initTRPC.context().create({ @@ -47,18 +63,42 @@ export const createRouter = t.router; export const publicProcedure = t.procedure; /** Requires an authenticated session */ -export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { - if (!ctx.session?.user) { - throw new TRPCError({ code: "UNAUTHORIZED" }); +export const protectedProcedure = t.procedure.use( + async ({ ctx, next, type, path, getRawInput }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + // Global read-only guard: viewers can run any query but no mutation. This + // makes the role enforceable everywhere without touching each router. + if (type === "mutation" && ctx.session.user.role === "viewer") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Your account has read-only (viewer) access.", + }); + } + + const user = ctx.session.user; + const result = await next({ + ctx: { session: ctx.session, user, practiceId: user.practiceId }, + }); + + // Audit every successful mutation: who changed what, when, from where. + // Fire-and-forget — never block or fail the request on the audit write. + if (type === "mutation" && result.ok) { + const rawInput = await getRawInput().catch(() => undefined); + void recordAuditLog(ctx.db, { + practiceId: user.practiceId, + userId: user.id, + ip: ctx.ip, + path, + rawInput, + resultData: (result as { data?: unknown }).data, + }); + } + + return result; } - return next({ - ctx: { - session: ctx.session, - user: ctx.session.user, - practiceId: ctx.session.user.practiceId, - }, - }); -}); +); /** Requires specific roles */ export function requireRole(...roles: UserRole[]) { diff --git a/packages/db/schema/users.ts b/packages/db/schema/users.ts index 972a6c0..8abb8f1 100644 --- a/packages/db/schema/users.ts +++ b/packages/db/schema/users.ts @@ -16,6 +16,9 @@ export const userRoleEnum = pgEnum("user_role", [ "veterinarian", "technician", "front_desk", + // Read-only access — can view everything in their practice but cannot + // mutate. Useful for running OpenVPM as a parallel backup/secondary. + "viewer", ]); export const users = pgTable("users", { diff --git a/vercel.json b/vercel.json index da167da..2d41be6 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/cron/reminders", "schedule": "0 8 * * *" + }, + { + "path": "/api/cron/backup", + "schedule": "0 3 * * *" } ] }