Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
9 changes: 8 additions & 1 deletion apps/web/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,12 @@ function StaffTab() {
const [editingId, setEditingId] = useState<string | null>(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: "",
});
Expand Down Expand Up @@ -374,6 +379,7 @@ function StaffTab() {
}
>
<option value="front_desk">Front Desk</option>
<option value="viewer">Viewer (read-only)</option>
<option value="technician">Technician</option>
<option value="veterinarian">Veterinarian</option>
<option value="admin">Admin</option>
Expand Down Expand Up @@ -472,6 +478,7 @@ function StaffTab() {
}
>
<option value="front_desk">Front Desk</option>
<option value="viewer">Viewer (read-only)</option>
<option value="technician">Technician</option>
<option value="veterinarian">Veterinarian</option>
<option value="admin">Admin</option>
Expand Down
61 changes: 61 additions & 0 deletions apps/web/app/api/cron/backup/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
12 changes: 12 additions & 0 deletions apps/web/app/api/cron/reminders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" },
Expand Down
10 changes: 8 additions & 2 deletions apps/web/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions apps/web/lib/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
47 changes: 47 additions & 0 deletions apps/web/lib/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions apps/web/lib/alerts.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
68 changes: 68 additions & 0 deletions apps/web/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return input == null ? null : { value: "[redacted-nonobject]" };
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
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<void> {
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);
}
}
6 changes: 3 additions & 3 deletions apps/web/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ 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;
};
}
interface User {
id: string;
email: string;
name: string;
role: "admin" | "veterinarian" | "technician" | "front_desk";
role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
practiceId: string;
}
}

declare module "next-auth/jwt" {
interface JWT {
id: string;
role: "admin" | "veterinarian" | "technician" | "front_desk";
role: "admin" | "veterinarian" | "technician" | "front_desk" | "viewer";
practiceId: string;
}
}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/lib/backup/__tests__/export.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading