Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8a93111
Add e2e regression test for public repo appearance
evangauer Apr 20, 2026
9056054
Add Vitest unit-test runner and wire it into CI
evangauer Jun 2, 2026
28be3bf
Wire API-key authentication for the public API
evangauer Jun 2, 2026
f7750ea
Add v1 REST API compatibility layer (clients, patients, appointments)
evangauer Jun 2, 2026
8a26df7
Document the v1 REST API and the compatibility-layer contributor flow
evangauer Jun 2, 2026
0aaa393
Add weight-based drug dosing calculator
evangauer Jun 2, 2026
ea5356a
Add vital signs tracking
evangauer Jun 2, 2026
f489cf8
Add OpenVPM Agent: typed tool layer + Claude tool-use runner
evangauer Jun 2, 2026
94fcd3a
Marketing site: 'don't switch, connect' direction + Agent + /updates
evangauer Jun 2, 2026
619ecbf
Add in-product Agent console
evangauer Jun 2, 2026
98811fd
Document OpenVPM Agent + add agent env vars to .env.example
evangauer Jun 2, 2026
96c4f92
Deduct product stock when dispensed on an invoice
evangauer Jun 2, 2026
e70b901
Add self-service online booking to the client portal
evangauer Jun 2, 2026
64ff53b
Expose the OpenVPM Agent over the public API
evangauer Jun 2, 2026
aef90b9
Add treatment plans linked to the problem list
evangauer Jun 2, 2026
20c898a
Add CSV data import for clients and patients
evangauer Jun 2, 2026
dd84a26
Expand agent toolset: read treatment plans, record vital signs
evangauer Jun 2, 2026
44cd35b
Add wellness plans / recurring billing
evangauer Jun 2, 2026
3d01921
Surface vital signs on the patient detail page
evangauer Jun 2, 2026
e0792fe
Fix scheduling conflicts: strict overlap + room double-booking
evangauer Jun 2, 2026
7034749
Add appointment reschedule with conflict checking
evangauer Jun 2, 2026
eed9608
Site: publish calendar + clinical-tooling updates
evangauer Jun 2, 2026
7ae97c3
Add open-slot availability for the calendar
evangauer Jun 2, 2026
f12904f
Suggest open times in portal booking
evangauer Jun 2, 2026
50deae8
Add find_open_slots agent tool
evangauer Jun 2, 2026
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ CRON_SECRET=
IDEXX_API_KEY=
ANTECH_API_KEY=
ZOETIS_API_KEY=

# OpenVPM Agent (AI). Without ANTHROPIC_API_KEY the agent UI shows a setup
# notice and agent runs are disabled; everything else works normally.
ANTHROPIC_API_KEY=
# Optional model override for the agent (defaults to claude-sonnet-4-6).
AGENT_MODEL=
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ jobs:

- run: pnpm type-check

- run: pnpm test

- run: pnpm build
# Note: next build includes linting — no separate lint step needed
35 changes: 33 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,47 @@ Thank you for your interest in contributing to OpenVPM!

1. Create a feature branch from `main`
2. Make your changes
3. Run `pnpm build` to verify no type errors
3. Run `pnpm test` (unit) and `pnpm build` (type-check + lint) to verify
4. Submit a pull request

## Testing

- **Unit tests** (Vitest): `pnpm test`. Co-locate as `*.test.ts` next to the code
(e.g. `lib/compat/openvpm/__tests__/`). Pure logic — mappers, helpers — should
be unit-tested without a database.
- **E2E tests** (Playwright): `pnpm test:e2e`.

## Code Style

- TypeScript strict mode
- Tailwind CSS for styling (follow existing design tokens)
- tRPC for all API endpoints
- tRPC for all internal API endpoints
- Drizzle ORM for database queries

## Adding a compatibility endpoint or target

The public REST API ([docs/api](docs/api/README.md)) is OpenVPM's integration
moat: integrators (and AI agents) plug into a frozen, vendor-shaped contract.
The layout makes adding endpoints — and entire vendor-compatible "targets" —
repeatable:

