Skip to content

Commit da37010

Browse files
authored
Merge pull request #2 from evangauer/feat/api-v1-compat-layer
API + Agent layer, scheduling overhaul, and clinical depth
2 parents 9b63208 + 50deae8 commit da37010

75 files changed

Lines changed: 6277 additions & 73 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ CRON_SECRET=
3131
IDEXX_API_KEY=
3232
ANTECH_API_KEY=
3333
ZOETIS_API_KEY=
34+
35+
# OpenVPM Agent (AI). Without ANTHROPIC_API_KEY the agent UI shows a setup
36+
# notice and agent runs are disabled; everything else works normally.
37+
ANTHROPIC_API_KEY=
38+
# Optional model override for the agent (defaults to claude-sonnet-4-6).
39+
AGENT_MODEL=

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@ jobs:
2424

2525
- run: pnpm type-check
2626

27+
- run: pnpm test
28+
2729
- run: pnpm build
2830
# Note: next build includes linting — no separate lint step needed

CONTRIBUTING.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,47 @@ Thank you for your interest in contributing to OpenVPM!
3131

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

37+
## Testing
38+
39+
- **Unit tests** (Vitest): `pnpm test`. Co-locate as `*.test.ts` next to the code
40+
(e.g. `lib/compat/openvpm/__tests__/`). Pure logic — mappers, helpers — should
41+
be unit-tested without a database.
42+
- **E2E tests** (Playwright): `pnpm test:e2e`.
43+
3744
## Code Style
3845

3946
- TypeScript strict mode
4047
- Tailwind CSS for styling (follow existing design tokens)
41-
- tRPC for all API endpoints
48+
- tRPC for all internal API endpoints
4249
- Drizzle ORM for database queries
4350

51+
## Adding a compatibility endpoint or target
52+
53+
The public REST API ([docs/api](docs/api/README.md)) is OpenVPM's integration
54+
moat: integrators (and AI agents) plug into a frozen, vendor-shaped contract.
55+
The layout makes adding endpoints — and entire vendor-compatible "targets" —
56+
repeatable:
57+
58+
- **Route handlers**: `apps/web/app/api/v1/<resource>/route.ts`. Keep them
59+
**thin** — authenticate → validate → tenant-scoped Drizzle query → map →
60+
respond. No business logic or shape knowledge in the handler.
61+
- **Auth**: call `authenticateApiKey(req, "<scope>")` from `lib/api-auth.ts`.
62+
**Every** query MUST be scoped by `ctx.practiceId` and filter
63+
`isNull(table.deletedAt)` — a cross-tenant read is a security bug.
64+
- **Mappers**: `lib/compat/<target>/mappers.ts` holds **pure** functions that
65+
translate internal rows ↔ the target's shapes (enum crosswalks, date formats).
66+
The target's request/response shapes live in `lib/compat/<target>/schema.ts`.
67+
- **A new target** (e.g. an existing PIMS's public API) is a new
68+
`lib/compat/<vendor>/` module plus an `app/api/compat/<vendor>/v1/` namespace —
69+
the auth, pagination, and error helpers are shared.
70+
71+
Every endpoint ships with mapper unit tests **and** a contract test
72+
(`schema.parse(toApiX(row))`) so internal changes can't silently break the
73+
public contract.
74+
4475
## License
4576

4677
By contributing, you agree that your contributions will be licensed under the MIT License.

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,17 @@ The Docker setup includes PostgreSQL 16 with health checks, MinIO for S3-compati
223223

224224
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.
225225

226+
### REST API (v1)
227+
228+
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.
229+
230+
```bash
231+
curl https://demo.openvpm.com/api/v1/clients \
232+
-H "Authorization: Bearer ovpm_…"
233+
```
234+
235+
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).
236+
226237
### Webhooks
227238

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

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

270+
### OpenVPM Agent
271+
272+
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.
273+
259274
## Why Open Source Matters for Veterinary Medicine
260275