- **Route handlers**: `apps/web/app/api/v1/<resource>/route.ts`. Keep them
**thin** — authenticate → validate → tenant-scoped Drizzle query → map →
respond. No business logic or shape knowledge in the handler.
- **Auth**: call `authenticateApiKey(req, "<scope>")` from `lib/api-auth.ts`.
**Every** query MUST be scoped by `ctx.practiceId` and filter
`isNull(table.deletedAt)` — a cross-tenant read is a security bug.
- **Mappers**: `lib/compat/<target>/mappers.ts` holds **pure** functions that
translate internal rows ↔ the target's shapes (enum crosswalks, date formats).
The target's request/response shapes live in `lib/compat/<target>/schema.ts`.
- **A new target** (e.g. an existing PIMS's public API) is a new
`lib/compat/<vendor>/` module plus an `app/api/compat/<vendor>/v1/` namespace —
the auth, pagination, and error helpers are shared.

Every endpoint ships with mapper unit tests **and** a contract test
(`schema.parse(toApiX(row))`) so internal changes can't silently break the
public contract.

## License

By contributing, you agree that your contributions will be licensed under the MIT License.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,17 @@ The Docker setup includes PostgreSQL 16 with health checks, MinIO for S3-compati

OpenVPM is **API-first**. Every action the UI performs goes through the same API available to third-party integrations. This is the killer feature — no other open-source PIMS has a real, well-documented, read-write API.

### REST API (v1)

A versioned, public REST API over the core records, authenticated with scoped API keys — built so integrators (booking, reminders, client comms, AI agents) can read clients/patients and create appointments without touching the internal client. Response shapes are owned by an explicit contract and frozen independently of the database, so internal changes never break integrations.

```bash
curl https://demo.openvpm.com/api/v1/clients \
-H "Authorization: Bearer ovpm_…"
```

See [docs/api](docs/api/README.md) for endpoints, scopes, rate limits, and the error format. This namespace also serves as the foundation for vendor-compatible "identical-twin" APIs (point an existing integration at OpenVPM with zero changes).

### Webhooks

Subscribe to real-time events:
Expand Down Expand Up @@ -256,6 +267,10 @@ OpenVPM's structured data models and event streams make it the ideal foundation

The PIMS is the system of record. AI agents are first-class citizens.

### OpenVPM Agent

OpenVPM ships with a built-in AI agent that operates on practice data through a typed tool layer (find clients/patients, pull a clinical summary, list overdue vaccinations, calculate a weight-based drug dose, book appointments). It runs a Claude tool-use loop scoped to a single practice, gates every write behind an explicit opt-in, and degrades gracefully when no model key is set. Bring your own `ANTHROPIC_API_KEY`; the agent is open source and fully inspectable. Available in-app under **Agent** and via the `agent` tRPC router.

## Why Open Source Matters for Veterinary Medicine

The veterinary industry is at a crossroads. AI is arriving. Data interoperability is becoming critical. And the dominant PIMS vendors are still charging hundreds per month for software that crashes, frustrates staff, and locks clinics into proprietary ecosystems.
Expand Down
149 changes: 149 additions & 0 deletions apps/web/app/(dashboard)/agent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"use client";

import { useState } from "react";
import { Bot, Send, ChevronDown, ChevronRight, AlertTriangle, Wrench } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

const SUGGESTIONS = [
"Which patients are overdue for vaccinations?",
"Summarize today's appointments.",
"What's the carprofen dose for a 12 kg dog?",
"Pull a clinical summary for the next patient checked in.",
];

export default function AgentPage() {
const status = trpc.agent.status.useQuery();
const run = trpc.agent.run.useMutation();
const [instruction, setInstruction] = useState("");
const [allowWrites, setAllowWrites] = useState(false);
const [traceOpen, setTraceOpen] = useState(false);

const configured = status.data?.configured ?? true;

function submit() {
if (!instruction.trim() || run.isPending) return;
run.mutate({ instruction: instruction.trim(), allowWrites });
}

return (
<div className="mx-auto max-w-3xl">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Bot className="h-5 w-5" />
</div>
<div>
<h1 className="font-heading text-2xl font-semibold">OpenVPM Agent</h1>
<p className="text-sm text-muted-foreground">
Ask the agent to work on your practice data. It uses real tools and
never invents records.
</p>
</div>
</div>

{!configured && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
The agent is not configured yet. Set <code className="font-mono">ANTHROPIC_API_KEY</code>{" "}
in your environment to enable agent runs.
</div>
</div>
)}

<div className="rounded-xl border border-border bg-surface p-4">
<textarea
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit();
}}
rows={3}
placeholder="Ask the agent to do something… (⌘/Ctrl + Enter to run)"
className="w-full resize-none rounded-md border border-border bg-background p-3 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<div className="mt-3 flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={allowWrites}
onChange={(e) => setAllowWrites(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
Allow writes (e.g. booking appointments)
</label>
<Button onClick={submit} disabled={!instruction.trim() || run.isPending}>
<Send className="mr-1.5 h-4 w-4" />
{run.isPending ? "Working…" : "Run"}
</Button>
</div>
</div>

<div className="mt-3 flex flex-wrap gap-2">
{SUGGESTIONS.map((s) => (
<button
key={s}
onClick={() => setInstruction(s)}
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-primary"
>
{s}
</button>
))}
</div>

{run.error && (
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{run.error.message}
</div>
)}

{run.data && (
<div className="mt-6 rounded-xl border border-border bg-background p-5">
<div className="whitespace-pre-wrap text-sm leading-relaxed">
{run.data.text}
</div>

{run.data.toolCalls.length > 0 && (
<div className="mt-4 border-t border-border pt-3">
<button
onClick={() => setTraceOpen((o) => !o)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
{traceOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
<Wrench className="h-3.5 w-3.5" />
{run.data.toolCalls.length} tool call
{run.data.toolCalls.length === 1 ? "" : "s"}
</button>
{traceOpen && (
<ul className="mt-2 space-y-2">
{run.data.toolCalls.map((call, i) => (
<li
key={i}
className={cn(
"rounded-md border p-2 font-mono text-xs",
call.error
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-border bg-surface text-muted-foreground"
)}
>
<div className="font-semibold text-foreground">{call.name}</div>
<div className="mt-1 break-all">
{JSON.stringify(call.input)}
</div>
{call.error && <div className="mt-1">⚠ {call.error}</div>}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div>
);
}
125 changes: 124 additions & 1 deletion apps/web/app/(dashboard)/patients/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ function calculateAge(dob: string | null): string {
return `${adjustedYears}y ${adjustedMonths}m`;
}

type Tab = "overview" | "weight" | "vaccinations";
type Tab = "overview" | "weight" | "vitals" | "vaccinations";

const tabs: { id: Tab; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "weight", label: "Weight History" },
{ id: "vitals", label: "Vitals" },
{ id: "vaccinations", label: "Vaccinations" },
];

Expand Down Expand Up @@ -515,6 +516,8 @@ export default function PatientDetailPage() {
</div>
)}

{activeTab === "vitals" && <VitalsTab patientId={patient.id} />}

{activeTab === "vaccinations" && (
<VaccinationsTab patientId={patient.id} />
)}
Expand All @@ -523,6 +526,126 @@ export default function PatientDetailPage() {
);
}

function VitalsTab({ patientId }: { patientId: string }) {
const utils = trpc.useUtils();
const { data: vitals, isLoading } = trpc.vitals.listByPatient.useQuery({ patientId });
const record = trpc.vitals.record.useMutation({
onSuccess: () => {
toast.success("Vitals recorded");
utils.vitals.listByPatient.invalidate({ patientId });
setForm({});
},
onError: (err) => toast.error(err.message),
});

const [form, setForm] = useState<Record<string, string>>({});
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const num = (v?: string) => {
if (v === undefined || v.trim() === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};

function submit(e: React.FormEvent) {
e.preventDefault();
record.mutate({
patientId,
temperatureC: num(form.temperatureC),
heartRateBpm: num(form.heartRateBpm),
respiratoryRateBpm: num(form.respiratoryRateBpm),
weightKg: num(form.weightKg),
bodyConditionScore: num(form.bodyConditionScore),
painScore: num(form.painScore),
notes: form.notes?.trim() || undefined,
});
}

const fields: { key: string; label: string; placeholder?: string }[] = [
{ key: "temperatureC", label: "Temp (°C)" },
{ key: "heartRateBpm", label: "HR (bpm)" },
{ key: "respiratoryRateBpm", label: "RR (bpm)" },
{ key: "weightKg", label: "Weight (kg)" },
{ key: "bodyConditionScore", label: "BCS (1-9)" },
{ key: "painScore", label: "Pain (0-10)" },
];

return (
<div className="space-y-6">
<form onSubmit={submit} className="rounded-lg border border-border bg-card p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{fields.map((f) => (
<div key={f.key}>
<label className="mb-1 block text-xs font-medium text-muted-foreground">
{f.label}
</label>
<input
type="number"
step="any"
value={form[f.key] ?? ""}
onChange={set(f.key)}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
))}
</div>
<input
type="text"
value={form.notes ?? ""}
onChange={set("notes")}
placeholder="Notes (optional)"
className="mt-3 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<div className="mt-3 flex justify-end">
<Button type="submit" disabled={record.isPending}>
{record.isPending ? "Saving…" : "Record vitals"}
</Button>
</div>
</form>

{isLoading ? (
<div className="py-8 text-center text-muted-foreground">Loading...</div>
) : !vitals || vitals.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center">
<Activity className="mx-auto h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">No vitals recorded yet</p>
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50 text-left text-muted-foreground">
<th className="px-3 py-2 font-medium">Date</th>
<th className="px-3 py-2 font-medium">Temp</th>
<th className="px-3 py-2 font-medium">HR</th>
<th className="px-3 py-2 font-medium">RR</th>
<th className="px-3 py-2 font-medium">Weight</th>
<th className="px-3 py-2 font-medium">BCS</th>
<th className="px-3 py-2 font-medium">Pain</th>
</tr>
</thead>
<tbody>
{vitals.map((v) => (
<tr key={v.id} className="border-b border-border last:border-0">
<td className="px-3 py-2">
{v.recordedAt ? new Date(v.recordedAt).toLocaleString() : "—"}
</td>
<td className="px-3 py-2">{v.temperatureC ?? "—"}</td>
<td className="px-3 py-2">{v.heartRateBpm ?? "—"}</td>
<td className="px-3 py-2">{v.respiratoryRateBpm ?? "—"}</td>
<td className="px-3 py-2">{v.weightKg ?? "—"}</td>
<td className="px-3 py-2">{v.bodyConditionScore ?? "—"}</td>
<td className="px-3 py-2">{v.painScore ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

function VaccinationsTab({ patientId }: { patientId: string }) {
const { data: vaccinations, isLoading } =
trpc.records.listVaccinations.useQuery({ patientId });
Expand Down
Loading
Loading