261276
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.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Bot, Send, ChevronDown, ChevronRight, AlertTriangle, Wrench } from "lucide-react";
5+
import { trpc } from "@/lib/trpc";
6+
import { cn } from "@/lib/utils";
7+
import { Button } from "@/components/ui/button";
8+
9+
const SUGGESTIONS = [
10+
"Which patients are overdue for vaccinations?",
11+
"Summarize today's appointments.",
12+
"What's the carprofen dose for a 12 kg dog?",
13+
"Pull a clinical summary for the next patient checked in.",
14+
];
15+
16+
export default function AgentPage() {
17+
const status = trpc.agent.status.useQuery();
18+
const run = trpc.agent.run.useMutation();
19+
const [instruction, setInstruction] = useState("");
20+
const [allowWrites, setAllowWrites] = useState(false);
21+
const [traceOpen, setTraceOpen] = useState(false);
22+
23+
const configured = status.data?.configured ?? true;
24+
25+
function submit() {
26+
if (!instruction.trim() || run.isPending) return;
27+
run.mutate({ instruction: instruction.trim(), allowWrites });
28+
}
29+
30+
return (
31+
<div className="mx-auto max-w-3xl">
32+
<div className="mb-6 flex items-center gap-3">
33+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
34+
<Bot className="h-5 w-5" />
35+
</div>
36+
<div>
37+
<h1 className="font-heading text-2xl font-semibold">OpenVPM Agent</h1>
38+
<p className="text-sm text-muted-foreground">
39+
Ask the agent to work on your practice data. It uses real tools and
40+
never invents records.
41+
</p>
42+
</div>
43+
</div>
44+
45+
{!configured && (
46+
<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">
47+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
48+
<div>
49+
The agent is not configured yet. Set <code className="font-mono">ANTHROPIC_API_KEY</code>{" "}
50+
in your environment to enable agent runs.
51+
</div>
52+
</div>
53+
)}
54+
55+
<div className="rounded-xl border border-border bg-surface p-4">
56+
<textarea
57+
value={instruction}
58+
onChange={(e) => setInstruction(e.target.value)}
59+
onKeyDown={(e) => {
60+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit();
61+
}}
62+
rows={3}
63+
placeholder="Ask the agent to do something… (⌘/Ctrl + Enter to run)"
64+
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"
65+
/>
66+
<div className="mt-3 flex items-center justify-between">
67+
<label className="flex items-center gap-2 text-sm text-muted-foreground">
68+
<input
69+
type="checkbox"
70+
checked={allowWrites}
71+
onChange={(e) => setAllowWrites(e.target.checked)}
72+
className="h-4 w-4 rounded border-border"
73+
/>
74+
Allow writes (e.g. booking appointments)
75+
</label>
76+
<Button onClick={submit} disabled={!instruction.trim() || run.isPending}>
77+
<Send className="mr-1.5 h-4 w-4" />
78+
{run.isPending ? "Working…" : "Run"}
79+
</Button>
80+
</div>
81+
</div>
82+
83+
<div className="mt-3 flex flex-wrap gap-2">
84+
{SUGGESTIONS.map((s) => (
85+
<button
86+
key={s}
87+
onClick={() => setInstruction(s)}
88+
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"
89+
>
90+
{s}
91+
</button>
92+
))}
93+
</div>
94+
95+
{run.error && (
96+
<div className="mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
97+
{run.error.message}
98+
</div>
99+
)}
100+
101+
{run.data && (
102+
<div className="mt-6 rounded-xl border border-border bg-background p-5">
103+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
104+
{run.data.text}
105+
</div>
106+
107+
{run.data.toolCalls.length > 0 && (
108+
<div className="mt-4 border-t border-border pt-3">
109+
<button
110+
onClick={() => setTraceOpen((o) => !o)}
111+
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
112+
>
113+
{traceOpen ? (
114+
<ChevronDown className="h-3.5 w-3.5" />
115+
) : (
116+
<ChevronRight className="h-3.5 w-3.5" />
117+
)}
118+
<Wrench className="h-3.5 w-3.5" />
119+
{run.data.toolCalls.length} tool call
120+
{run.data.toolCalls.length === 1 ? "" : "s"}
121+
</button>
122+
{traceOpen && (
123+
<ul className="mt-2 space-y-2">
124+
{run.data.toolCalls.map((call, i) => (
125+
<li
126+
key={i}
127+
className={cn(
128+
"rounded-md border p-2 font-mono text-xs",
129+
call.error
130+
? "border-destructive/30 bg-destructive/5 text-destructive"
131+
: "border-border bg-surface text-muted-foreground"
132+
)}
133+
>
134+
<div className="font-semibold text-foreground">{call.name}</div>
135+
<div className="mt-1 break-all">
136+
{JSON.stringify(call.input)}
137+
</div>
138+
{call.error && <div className="mt-1">{call.error}</div>}
139+
</li>
140+
))}
141+
</ul>
142+
)}
143+
</div>
144+
)}
145+
</div>
146+
)}
147+
</div>
148+
);
149+
}

apps/web/app/(dashboard)/patients/[id]/page.tsx

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ function calculateAge(dob: string | null): string {
5656
return `${adjustedYears}y ${adjustedMonths}m`;
5757
}
5858

59-
type Tab = "overview" | "weight" | "vaccinations";
59+
type Tab = "overview" | "weight" | "vitals" | "vaccinations";
6060

6161
const tabs: { id: Tab; label: string }[] = [
6262
{ id: "overview", label: "Overview" },
6363
{ id: "weight", label: "Weight History" },
64+
{ id: "vitals", label: "Vitals" },
6465
{ id: "vaccinations", label: "Vaccinations" },
6566
];
6667

@@ -515,6 +516,8 @@ export default function PatientDetailPage() {
515516
</div>
516517
)}
517518

519+
{activeTab === "vitals" && <VitalsTab patientId={patient.id} />}
520+
518521
{activeTab === "vaccinations" && (
519522
<VaccinationsTab patientId={patient.id} />
520523
)}
@@ -523,6 +526,126 @@ export default function PatientDetailPage() {
523526
);
524527
}
525528

529+
function VitalsTab({ patientId }: { patientId: string }) {
530+
const utils = trpc.useUtils();
531+
const { data: vitals, isLoading } = trpc.vitals.listByPatient.useQuery({ patientId });
532+
const record = trpc.vitals.record.useMutation({
533+
onSuccess: () => {
534+
toast.success("Vitals recorded");
535+
utils.vitals.listByPatient.invalidate({ patientId });
536+
setForm({});
537+
},
538+
onError: (err) => toast.error(err.message),
539+
});
540+
541+
const [form, setForm] = useState<Record<string, string>>({});
542+
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
543+
setForm((f) => ({ ...f, [k]: e.target.value }));
544+
const num = (v?: string) => {
545+
if (v === undefined || v.trim() === "") return undefined;
546+
const n = Number(v);
547+
return Number.isFinite(n) ? n : undefined;
548+
};
549+
550+
function submit(e: React.FormEvent) {
551+
e.preventDefault();
552+
record.mutate({
553+
patientId,
554+
temperatureC: num(form.temperatureC),
555+
heartRateBpm: num(form.heartRateBpm),
556+
respiratoryRateBpm: num(form.respiratoryRateBpm),
557+
weightKg: num(form.weightKg),
558+
bodyConditionScore: num(form.bodyConditionScore),
559+
painScore: num(form.painScore),
560+
notes: form.notes?.trim() || undefined,
561+
});
562+
}
563+
564+
const fields: { key: string; label: string; placeholder?: string }[] = [
565+
{ key: "temperatureC", label: "Temp (°C)" },
566+
{ key: "heartRateBpm", label: "HR (bpm)" },
567+
{ key: "respiratoryRateBpm", label: "RR (bpm)" },
568+
{ key: "weightKg", label: "Weight (kg)" },
569+
{ key: "bodyConditionScore", label: "BCS (1-9)" },
570+
{ key: "painScore", label: "Pain (0-10)" },
571+
];
572+
573+
return (
574+
<div className="space-y-6">
575+
<form onSubmit={submit} className="rounded-lg border border-border bg-card p-4">
576+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
577+
{fields.map((f) => (
578+
<div key={f.key}>
579+
<label className="mb-1 block text-xs font-medium text-muted-foreground">
580+
{f.label}
581+
</label>
582+
<input
583+
type="number"
584+
step="any"
585+
value={form[f.key] ?? ""}
586+
onChange={set(f.key)}
587+
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"
588+
/>
589+
</div>
590+
))}
591+
</div>
592+
<input
593+
type="text"
594+
value={form.notes ?? ""}
595+
onChange={set("notes")}
596+
placeholder="Notes (optional)"
597+
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"
598+
/>
599+
<div className="mt-3 flex justify-end">
600+
<Button type="submit" disabled={record.isPending}>
601+
{record.isPending ? "Saving…" : "Record vitals"}
602+
</Button>
603+
</div>
604+
</form>
605+
606+
{isLoading ? (
607+
<div className="py-8 text-center text-muted-foreground">Loading...</div>
608+
) : !vitals || vitals.length === 0 ? (
609+
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center">
610+
<Activity className="mx-auto h-8 w-8 text-muted-foreground/50" />
611+
<p className="mt-2 text-sm text-muted-foreground">No vitals recorded yet</p>
612+
</div>
613+
) : (
614+
<div className="overflow-x-auto rounded-lg border border-border">
615+
<table className="w-full text-sm">
616+
<thead>
617+
<tr className="border-b border-border bg-muted/50 text-left text-muted-foreground">
618+
<th className="px-3 py-2 font-medium">Date</th>
619+
<th className="px-3 py-2 font-medium">Temp</th>
620+
<th className="px-3 py-2 font-medium">HR</th>
621+
<th className="px-3 py-2 font-medium">RR</th>
622+
<th className="px-3 py-2 font-medium">Weight</th>
623+
<th className="px-3 py-2 font-medium">BCS</th>
624+
<th className="px-3 py-2 font-medium">Pain</th>
625+
</tr>
626+
</thead>
627+
<tbody>
628+
{vitals.map((v) => (
629+
<tr key={v.id} className="border-b border-border last:border-0">
630+
<td className="px-3 py-2">
631+
{v.recordedAt ? new Date(v.recordedAt).toLocaleString() : "—"}
632+
</td>
633+
<td className="px-3 py-2">{v.temperatureC ?? "—"}</td>
634+
<td className="px-3 py-2">{v.heartRateBpm ?? "—"}</td>
635+
<td className="px-3 py-2">{v.respiratoryRateBpm ?? "—"}</td>
636+
<td className="px-3 py-2">{v.weightKg ?? "—"}</td>
637+
<td className="px-3 py-2">{v.bodyConditionScore ?? "—"}</td>
638+
<td className="px-3 py-2">{v.painScore ?? "—"}</td>
639+
</tr>
640+
))}
641+
</tbody>
642+
</table>
643+
</div>
644+
)}
645+
</div>
646+
);
647+
}
648+
526649
function VaccinationsTab({ patientId }: { patientId: string }) {
527650
const { data: vaccinations, isLoading } =
528651
trpc.records.listVaccinations.useQuery({ patientId });

0 commit comments

Comments
 (0)