From 8a93111c43cf4ba3fe64891abc0bb405b16dfc4b Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 20 Apr 2026 18:51:15 -0400 Subject: [PATCH 01/25] Add e2e regression test for public repo appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visits the public GitHub repo in a clean browser context and verifies first-time visitors see the expected repo landing state — README images load, CI badge is present, no page errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/public-repo-check.spec.ts | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 e2e/public-repo-check.spec.ts diff --git a/e2e/public-repo-check.spec.ts b/e2e/public-repo-check.spec.ts new file mode 100644 index 0000000..adc1792 --- /dev/null +++ b/e2e/public-repo-check.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; +import path from "path"; + +// Visit the public GitHub repo in a clean browser context (no GitHub cookies) +// and confirm a first-time visitor sees exactly what we want. + +test.use({ + storageState: { cookies: [], origins: [] }, + viewport: { width: 1400, height: 900 }, +}); + +test("public repo passes sniff test", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto("https://github.com/evangauer/openvpm", { waitUntil: "networkidle" }); + + // 1. Repo is visible (no "Sign in" wall) + await expect(page.locator("h1")).toContainText(/openvpm/i); + + // 2. Description + topics visible + const description = await page.locator('[data-testid="repo-sidebar"], .f4, [aria-label="About"]').first().textContent().catch(() => ""); + console.log("Description area:", description?.slice(0, 300)); + + // 3. README screenshots load (not broken images) + const brokenImages = await page.$$eval("img", (imgs) => + imgs + .filter((img) => (img as HTMLImageElement).src.includes("docs/screenshots") || (img as HTMLImageElement).src.includes("openvpm.com/logo")) + .map((img) => ({ + src: (img as HTMLImageElement).src, + natural: (img as HTMLImageElement).naturalWidth > 0, + })) + ); + console.log("README-embedded images:", JSON.stringify(brokenImages, null, 2)); + + // 4. CI badge visible and green (not 'failing') + const ciBadge = await page.locator('img[alt="CI"]').first().getAttribute("src").catch(() => null); + console.log("CI badge src:", ciBadge); + + // 5. Take full-page screenshot for visual verification + await page.screenshot({ + path: path.resolve(__dirname, "../docs/brand/public-repo.png"), + fullPage: true, + }); + + // 6. Console errors from GitHub's own JS — filter out noise + const realErrors = errors.filter((e) => !e.includes("ResizeObserver")); + console.log(`Page errors: ${realErrors.length}`); + realErrors.forEach((e) => console.log(" -", e)); + + expect(brokenImages.filter((i) => !i.natural).length).toBe(0); +}); From 9056054ca2baf26679c73a99d959a9924ca280b1 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 22:53:17 -0400 Subject: [PATCH 02/25] Add Vitest unit-test runner and wire it into CI apps/web had only Playwright e2e; pure logic (mappers, auth helpers) had no unit coverage. Adds vitest with a node environment and a manual @/ alias, a 'test' script, the turbo 'test' task, and a CI step so unit tests run on every push/PR. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 + apps/web/package.json | 7 +- apps/web/vitest.config.ts | 16 + package.json | 1 + pnpm-lock.yaml | 804 ++++++++++++++++++++++++++++++++++++++ turbo.json | 4 + 6 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 apps/web/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 008b103..6695135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index d540506..17e8224 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@aws-sdk/client-s3": "^3.700.0", @@ -59,6 +61,7 @@ "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^2.1.8" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..46ffe7a --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { resolve } from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + // Mirror the tsconfig "@/*" -> "./*" path alias used across apps/web. + alias: [{ find: /^@\//, replacement: resolve(__dirname, "./") + "/" }], + }, + test: { + environment: "node", + // Unit tests only — pure logic (mappers, auth helpers). The Playwright + // e2e suite lives under /e2e and is run separately via `pnpm test:e2e`. + include: ["**/*.test.ts"], + exclude: ["node_modules", ".next", "../../e2e/**"], + }, +}); diff --git a/package.json b/package.json index b0ec2f8..20fd147 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "turbo dev", "lint": "turbo lint", "type-check": "turbo type-check", + "test": "turbo test", "db:push": "pnpm --filter @openpims/db db:push", "db:generate": "pnpm --filter @openpims/db db:generate", "db:migrate": "pnpm --filter @openpims/db db:migrate", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42f9521..dffb6c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: typescript: specifier: ^5.5.0 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@20.19.37) apps/www: dependencies: @@ -465,6 +468,12 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -483,6 +492,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -501,6 +516,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -519,6 +540,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -537,6 +564,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -555,6 +588,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -573,6 +612,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -591,6 +636,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -609,6 +660,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -627,6 +684,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -645,6 +708,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -663,6 +732,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -681,6 +756,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -699,6 +780,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -717,6 +804,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -735,6 +828,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -753,6 +852,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -783,6 +888,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -813,6 +924,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -843,6 +960,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -861,6 +984,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -879,6 +1008,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -897,6 +1032,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1565,6 +1706,131 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} + cpu: [x64] + os: [win32] + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -1868,6 +2134,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} @@ -1920,6 +2189,35 @@ packages: vue-router: optional: true + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1958,6 +2256,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2015,6 +2317,10 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2034,6 +2340,14 @@ packages: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -2161,6 +2475,10 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -2333,6 +2651,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2346,6 +2667,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -2360,9 +2686,16 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@2.0.1: resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} @@ -2618,6 +2951,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2630,6 +2966,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@7.0.4: resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} engines: {node: '>= 16'} @@ -2779,6 +3118,13 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -3011,6 +3357,11 @@ packages: resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} engines: {node: '>= 0.8.15'} + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3056,6 +3407,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3077,6 +3431,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} engines: {node: '>=0.1.14'} @@ -3084,6 +3441,9 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -3178,10 +3538,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3291,11 +3669,77 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3794,6 +4238,9 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.13.6 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3803,6 +4250,9 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -3812,6 +4262,9 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -3821,6 +4274,9 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -3830,6 +4286,9 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -3839,6 +4298,9 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -3848,6 +4310,9 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -3857,6 +4322,9 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -3866,6 +4334,9 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -3875,6 +4346,9 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -3884,6 +4358,9 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -3893,6 +4370,9 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -3902,6 +4382,9 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -3911,6 +4394,9 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -3920,6 +4406,9 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -3929,6 +4418,9 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -3938,6 +4430,9 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -3953,6 +4448,9 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -3968,6 +4466,9 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -3983,6 +4484,9 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -3992,6 +4496,9 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -4001,6 +4508,9 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -4010,6 +4520,9 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -4620,6 +5133,81 @@ snapshots: dependencies: react: 18.3.1 + '@rollup/rollup-android-arm-eabi@4.61.0': + optional: true + + '@rollup/rollup-android-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-x64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.0': + optional: true + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -5035,6 +5623,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.9': {} + '@types/node@20.19.37': dependencies: undici-types: 6.21.0 @@ -5063,6 +5653,46 @@ snapshots: next: 14.2.35(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.37))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.37) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + abbrev@2.0.0: {} agent-base@6.0.2: @@ -5094,6 +5724,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -5150,6 +5782,8 @@ snapshots: dependencies: streamsearch: 1.1.0 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5176,6 +5810,16 @@ snapshots: svg-pathdata: 6.0.3 optional: true + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -5297,6 +5941,8 @@ snapshots: decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} + deepmerge@4.3.1: {} delayed-stream@1.0.0: {} @@ -5380,6 +6026,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5416,6 +6064,32 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -5476,8 +6150,14 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + eventemitter3@4.0.7: {} + expect-type@1.3.0: {} + fast-deep-equal@2.0.1: {} fast-equals@5.4.0: {} @@ -5743,6 +6423,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@6.0.0: @@ -5753,6 +6435,10 @@ snapshots: dependencies: react: 18.3.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + marked@7.0.4: {} math-intrinsics@1.1.0: {} @@ -5884,6 +6570,10 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + pathe@1.1.2: {} + + pathval@2.0.1: {} + peberminta@0.9.0: {} performance-now@2.1.0: @@ -6097,6 +6787,37 @@ snapshots: rgbcolor@1.0.1: optional: true + rollup@4.61.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -6149,6 +6870,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -6165,6 +6888,8 @@ snapshots: source-map@0.6.1: {} + stackback@0.0.2: {} + stackblur-canvas@2.7.0: optional: true @@ -6173,6 +6898,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + std-env@3.10.0: {} + streamsearch@1.1.0: {} string-width@4.2.3: @@ -6281,11 +7008,21 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6398,10 +7135,77 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite-node@2.1.9(@types/node@20.19.37): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.37) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.37): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.61.0 + optionalDependencies: + '@types/node': 20.19.37 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@20.19.37): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.37)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.37) + vite-node: 2.1.9(@types/node@20.19.37) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.37 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/turbo.json b/turbo.json index 60e0494..ba3dd31 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,10 @@ "type-check": { "dependsOn": ["^build"] }, + "test": { + "dependsOn": ["^build"], + "outputs": [] + }, "clean": { "cache": false } From 28be3bf91b57d3830082e6da2b3d61e70a78a65c Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 22:53:25 -0400 Subject: [PATCH 03/25] Wire API-key authentication for the public API The apiKeys table existed but was never connected to any auth path. Adds: - An indexed key_prefix column so a raw key can be narrowed to a small candidate set before a constant-time bcrypt compare (bcrypt hashes can't be looked up directly). - lib/api-auth.ts: parse Bearer/X-API-Key, verify, enforce scopes and a per-key rate limit (reusing lib/rate-limit.ts), return a tenant context. - An admin api-keys tRPC router (create/list/revoke) that returns the raw key exactly once and stores only the bcrypt hash. - Unit tests for key generation, header extraction, and scope checks. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/__tests__/api-auth.test.ts | 71 +++++++++++ apps/web/lib/api-auth.ts | 154 ++++++++++++++++++++++++ apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/api-keys.ts | 76 ++++++++++++ packages/db/schema/communications.ts | 29 +++-- 5 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 apps/web/lib/__tests__/api-auth.test.ts create mode 100644 apps/web/lib/api-auth.ts create mode 100644 apps/web/server/routers/api-keys.ts diff --git a/apps/web/lib/__tests__/api-auth.test.ts b/apps/web/lib/__tests__/api-auth.test.ts new file mode 100644 index 0000000..28a3040 --- /dev/null +++ b/apps/web/lib/__tests__/api-auth.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import bcrypt from "bcryptjs"; +import { + generateApiKey, + extractApiKey, + hasScope, + API_KEY_PREFIX, +} from "../api-auth"; + +describe("generateApiKey", () => { + it("produces a prefixed key whose hash verifies and whose prefix is the first 12 chars", async () => { + const { raw, prefix, hash } = await generateApiKey(); + expect(raw.startsWith(API_KEY_PREFIX)).toBe(true); + expect(prefix).toBe(raw.slice(0, 12)); + expect(await bcrypt.compare(raw, hash)).toBe(true); + }); + + it("does not verify against a different key", async () => { + const a = await generateApiKey(); + const b = await generateApiKey(); + expect(await bcrypt.compare(b.raw, a.hash)).toBe(false); + expect(a.raw).not.toBe(b.raw); + }); +}); + +describe("extractApiKey", () => { + it("reads a Bearer token", () => { + const h = new Headers({ authorization: "Bearer ovpm_abc123" }); + expect(extractApiKey(h)).toBe("ovpm_abc123"); + }); + + it("reads X-API-Key when no Bearer present", () => { + const h = new Headers({ "x-api-key": "ovpm_xyz789" }); + expect(extractApiKey(h)).toBe("ovpm_xyz789"); + }); + + it("prefers Bearer over X-API-Key", () => { + const h = new Headers({ + authorization: "Bearer ovpm_bearer", + "x-api-key": "ovpm_header", + }); + expect(extractApiKey(h)).toBe("ovpm_bearer"); + }); + + it("returns null when no key is present", () => { + expect(extractApiKey(new Headers())).toBeNull(); + }); + + it("returns null for a non-Bearer Authorization scheme", () => { + const h = new Headers({ authorization: "Basic dXNlcjpwYXNz" }); + expect(extractApiKey(h)).toBeNull(); + }); +}); + +describe("hasScope", () => { + it("grants when the exact scope is present", () => { + expect(hasScope(["clients:read", "patients:read"], "clients:read")).toBe(true); + }); + + it("denies when the scope is absent", () => { + expect(hasScope(["patients:read"], "appointments:write")).toBe(false); + }); + + it("grants any scope when the wildcard is present", () => { + expect(hasScope(["*"], "appointments:write")).toBe(true); + }); + + it("denies on an empty scope list", () => { + expect(hasScope([], "clients:read")).toBe(false); + }); +}); diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts new file mode 100644 index 0000000..eb19303 --- /dev/null +++ b/apps/web/lib/api-auth.ts @@ -0,0 +1,154 @@ +import { randomBytes } from "crypto"; +import bcrypt from "bcryptjs"; +import { eq, and, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { apiKeys } from "@openpims/db"; +import { rateLimit } from "@/lib/rate-limit"; + +/** Public prefix for every issued key. Also used as the human-visible label. */ +export const API_KEY_PREFIX = "ovpm_"; +/** Length of the stored, indexed lookup prefix (e.g. "ovpm_AbC1234"). */ +const LOOKUP_PREFIX_LEN = 12; +/** Per-key request budget. In-memory + per-instance — see docs/api/README.md. */ +const RATE_LIMIT = 600; +const RATE_WINDOW_MS = 60_000; + +export interface ApiKeyContext { + practiceId: string; + apiKeyId: string; + scopes: string[]; +} + +export type AuthResult = + | { ok: true; ctx: ApiKeyContext } + | { ok: false; response: NextResponse }; + +/** + * Generate a new API key. Returns the raw key (shown to the user exactly once), + * the indexed lookup prefix, and the bcrypt hash to persist. The raw key is + * never stored. + */ +export async function generateApiKey(): Promise<{ + raw: string; + prefix: string; + hash: string; +}> { + const raw = API_KEY_PREFIX + randomBytes(24).toString("base64url"); + const prefix = raw.slice(0, LOOKUP_PREFIX_LEN); + const hash = await bcrypt.hash(raw, 10); + return { raw, prefix, hash }; +} + +/** Extract the raw key from `Authorization: Bearer` (preferred) or `X-API-Key`. */ +export function extractApiKey(headers: Headers): string | null { + const auth = headers.get("authorization"); + if (auth?.startsWith("Bearer ")) return auth.slice(7).trim(); + const headerKey = headers.get("x-api-key"); + if (headerKey) return headerKey.trim(); + return null; +} + +/** `*` grants everything; otherwise the exact scope must be present. */ +export function hasScope(scopes: string[], required: string): boolean { + return scopes.includes("*") || scopes.includes(required); +} + +function err(message: string, status: number): { ok: false; response: NextResponse } { + return { + ok: false, + response: NextResponse.json({ error: { message } }, { status }), + }; +} + +/** + * Authenticate a request via API key and assert it carries `requiredScope`. + * + * Flow: parse header → narrow candidates by indexed prefix → constant-time + * bcrypt compare → enforce per-key rate limit → return tenant context. The + * caller MUST still scope every query by `ctx.practiceId`. + */ +export async function authenticateApiKey( + req: Request, + requiredScope: string +): Promise { + const raw = extractApiKey(req.headers); + if (!raw) { + return err("Missing API key. Send 'Authorization: Bearer ' or 'X-API-Key'.", 401); + } + + const prefix = raw.slice(0, LOOKUP_PREFIX_LEN); + + let candidates; + try { + candidates = await db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.deletedAt))); + } catch (e) { + console.error("[api-auth] key lookup failed:", e); + return err("Internal error", 500); + } + + let matched: (typeof candidates)[number] | null = null; + for (const candidate of candidates) { + if (await bcrypt.compare(raw, candidate.keyHash)) { + matched = candidate; + break; + } + } + + if (!matched) { + return err("Invalid API key.", 401); + } + + const scopes = Array.isArray(matched.scopes) ? (matched.scopes as string[]) : []; + if (!hasScope(scopes, requiredScope)) { + return err(`API key missing required scope: ${requiredScope}`, 403); + } + + const { success, remaining } = rateLimit({ + key: `apikey:${matched.id}`, + limit: RATE_LIMIT, + windowMs: RATE_WINDOW_MS, + }); + if (!success) { + return { + ok: false, + response: NextResponse.json( + { error: { message: "Rate limit exceeded." } }, + { + status: 429, + headers: { + "Retry-After": String(Math.ceil(RATE_WINDOW_MS / 1000)), + "X-RateLimit-Limit": String(RATE_LIMIT), + "X-RateLimit-Remaining": "0", + }, + } + ), + }; + } + + // Audit trail — non-blocking, never fail the request on this. + void db + .update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, matched.id)) + .catch((e) => console.error("[api-auth] lastUsedAt update failed:", e)); + + return { + ok: true, + ctx: { practiceId: matched.practiceId, apiKeyId: matched.id, scopes }, + }; +} + +/** Scopes a practice admin can grant to an API key. */ +export const API_SCOPES = [ + "clients:read", + "patients:read", + "appointments:read", + "appointments:write", + "*", +] as const; + +export type ApiScope = (typeof API_SCOPES)[number]; diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index 1b6efc2..aaefabc 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -19,6 +19,7 @@ import { notificationsRouter } from "./notifications"; import { templatesRouter } from "./templates"; import { controlledSubstancesRouter } from "./controlled-substances"; import { insuranceRouter } from "./insurance"; +import { apiKeysRouter } from "./api-keys"; export const appRouter = createRouter({ auth: authRouter, @@ -41,6 +42,7 @@ export const appRouter = createRouter({ templates: templatesRouter, controlledSubstances: controlledSubstancesRouter, insurance: insuranceRouter, + apiKeys: apiKeysRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/api-keys.ts b/apps/web/server/routers/api-keys.ts new file mode 100644 index 0000000..eb5a201 --- /dev/null +++ b/apps/web/server/routers/api-keys.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { eq, and, isNull, desc } from "drizzle-orm"; +import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { apiKeys } from "@openpims/db"; +import { generateApiKey, API_SCOPES } from "@/lib/api-auth"; + +const adminProcedure = protectedProcedure.use(requireRole("admin")); + +export const apiKeysRouter = createRouter({ + list: adminProcedure.query(async ({ ctx }) => { + return ctx.db + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + scopes: apiKeys.scopes, + lastUsedAt: apiKeys.lastUsedAt, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where( + and(eq(apiKeys.practiceId, ctx.practiceId), isNull(apiKeys.deletedAt)) + ) + .orderBy(desc(apiKeys.createdAt)); + }), + + create: adminProcedure + .input( + z.object({ + name: z.string().min(1).max(128), + scopes: z.array(z.enum(API_SCOPES)).min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const { raw, prefix, hash } = await generateApiKey(); + + const [created] = await ctx.db + .insert(apiKeys) + .values({ + practiceId: ctx.practiceId, + name: input.name, + scopes: input.scopes, + keyPrefix: prefix, + keyHash: hash, + }) + .returning({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + scopes: apiKeys.scopes, + createdAt: apiKeys.createdAt, + }); + + // The raw key is returned exactly once and never persisted in plaintext. + return { ...created!, key: raw }; + }), + + revoke: adminProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const [revoked] = await ctx.db + .update(apiKeys) + .set({ deletedAt: new Date() }) + .where( + and( + eq(apiKeys.id, input.id), + eq(apiKeys.practiceId, ctx.practiceId), + isNull(apiKeys.deletedAt) + ) + ) + .returning({ id: apiKeys.id }); + + if (!revoked) throw new Error("API key not found"); + return { success: true as const }; + }), +}); diff --git a/packages/db/schema/communications.ts b/packages/db/schema/communications.ts index 3038a61..04d7aa3 100644 --- a/packages/db/schema/communications.ts +++ b/packages/db/schema/communications.ts @@ -60,16 +60,25 @@ export const webhooks = pgTable("webhooks", { active: boolean("active").notNull().default(true), }); -export const apiKeys = pgTable("api_keys", { - ...baseColumns(), - practiceId: uuid("practice_id") - .notNull() - .references(() => practices.id), - keyHash: varchar("key_hash", { length: 255 }).notNull(), - name: varchar("name", { length: 128 }).notNull(), - scopes: jsonb("scopes").notNull().default([]), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), -}); +export const apiKeys = pgTable( + "api_keys", + { + ...baseColumns(), + practiceId: uuid("practice_id") + .notNull() + .references(() => practices.id), + // Non-secret prefix (e.g. "ovpm_AbC123") used as a fast, indexed lookup to + // narrow candidates before a constant-time bcrypt compare against keyHash. + keyPrefix: varchar("key_prefix", { length: 16 }), + keyHash: varchar("key_hash", { length: 255 }).notNull(), + name: varchar("name", { length: 128 }).notNull(), + scopes: jsonb("scopes").notNull().default([]), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + }, + (table) => ({ + prefixIdx: index("api_keys_prefix_idx").on(table.keyPrefix), + }) +); export const auditLog = pgTable( "audit_log", From f7750ea14a5b3175c390d11b49611ca7fe2740a3 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 22:53:34 -0400 Subject: [PATCH 04/25] Add v1 REST API compatibility layer (clients, patients, appointments) A versioned public REST surface for third-party integrators, built as thin Next.js route handlers that query Drizzle directly and pass rows through a pure, isolated mapper layer. Response shapes are owned by an explicit Zod contract and frozen independently of the internal schema. Endpoints: GET clients, GET clients/:id, GET patients (with ?client_id), GET patients/:id, POST appointments. The appointment write fires the previously never-called appointment.created webhook. Every query is scoped by practiceId + isNull(deletedAt); access is gated by scoped API keys. Mappers normalize internal vocabulary to integrator-friendly shapes (species canine->dog, sex enum split into sex + neutered). Covered by mapper unit tests and contract tests asserting output satisfies the schema. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/api/v1/appointments/route.ts | 50 +++++++ apps/web/app/api/v1/clients/[id]/route.ts | 35 +++++ apps/web/app/api/v1/clients/route.ts | 42 ++++++ apps/web/app/api/v1/patients/[id]/route.ts | 35 +++++ apps/web/app/api/v1/patients/route.ts | 45 +++++++ .../lib/compat/openvpm/__tests__/fixtures.ts | 76 +++++++++++ .../compat/openvpm/__tests__/mappers.test.ts | 111 ++++++++++++++++ .../compat/openvpm/__tests__/schema.test.ts | 84 ++++++++++++ apps/web/lib/compat/openvpm/index.ts | 2 + apps/web/lib/compat/openvpm/mappers.ts | 125 ++++++++++++++++++ apps/web/lib/compat/openvpm/schema.ts | 97 ++++++++++++++ apps/web/lib/compat/shared/errors.ts | 34 +++++ apps/web/lib/compat/shared/pagination.ts | 34 +++++ 13 files changed, 770 insertions(+) create mode 100644 apps/web/app/api/v1/appointments/route.ts create mode 100644 apps/web/app/api/v1/clients/[id]/route.ts create mode 100644 apps/web/app/api/v1/clients/route.ts create mode 100644 apps/web/app/api/v1/patients/[id]/route.ts create mode 100644 apps/web/app/api/v1/patients/route.ts create mode 100644 apps/web/lib/compat/openvpm/__tests__/fixtures.ts create mode 100644 apps/web/lib/compat/openvpm/__tests__/mappers.test.ts create mode 100644 apps/web/lib/compat/openvpm/__tests__/schema.test.ts create mode 100644 apps/web/lib/compat/openvpm/index.ts create mode 100644 apps/web/lib/compat/openvpm/mappers.ts create mode 100644 apps/web/lib/compat/openvpm/schema.ts create mode 100644 apps/web/lib/compat/shared/errors.ts create mode 100644 apps/web/lib/compat/shared/pagination.ts diff --git a/apps/web/app/api/v1/appointments/route.ts b/apps/web/app/api/v1/appointments/route.ts new file mode 100644 index 0000000..138008e --- /dev/null +++ b/apps/web/app/api/v1/appointments/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { appointments } from "@openpims/db"; +import { authenticateApiKey } from "@/lib/api-auth"; +import { dispatchWebhookEvent } from "@/lib/webhook-dispatcher"; +import { withErrorHandling, apiError, validationError } from "@/lib/compat/shared/errors"; +import { + AppointmentCreateSchema, + fromApiAppointmentCreate, + toApiAppointment, +} from "@/lib/compat/openvpm"; + +export const dynamic = "force-dynamic"; + +// POST /api/v1/appointments — create an appointment for the authenticated practice. +export async function POST(req: Request) { + const auth = await authenticateApiKey(req, "appointments:write"); + if (!auth.ok) return auth.response; + + return withErrorHandling(async () => { + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("Request body must be valid JSON", 400); + } + + const parsed = AppointmentCreateSchema.safeParse(body); + if (!parsed.success) return validationError(parsed.error); + + const [created] = await db + .insert(appointments) + .values({ + ...fromApiAppointmentCreate(parsed.data), + practiceId: auth.ctx.practiceId, + }) + .returning(); + + const apiAppointment = toApiAppointment(created!); + + // Fire the (previously never-triggered) webhook for this practice. + await dispatchWebhookEvent( + auth.ctx.practiceId, + "appointment.created", + apiAppointment + ); + + return NextResponse.json({ data: apiAppointment }, { status: 201 }); + }); +} diff --git a/apps/web/app/api/v1/clients/[id]/route.ts b/apps/web/app/api/v1/clients/[id]/route.ts new file mode 100644 index 0000000..e190150 --- /dev/null +++ b/apps/web/app/api/v1/clients/[id]/route.ts @@ -0,0 +1,35 @@ +import { eq, and, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { clients } from "@openpims/db"; +import { authenticateApiKey } from "@/lib/api-auth"; +import { withErrorHandling, notFound } from "@/lib/compat/shared/errors"; +import { toApiClient } from "@/lib/compat/openvpm"; + +export const dynamic = "force-dynamic"; + +// GET /api/v1/clients/:id — fetch a single client scoped to the practice. +export async function GET( + req: Request, + { params }: { params: { id: string } } +) { + const auth = await authenticateApiKey(req, "clients:read"); + if (!auth.ok) return auth.response; + + return withErrorHandling(async () => { + const [row] = await db + .select() + .from(clients) + .where( + and( + eq(clients.id, params.id), + eq(clients.practiceId, auth.ctx.practiceId), + isNull(clients.deletedAt) + ) + ) + .limit(1); + + if (!row) return notFound("Client"); + return NextResponse.json({ data: toApiClient(row) }); + }); +} diff --git a/apps/web/app/api/v1/clients/route.ts b/apps/web/app/api/v1/clients/route.ts new file mode 100644 index 0000000..d87f227 --- /dev/null +++ b/apps/web/app/api/v1/clients/route.ts @@ -0,0 +1,42 @@ +import { eq, and, isNull, desc, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { clients } from "@openpims/db"; +import { authenticateApiKey } from "@/lib/api-auth"; +import { withErrorHandling } from "@/lib/compat/shared/errors"; +import { parsePagination, paginated } from "@/lib/compat/shared/pagination"; +import { toApiClient } from "@/lib/compat/openvpm"; + +export const dynamic = "force-dynamic"; + +// GET /api/v1/clients — list clients for the authenticated practice. +export async function GET(req: Request) { + const auth = await authenticateApiKey(req, "clients:read"); + if (!auth.ok) return auth.response; + + return withErrorHandling(async () => { + const { searchParams } = new URL(req.url); + const { limit, offset } = parsePagination(searchParams); + + const where = and( + eq(clients.practiceId, auth.ctx.practiceId), + isNull(clients.deletedAt) + ); + + const [rows, countResult] = await Promise.all([ + db + .select() + .from(clients) + .where(where) + .orderBy(desc(clients.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }).from(clients).where(where), + ]); + + const total = Number(countResult[0]?.count ?? 0); + return NextResponse.json( + paginated(rows.map(toApiClient), { limit, offset }, total) + ); + }); +} diff --git a/apps/web/app/api/v1/patients/[id]/route.ts b/apps/web/app/api/v1/patients/[id]/route.ts new file mode 100644 index 0000000..c0fb165 --- /dev/null +++ b/apps/web/app/api/v1/patients/[id]/route.ts @@ -0,0 +1,35 @@ +import { eq, and, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { patients } from "@openpims/db"; +import { authenticateApiKey } from "@/lib/api-auth"; +import { withErrorHandling, notFound } from "@/lib/compat/shared/errors"; +import { toApiPatient } from "@/lib/compat/openvpm"; + +export const dynamic = "force-dynamic"; + +// GET /api/v1/patients/:id — fetch a single patient scoped to the practice. +export async function GET( + req: Request, + { params }: { params: { id: string } } +) { + const auth = await authenticateApiKey(req, "patients:read"); + if (!auth.ok) return auth.response; + + return withErrorHandling(async () => { + const [row] = await db + .select() + .from(patients) + .where( + and( + eq(patients.id, params.id), + eq(patients.practiceId, auth.ctx.practiceId), + isNull(patients.deletedAt) + ) + ) + .limit(1); + + if (!row) return notFound("Patient"); + return NextResponse.json({ data: toApiPatient(row) }); + }); +} diff --git a/apps/web/app/api/v1/patients/route.ts b/apps/web/app/api/v1/patients/route.ts new file mode 100644 index 0000000..202a5e3 --- /dev/null +++ b/apps/web/app/api/v1/patients/route.ts @@ -0,0 +1,45 @@ +import { eq, and, isNull, desc, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@openpims/db/client"; +import { patients } from "@openpims/db"; +import { authenticateApiKey } from "@/lib/api-auth"; +import { withErrorHandling } from "@/lib/compat/shared/errors"; +import { parsePagination, paginated } from "@/lib/compat/shared/pagination"; +import { toApiPatient } from "@/lib/compat/openvpm"; + +export const dynamic = "force-dynamic"; + +// GET /api/v1/patients — list patients, optionally filtered by ?client_id=. +export async function GET(req: Request) { + const auth = await authenticateApiKey(req, "patients:read"); + if (!auth.ok) return auth.response; + + return withErrorHandling(async () => { + const { searchParams } = new URL(req.url); + const { limit, offset } = parsePagination(searchParams); + const clientId = searchParams.get("client_id"); + + const conditions = [ + eq(patients.practiceId, auth.ctx.practiceId), + isNull(patients.deletedAt), + ]; + if (clientId) conditions.push(eq(patients.clientId, clientId)); + const where = and(...conditions); + + const [rows, countResult] = await Promise.all([ + db + .select() + .from(patients) + .where(where) + .orderBy(desc(patients.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }).from(patients).where(where), + ]); + + const total = Number(countResult[0]?.count ?? 0); + return NextResponse.json( + paginated(rows.map(toApiPatient), { limit, offset }, total) + ); + }); +} diff --git a/apps/web/lib/compat/openvpm/__tests__/fixtures.ts b/apps/web/lib/compat/openvpm/__tests__/fixtures.ts new file mode 100644 index 0000000..52431c6 --- /dev/null +++ b/apps/web/lib/compat/openvpm/__tests__/fixtures.ts @@ -0,0 +1,76 @@ +import type { clients, patients, appointments } from "@openpims/db"; + +type ClientRow = typeof clients.$inferSelect; +type PatientRow = typeof patients.$inferSelect; +type AppointmentRow = typeof appointments.$inferSelect; + +export const CREATED = new Date("2026-01-02T03:04:05.000Z"); +export const UPDATED = new Date("2026-02-03T04:05:06.000Z"); + +export function clientRow(overrides: Partial = {}): ClientRow { + return { + id: "11111111-1111-1111-1111-111111111111", + createdAt: CREATED, + updatedAt: UPDATED, + deletedAt: null, + practiceId: "99999999-9999-9999-9999-999999999999", + firstName: "Jane", + lastName: "Doe", + email: "jane@example.com", + phone: "555-0100", + address: "1 Main St", + city: "Tampa", + state: "FL", + zip: "33601", + emergencyContact: "John Doe", + emergencyPhone: "555-0101", + preferredContactMethod: "email", + notes: "VIP", + accessToken: null, + ...overrides, + } as ClientRow; +} + +export function patientRow(overrides: Partial = {}): PatientRow { + return { + id: "22222222-2222-2222-2222-222222222222", + createdAt: CREATED, + updatedAt: UPDATED, + deletedAt: null, + practiceId: "99999999-9999-9999-9999-999999999999", + clientId: "11111111-1111-1111-1111-111111111111", + name: "Rex", + species: "canine", + breed: "Labrador", + sex: "male_neutered", + dob: "2020-05-15", + color: "black", + microchipNumber: "985112345678900", + photoUrl: null, + status: "active", + ...overrides, + } as PatientRow; +} + +export function appointmentRow( + overrides: Partial = {} +): AppointmentRow { + return { + id: "33333333-3333-3333-3333-333333333333", + createdAt: CREATED, + updatedAt: UPDATED, + deletedAt: null, + practiceId: "99999999-9999-9999-9999-999999999999", + startTime: new Date("2026-03-01T09:00:00.000Z"), + endTime: new Date("2026-03-01T09:30:00.000Z"), + typeId: null, + patientId: "22222222-2222-2222-2222-222222222222", + clientId: "11111111-1111-1111-1111-111111111111", + doctorId: null, + roomId: null, + status: "scheduled", + notes: null, + recurringSeriesId: null, + ...overrides, + } as AppointmentRow; +} diff --git a/apps/web/lib/compat/openvpm/__tests__/mappers.test.ts b/apps/web/lib/compat/openvpm/__tests__/mappers.test.ts new file mode 100644 index 0000000..b5d0002 --- /dev/null +++ b/apps/web/lib/compat/openvpm/__tests__/mappers.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import type { patients } from "@openpims/db"; +import { + toApiClient, + toApiPatient, + toApiAppointment, + fromApiAppointmentCreate, +} from "../mappers"; +import { clientRow, patientRow, appointmentRow } from "./fixtures"; + +type PatientRow = typeof patients.$inferSelect; + +describe("toApiClient", () => { + it("maps fields to snake_case and ISO timestamps", () => { + const result = toApiClient(clientRow()); + expect(result).toMatchObject({ + id: "11111111-1111-1111-1111-111111111111", + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com", + preferred_contact_method: "email", + created_at: "2026-01-02T03:04:05.000Z", + updated_at: "2026-02-03T04:05:06.000Z", + }); + }); + + it("preserves nulls for optional fields", () => { + const result = toApiClient( + clientRow({ email: null, phone: null, notes: null, preferredContactMethod: null }) + ); + expect(result.email).toBeNull(); + expect(result.phone).toBeNull(); + expect(result.notes).toBeNull(); + expect(result.preferred_contact_method).toBeNull(); + }); +}); + +describe("toApiPatient species crosswalk", () => { + const cases: Array<[PatientRow["species"], string]> = [ + ["canine", "dog"], + ["feline", "cat"], + ["avian", "bird"], + ["rabbit", "rabbit"], + ["reptile", "reptile"], + ["equine", "horse"], + ["other", "other"], + ]; + it.each(cases)("maps internal %s -> api %s", (internal, api) => { + expect(toApiPatient(patientRow({ species: internal })).species).toBe(api); + }); +}); + +describe("toApiPatient sex crosswalk", () => { + it("splits collapsed sex enum into sex + neutered", () => { + expect(toApiPatient(patientRow({ sex: "male" }))).toMatchObject({ sex: "male", neutered: false }); + expect(toApiPatient(patientRow({ sex: "female" }))).toMatchObject({ sex: "female", neutered: false }); + expect(toApiPatient(patientRow({ sex: "male_neutered" }))).toMatchObject({ sex: "male", neutered: true }); + expect(toApiPatient(patientRow({ sex: "female_spayed" }))).toMatchObject({ sex: "female", neutered: true }); + }); + + it("maps null sex to unknown with null neutered", () => { + expect(toApiPatient(patientRow({ sex: null }))).toMatchObject({ sex: "unknown", neutered: null }); + }); +}); + +describe("toApiPatient dob passthrough", () => { + it("passes the date string through unchanged", () => { + expect(toApiPatient(patientRow({ dob: "2020-05-15" })).date_of_birth).toBe("2020-05-15"); + }); + it("preserves null dob", () => { + expect(toApiPatient(patientRow({ dob: null })).date_of_birth).toBeNull(); + }); +}); + +describe("toApiAppointment", () => { + it("serializes timestamps to ISO-8601", () => { + const result = toApiAppointment(appointmentRow()); + expect(result.start_time).toBe("2026-03-01T09:00:00.000Z"); + expect(result.end_time).toBe("2026-03-01T09:30:00.000Z"); + expect(result.status).toBe("scheduled"); + expect(result.patient_id).toBe("22222222-2222-2222-2222-222222222222"); + }); +}); + +describe("fromApiAppointmentCreate", () => { + it("parses ISO strings into Date objects and defaults optionals to null", () => { + const result = fromApiAppointmentCreate({ + start_time: "2026-03-01T09:00:00.000Z", + end_time: "2026-03-01T09:30:00.000Z", + }); + expect(result.startTime).toBeInstanceOf(Date); + expect((result.startTime as Date).toISOString()).toBe("2026-03-01T09:00:00.000Z"); + expect(result.clientId).toBeNull(); + expect(result.patientId).toBeNull(); + expect(result.doctorId).toBeNull(); + expect(result.notes).toBeNull(); + }); + + it("maps provided optional ids through", () => { + const result = fromApiAppointmentCreate({ + start_time: "2026-03-01T09:00:00.000Z", + end_time: "2026-03-01T09:30:00.000Z", + client_id: "11111111-1111-1111-1111-111111111111", + patient_id: "22222222-2222-2222-2222-222222222222", + notes: "follow-up", + }); + expect(result.clientId).toBe("11111111-1111-1111-1111-111111111111"); + expect(result.patientId).toBe("22222222-2222-2222-2222-222222222222"); + expect(result.notes).toBe("follow-up"); + }); +}); diff --git a/apps/web/lib/compat/openvpm/__tests__/schema.test.ts b/apps/web/lib/compat/openvpm/__tests__/schema.test.ts new file mode 100644 index 0000000..dac197a --- /dev/null +++ b/apps/web/lib/compat/openvpm/__tests__/schema.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import type { patients } from "@openpims/db"; +import { + ApiClientSchema, + ApiPatientSchema, + ApiAppointmentSchema, + AppointmentCreateSchema, +} from "../schema"; +import { toApiClient, toApiPatient, toApiAppointment } from "../mappers"; +import { clientRow, patientRow, appointmentRow } from "./fixtures"; + +type PatientRow = typeof patients.$inferSelect; + +/** + * Contract guardrail: mapper output MUST satisfy the public schema. If an + * internal change makes a mapper produce a shape the contract forbids, these + * fail loudly before the change can break an integrator. + */ +describe("mapper output satisfies the public contract", () => { + it("toApiClient -> ApiClientSchema", () => { + expect(() => ApiClientSchema.parse(toApiClient(clientRow()))).not.toThrow(); + }); + + it("toApiClient with nulls -> ApiClientSchema", () => { + const row = clientRow({ email: null, phone: null, notes: null, preferredContactMethod: null }); + expect(() => ApiClientSchema.parse(toApiClient(row))).not.toThrow(); + }); + + const speciesValues: PatientRow["species"][] = [ + "canine", "feline", "avian", "rabbit", "reptile", "equine", "other", + ]; + it.each(speciesValues)("toApiPatient (%s) -> ApiPatientSchema", (species) => { + expect(() => ApiPatientSchema.parse(toApiPatient(patientRow({ species })))).not.toThrow(); + }); + + const sexValues: PatientRow["sex"][] = [ + "male", "female", "male_neutered", "female_spayed", null, + ]; + it.each(sexValues.map((s) => [String(s), s] as const))( + "toApiPatient (sex=%s) -> ApiPatientSchema", + (_label, sex) => { + expect(() => ApiPatientSchema.parse(toApiPatient(patientRow({ sex })))).not.toThrow(); + } + ); + + it("toApiAppointment -> ApiAppointmentSchema", () => { + expect(() => ApiAppointmentSchema.parse(toApiAppointment(appointmentRow()))).not.toThrow(); + }); +}); + +describe("AppointmentCreateSchema validation", () => { + it("accepts a valid body", () => { + const r = AppointmentCreateSchema.safeParse({ + start_time: "2026-03-01T09:00:00.000Z", + end_time: "2026-03-01T09:30:00.000Z", + }); + expect(r.success).toBe(true); + }); + + it("rejects end_time before start_time", () => { + const r = AppointmentCreateSchema.safeParse({ + start_time: "2026-03-01T10:00:00.000Z", + end_time: "2026-03-01T09:30:00.000Z", + }); + expect(r.success).toBe(false); + }); + + it("rejects non-ISO timestamps", () => { + const r = AppointmentCreateSchema.safeParse({ + start_time: "March 1 2026", + end_time: "2026-03-01T09:30:00.000Z", + }); + expect(r.success).toBe(false); + }); + + it("rejects a non-uuid client_id", () => { + const r = AppointmentCreateSchema.safeParse({ + start_time: "2026-03-01T09:00:00.000Z", + end_time: "2026-03-01T09:30:00.000Z", + client_id: "not-a-uuid", + }); + expect(r.success).toBe(false); + }); +}); diff --git a/apps/web/lib/compat/openvpm/index.ts b/apps/web/lib/compat/openvpm/index.ts new file mode 100644 index 0000000..da630c7 --- /dev/null +++ b/apps/web/lib/compat/openvpm/index.ts @@ -0,0 +1,2 @@ +export * from "./schema"; +export * from "./mappers"; diff --git a/apps/web/lib/compat/openvpm/mappers.ts b/apps/web/lib/compat/openvpm/mappers.ts new file mode 100644 index 0000000..d4e95c2 --- /dev/null +++ b/apps/web/lib/compat/openvpm/mappers.ts @@ -0,0 +1,125 @@ +import type { clients, patients, appointments } from "@openpims/db"; +import type { ApiClient, ApiPatient, ApiAppointment, AppointmentCreate } from "./schema"; + +type ClientRow = typeof clients.$inferSelect; +type PatientRow = typeof patients.$inferSelect; +type AppointmentRow = typeof appointments.$inferSelect; + +/** + * Pure translation between internal Drizzle rows and the public v1 API shapes. + * No I/O, no db — trivially unit-testable. This is the only place that knows + * about the enum/field crosswalk between internal and external vocabularies. + */ + +// --- Enum crosswalks --------------------------------------------------------- + +const SPECIES_TO_API: Record = { + canine: "dog", + feline: "cat", + avian: "bird", + rabbit: "rabbit", + reptile: "reptile", + equine: "horse", + other: "other", +}; + +/** Internal sex enum collapses sex + neuter status; the API splits them. */ +function sexToApi(sex: PatientRow["sex"]): { + sex: ApiPatient["sex"]; + neutered: boolean | null; +} { + switch (sex) { + case "male": + return { sex: "male", neutered: false }; + case "female": + return { sex: "female", neutered: false }; + case "male_neutered": + return { sex: "male", neutered: true }; + case "female_spayed": + return { sex: "female", neutered: true }; + default: + return { sex: "unknown", neutered: null }; + } +} + +function iso(d: Date): string { + return d.toISOString(); +} + +// --- Outbound (internal row -> API shape) ------------------------------------ + +export function toApiClient(row: ClientRow): ApiClient { + return { + id: row.id, + first_name: row.firstName, + last_name: row.lastName, + email: row.email, + phone: row.phone, + address: row.address, + city: row.city, + state: row.state, + zip: row.zip, + preferred_contact_method: row.preferredContactMethod, + notes: row.notes, + created_at: iso(row.createdAt), + updated_at: iso(row.updatedAt), + }; +} + +export function toApiPatient(row: PatientRow): ApiPatient { + const { sex, neutered } = sexToApi(row.sex); + return { + id: row.id, + client_id: row.clientId, + name: row.name, + species: SPECIES_TO_API[row.species], + breed: row.breed, + sex, + neutered, + // `dob` is a Postgres `date` column — Drizzle returns it as a "YYYY-MM-DD" string. + date_of_birth: row.dob, + color: row.color, + microchip_number: row.microchipNumber, + status: row.status, + created_at: iso(row.createdAt), + updated_at: iso(row.updatedAt), + }; +} + +export function toApiAppointment(row: AppointmentRow): ApiAppointment { + return { + id: row.id, + start_time: iso(row.startTime), + end_time: iso(row.endTime), + status: row.status, + client_id: row.clientId, + patient_id: row.patientId, + doctor_id: row.doctorId, + type_id: row.typeId, + room_id: row.roomId, + notes: row.notes, + created_at: iso(row.createdAt), + updated_at: iso(row.updatedAt), + }; +} + +// --- Inbound (API body -> internal insert) ----------------------------------- + +/** + * Translate a validated create-appointment body into internal insert values. + * `practiceId` is added by the handler from the authenticated key, never the body. + */ +export function fromApiAppointmentCreate( + body: AppointmentCreate +): Omit { + return { + startTime: new Date(body.start_time), + endTime: new Date(body.end_time), + clientId: body.client_id ?? null, + patientId: body.patient_id ?? null, + doctorId: body.doctor_id ?? null, + typeId: body.type_id ?? null, + roomId: body.room_id ?? null, + notes: body.notes ?? null, + }; +} diff --git a/apps/web/lib/compat/openvpm/schema.ts b/apps/web/lib/compat/openvpm/schema.ts new file mode 100644 index 0000000..035b2ba --- /dev/null +++ b/apps/web/lib/compat/openvpm/schema.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +/** + * The PUBLIC, frozen contract for the OpenVPM v1 REST API. These shapes are + * owned by this file and are deliberately decoupled from the internal Drizzle + * schema — internal columns may change without breaking integrators, as long as + * the mappers (mappers.ts) keep producing these shapes. + */ + +export const ApiSpeciesSchema = z.enum([ + "dog", + "cat", + "bird", + "rabbit", + "reptile", + "horse", + "other", +]); + +export const ApiSexSchema = z.enum(["male", "female", "unknown"]); + +export const ApiClientSchema = z.object({ + id: z.string().uuid(), + first_name: z.string(), + last_name: z.string(), + email: z.string().nullable(), + phone: z.string().nullable(), + address: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + zip: z.string().nullable(), + preferred_contact_method: z.string().nullable(), + notes: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export const ApiPatientSchema = z.object({ + id: z.string().uuid(), + client_id: z.string().uuid(), + name: z.string(), + species: ApiSpeciesSchema, + breed: z.string().nullable(), + sex: ApiSexSchema, + neutered: z.boolean().nullable(), + date_of_birth: z.string().nullable(), + color: z.string().nullable(), + microchip_number: z.string().nullable(), + status: z.enum(["active", "inactive", "deceased"]), + created_at: z.string(), + updated_at: z.string(), +}); + +export const ApiAppointmentSchema = z.object({ + id: z.string().uuid(), + start_time: z.string(), + end_time: z.string(), + status: z.enum([ + "scheduled", + "confirmed", + "checked_in", + "in_exam", + "checked_out", + "no_show", + "cancelled", + ]), + client_id: z.string().uuid().nullable(), + patient_id: z.string().uuid().nullable(), + doctor_id: z.string().uuid().nullable(), + type_id: z.string().uuid().nullable(), + room_id: z.string().uuid().nullable(), + notes: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +/** Inbound body for POST /api/v1/appointments. */ +export const AppointmentCreateSchema = z + .object({ + start_time: z.string().datetime({ offset: true }), + end_time: z.string().datetime({ offset: true }), + client_id: z.string().uuid().optional(), + patient_id: z.string().uuid().optional(), + doctor_id: z.string().uuid().optional(), + type_id: z.string().uuid().optional(), + room_id: z.string().uuid().optional(), + notes: z.string().optional(), + }) + .refine((b) => new Date(b.end_time) > new Date(b.start_time), { + message: "end_time must be after start_time", + path: ["end_time"], + }); + +export type ApiClient = z.infer; +export type ApiPatient = z.infer; +export type ApiAppointment = z.infer; +export type AppointmentCreate = z.infer; diff --git a/apps/web/lib/compat/shared/errors.ts b/apps/web/lib/compat/shared/errors.ts new file mode 100644 index 0000000..8734f3b --- /dev/null +++ b/apps/web/lib/compat/shared/errors.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { ZodError } from "zod"; + +/** + * Consistent error envelope for the public REST API: `{ error: { message } }`. + * Matches the shape produced by lib/api-auth.ts so integrators see one format. + */ +export function apiError(message: string, status: number): NextResponse { + return NextResponse.json({ error: { message } }, { status }); +} + +export function notFound(resource = "Resource"): NextResponse { + return apiError(`${resource} not found`, 404); +} + +/** Turn a ZodError into a 400 with field-level detail. */ +export function validationError(err: ZodError): NextResponse { + return NextResponse.json( + { error: { message: "Validation failed", fields: err.flatten().fieldErrors } }, + { status: 400 } + ); +} + +/** Wrap a handler body so uncaught errors become a 500 instead of leaking. */ +export async function withErrorHandling( + fn: () => Promise +): Promise { + try { + return await fn(); + } catch (e) { + console.error("[api/v1] unhandled error:", e); + return apiError("Internal server error", 500); + } +} diff --git a/apps/web/lib/compat/shared/pagination.ts b/apps/web/lib/compat/shared/pagination.ts new file mode 100644 index 0000000..7e12689 --- /dev/null +++ b/apps/web/lib/compat/shared/pagination.ts @@ -0,0 +1,34 @@ +export interface Pagination { + limit: number; + offset: number; +} + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +/** + * Parse `limit`/`offset` query params with safe bounds. Invalid or out-of-range + * values fall back to defaults rather than erroring — lenient by design for a + * public REST surface. + */ +export function parsePagination(searchParams: URLSearchParams): Pagination { + const rawLimit = Number(searchParams.get("limit")); + const rawOffset = Number(searchParams.get("offset")); + + const limit = + Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), MAX_LIMIT) + : DEFAULT_LIMIT; + const offset = + Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; + + return { limit, offset }; +} + +/** Standard list envelope: `{ data, pagination }`. */ +export function paginated(data: T[], pagination: Pagination, total: number) { + return { + data, + pagination: { ...pagination, total }, + }; +} From 8a26df72353534e951a83a8425a4928dd0a39aa5 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 22:53:42 -0400 Subject: [PATCH 05/25] Document the v1 REST API and the compatibility-layer contributor flow Adds docs/api/README.md (auth, scopes, rate limits, error format, endpoints), a README section surfacing the API, and a CONTRIBUTING guide for adding compatibility endpoints/targets plus the unit + e2e testing workflow. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 35 +++++++++++- README.md | 11 ++++ docs/api/README.md | 132 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 docs/api/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8e9464..e51a07f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//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, "")` 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//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//schema.ts`. +- **A new target** (e.g. an existing PIMS's public API) is a new + `lib/compat//` module plus an `app/api/compat//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. diff --git a/README.md b/README.md index 038ffcb..704293a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..05dc644 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,132 @@ +# OpenVPM REST API (v1) + +A versioned, public REST API over OpenVPM's core records. It is intended for +third-party integrators (booking, reminders, client comms, AI agents) that read +and write practice data without using the internal tRPC client. + +> **Compatibility layer.** The `v1` surface is OpenVPM's own clean contract and +> doubles as the reference implementation for vendor-compatible APIs. Each +> response shape is owned by an explicit schema and frozen independently of the +> internal database — internal columns can change without breaking you. A +> vendor-specific "identical-twin" surface (so an existing integration can point +> at OpenVPM with zero changes) is added as a sibling namespace; see +> [Adding a compatibility target](../../CONTRIBUTING.md#adding-a-compatibility-endpoint-or-target). + +## Authentication + +Every request requires a scoped API key. A practice admin creates one from +**Settings → API Keys** (backed by the `apiKeys` tRPC router). The raw key is +shown **once** at creation — store it securely. + +Send it on every request as a bearer token (preferred) or `X-API-Key`: + +```bash +curl https://demo.openvpm.com/api/v1/clients \ + -H "Authorization: Bearer ovpm_xxxxxxxxxxxxxxxxxxxxxxxx" + +# equivalent +curl https://demo.openvpm.com/api/v1/clients \ + -H "X-API-Key: ovpm_xxxxxxxxxxxxxxxxxxxxxxxx" +``` + +Keys are stored as bcrypt hashes (never in plaintext) and are scoped to a single +practice — a key can only ever read or write its own practice's data. + +### Scopes + +| Scope | Grants | +|---|---| +| `clients:read` | List/read clients | +| `patients:read` | List/read patients | +| `appointments:read` | List/read appointments *(reserved for upcoming reads)* | +| `appointments:write` | Create appointments | +| `*` | All of the above | + +A request missing the required scope returns `403`. + +## Rate limits + +Each key is limited to **600 requests/minute**. Over the limit returns `429` +with `Retry-After` and `X-RateLimit-*` headers. + +> Note: the limiter is currently in-memory and per-instance, so on multi-instance +> deployments the effective limit is per instance. A shared (Redis) limiter is on +> the roadmap. + +## Error format + +All errors use a single envelope: + +```json +{ "error": { "message": "API key missing required scope: appointments:write" } } +``` + +Validation errors (`400`) include field detail: + +```json +{ "error": { "message": "Validation failed", "fields": { "end_time": ["end_time must be after start_time"] } } } +``` + +| Status | Meaning | +|---|---| +| `400` | Malformed JSON or failed validation | +| `401` | Missing or invalid API key | +| `403` | Key lacks the required scope | +| `404` | Resource not found (or not in your practice) | +| `429` | Rate limit exceeded | + +## Endpoints + +List responses use a `{ data, pagination }` envelope; single-resource responses +use `{ data }`. + +### `GET /api/v1/clients` +Scope `clients:read`. Query: `limit` (default 25, max 100), `offset`. + +```json +{ + "data": [ + { + "id": "…", "first_name": "Jane", "last_name": "Doe", + "email": "jane@example.com", "phone": "555-0100", + "address": "1 Main St", "city": "Tampa", "state": "FL", "zip": "33601", + "preferred_contact_method": "email", "notes": null, + "created_at": "2026-01-02T03:04:05.000Z", "updated_at": "2026-01-02T03:04:05.000Z" + } + ], + "pagination": { "limit": 25, "offset": 0, "total": 1 } +} +``` + +### `GET /api/v1/clients/:id` +Scope `clients:read`. Returns `{ data: }` or `404`. + +### `GET /api/v1/patients` +Scope `patients:read`. Query: `limit`, `offset`, optional `client_id`. +Species are normalized to integrator-friendly values (`dog`, `cat`, `bird`, +`rabbit`, `reptile`, `horse`, `other`) and the internal sex enum is split into +`sex` (`male`/`female`/`unknown`) + `neutered` (boolean | null). + +### `GET /api/v1/patients/:id` +Scope `patients:read`. Returns `{ data: }` or `404`. + +### `POST /api/v1/appointments` +Scope `appointments:write`. Body: + +```json +{ + "start_time": "2026-03-01T09:00:00.000Z", + "end_time": "2026-03-01T09:30:00.000Z", + "client_id": "…", + "patient_id": "…", + "doctor_id": "…", + "type_id": "…", + "room_id": "…", + "notes": "Annual exam" +} +``` + +`start_time`/`end_time` are required ISO-8601 timestamps (`end_time` must be +after `start_time`); all ids and `notes` are optional. Returns `201` with +`{ data: }` and fires the `appointment.created` webhook to any +subscribed endpoints (see the [Webhooks](../../README.md#webhooks) section). From 0aaa393903d60a6e77045d271a8a653c8d96646f Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 23:19:40 -0400 Subject: [PATCH 06/25] Add weight-based drug dosing calculator Closes a clinical-depth gap vs leading PIMS (ezyVet/Provet have dosing calculators; OpenVPM had none). Adds a pure calculator engine + a curated starter formulary (8 common drugs with species-specific reference ranges), exposed via a dosing tRPC router (formulary + calculate). - Weight-based mg range, optional injectable volume from concentration, practical tablet-split suggestions, and a hard max-single-dose cap. - Guard rails: rejects non-positive/implausible weight, unknown drugs, and refuses to extrapolate across species. Every result carries a verify-before- prescribing disclaimer and drug-level warnings (e.g. carprofen not for cats). - 14 unit tests covering math, capping, tablet logic, and guard rails. Co-Authored-By: Claude Opus 4.8 --- .../lib/dosing/__tests__/calculator.test.ts | 117 ++++++++++++++ apps/web/lib/dosing/calculator.ts | 143 ++++++++++++++++++ apps/web/lib/dosing/formulary.ts | 132 ++++++++++++++++ apps/web/lib/dosing/index.ts | 2 + apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/dosing.ts | 42 +++++ 6 files changed, 438 insertions(+) create mode 100644 apps/web/lib/dosing/__tests__/calculator.test.ts create mode 100644 apps/web/lib/dosing/calculator.ts create mode 100644 apps/web/lib/dosing/formulary.ts create mode 100644 apps/web/lib/dosing/index.ts create mode 100644 apps/web/server/routers/dosing.ts diff --git a/apps/web/lib/dosing/__tests__/calculator.test.ts b/apps/web/lib/dosing/__tests__/calculator.test.ts new file mode 100644 index 0000000..353a5ca --- /dev/null +++ b/apps/web/lib/dosing/__tests__/calculator.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { calculateDose } from "../calculator"; +import { FORMULARY, findDrug } from "../formulary"; + +describe("calculateDose — core math", () => { + it("computes a weight-based mg range", () => { + // carprofen canine 2.2–4.4 mg/kg, 10kg dog + const r = calculateDose({ drugId: "carprofen", species: "canine", weightKg: 10 }); + expect(r.doseLowMg).toBe(22); + expect(r.doseHighMg).toBe(44); + expect(r.route).toBe("PO"); + }); + + it("computes injectable volume when concentration is given", () => { + // maropitant canine 1–2 mg/kg, 5kg, 10 mg/mL + const r = calculateDose({ + drugId: "maropitant", + species: "canine", + weightKg: 5, + concentrationMgPerMl: 10, + }); + expect(r.doseLowMg).toBe(5); + expect(r.doseHighMg).toBe(10); + expect(r.volumeLowMl).toBe(0.5); + expect(r.volumeHighMl).toBe(1); + }); + + it("rounds to 2 decimal places", () => { + // gabapentin 5–10 mg/kg, 3.3kg -> 16.5 / 33 + const r = calculateDose({ drugId: "gabapentin", species: "feline", weightKg: 3.3 }); + expect(r.doseLowMg).toBe(16.5); + expect(r.doseHighMg).toBe(33); + }); +}); + +describe("calculateDose — max single dose cap", () => { + it("caps the dose and flags it", () => { + // feline meloxicam capped at 0.3 mg single dose; 10kg * 0.1 = 1.0 > 0.3 + const r = calculateDose({ drugId: "meloxicam", species: "feline", weightKg: 10 }); + expect(r.doseHighMg).toBe(0.3); + expect(r.cappedByMax).toBe(true); + expect(r.warnings.some((w) => w.includes("capped"))).toBe(true); + }); +}); + +describe("calculateDose — tablet suggestions", () => { + it("suggests practical tablet splits at the midpoint", () => { + // metronidazole 10–15 mg/kg, 20kg -> 200–300mg, mid 250. 250mg tab -> 1 tablet + const r = calculateDose({ drugId: "metronidazole", species: "canine", weightKg: 20 }); + const tab250 = r.tabletSuggestions?.find((t) => t.strengthMg === 250); + expect(tab250?.tabletsPerDose).toBe(1); + }); + + it("omits impractical tablet strengths (>4 or <0.25 tablets)", () => { + // famotidine 0.5–1 mg/kg, 5kg -> 2.5–5mg, mid 3.75. 40mg tab -> ~0.09 -> dropped + const r = calculateDose({ drugId: "famotidine", species: "canine", weightKg: 5 }); + expect(r.tabletSuggestions?.some((t) => t.strengthMg === 40)).toBe(false); + }); +}); + +describe("calculateDose — guard rails", () => { + it("rejects non-positive weight", () => { + expect(() => calculateDose({ drugId: "carprofen", species: "canine", weightKg: 0 })).toThrow( + /positive/ + ); + }); + + it("rejects implausibly large weight", () => { + expect(() => + calculateDose({ drugId: "carprofen", species: "canine", weightKg: 500 }) + ).toThrow(/plausible/); + }); + + it("rejects an unknown drug", () => { + expect(() => + calculateDose({ drugId: "nope", species: "canine", weightKg: 10 }) + ).toThrow(/Unknown drug/); + }); + + it("refuses to extrapolate across species (carprofen has no feline dose)", () => { + expect(() => + calculateDose({ drugId: "carprofen", species: "feline", weightKg: 4 }) + ).toThrow(/No reference dose/); + }); + + it("rejects an invalid concentration", () => { + expect(() => + calculateDose({ + drugId: "maropitant", + species: "canine", + weightKg: 5, + concentrationMgPerMl: 0, + }) + ).toThrow(/Concentration/); + }); + + it("surfaces drug-level warnings (carprofen not for cats note lives on the drug)", () => { + const r = calculateDose({ drugId: "carprofen", species: "canine", weightKg: 10 }); + expect(r.warnings.length).toBeGreaterThan(0); + expect(r.disclaimer).toMatch(/Verify every dose/); + }); +}); + +describe("formulary integrity", () => { + it("every dose has low <= high and a positive range", () => { + for (const drug of FORMULARY) { + for (const d of drug.doses) { + expect(d.mgPerKgLow).toBeGreaterThan(0); + expect(d.mgPerKgHigh).toBeGreaterThanOrEqual(d.mgPerKgLow); + } + } + }); + + it("findDrug returns undefined for unknown ids", () => { + expect(findDrug("does-not-exist")).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/dosing/calculator.ts b/apps/web/lib/dosing/calculator.ts new file mode 100644 index 0000000..1711738 --- /dev/null +++ b/apps/web/lib/dosing/calculator.ts @@ -0,0 +1,143 @@ +import { + findDrug, + DOSING_DISCLAIMER, + type DosingSpecies, + type FormularyDrug, + type SpeciesDose, +} from "./formulary"; + +export interface DoseInput { + drugId: string; + species: DosingSpecies; + weightKg: number; + /** Optional concentration (mg/mL) to compute a liquid/injectable volume. */ + concentrationMgPerMl?: number; +} + +export interface TabletSuggestion { + strengthMg: number; + /** Tablets per dose at the midpoint of the range, rounded to 1/4 tablet. */ + tabletsPerDose: number; +} + +export interface DoseResult { + drug: { id: string; name: string; category: string }; + species: DosingSpecies; + weightKg: number; + route: string; + frequency: string; + mgPerKgLow: number; + mgPerKgHigh: number; + doseLowMg: number; + doseHighMg: number; + cappedByMax: boolean; + volumeLowMl?: number; + volumeHighMl?: number; + tabletSuggestions?: TabletSuggestion[]; + warnings: string[]; + disclaimer: string; +} + +function round(value: number, dp: number): number { + const f = 10 ** dp; + return Math.round(value * f) / f; +} + +/** Nearest quarter, so tablet splitting stays practical (½, ¼ tablet). */ +function roundToQuarter(value: number): number { + return Math.round(value * 4) / 4; +} + +function doseForSpecies( + drug: FormularyDrug, + species: DosingSpecies +): SpeciesDose | undefined { + return drug.doses.find((d) => d.species === species); +} + +/** + * Calculate a weight-based dose range for a formulary drug. Pure function — + * no I/O. Throws on invalid weight; returns a `notFound`/`unsupported` style + * error via thrown Error the caller can surface as a 4xx. + */ +export function calculateDose(input: DoseInput): DoseResult { + if (!Number.isFinite(input.weightKg) || input.weightKg <= 0) { + throw new Error("Weight must be a positive number of kilograms."); + } + if (input.weightKg > 200) { + throw new Error("Weight exceeds a plausible range (>200kg). Check units."); + } + + const drug = findDrug(input.drugId); + if (!drug) { + throw new Error(`Unknown drug: ${input.drugId}`); + } + + const dose = doseForSpecies(drug, input.species); + if (!dose) { + throw new Error( + `No reference dose for ${drug.name} in ${input.species}. Do not extrapolate across species.` + ); + } + + let doseLowMg = input.weightKg * dose.mgPerKgLow; + let doseHighMg = input.weightKg * dose.mgPerKgHigh; + + let cappedByMax = false; + if (dose.maxSingleDoseMg !== undefined) { + if (doseHighMg > dose.maxSingleDoseMg) { + doseHighMg = dose.maxSingleDoseMg; + cappedByMax = true; + } + if (doseLowMg > dose.maxSingleDoseMg) { + doseLowMg = dose.maxSingleDoseMg; + cappedByMax = true; + } + } + + doseLowMg = round(doseLowMg, 2); + doseHighMg = round(doseHighMg, 2); + + const warnings = [...(drug.warnings ?? [])]; + if (dose.notes) warnings.push(dose.notes); + if (cappedByMax) { + warnings.push(`Dose capped at the maximum single dose of ${dose.maxSingleDoseMg} mg.`); + } + + const result: DoseResult = { + drug: { id: drug.id, name: drug.name, category: drug.category }, + species: input.species, + weightKg: input.weightKg, + route: dose.route, + frequency: dose.frequency, + mgPerKgLow: dose.mgPerKgLow, + mgPerKgHigh: dose.mgPerKgHigh, + doseLowMg, + doseHighMg, + cappedByMax, + warnings, + disclaimer: DOSING_DISCLAIMER, + }; + + const concentration = input.concentrationMgPerMl; + if (concentration !== undefined) { + if (!Number.isFinite(concentration) || concentration <= 0) { + throw new Error("Concentration must be a positive number (mg/mL)."); + } + result.volumeLowMl = round(doseLowMg / concentration, 2); + result.volumeHighMl = round(doseHighMg / concentration, 2); + } + + if (drug.tabletStrengthsMg?.length) { + const midpoint = (doseLowMg + doseHighMg) / 2; + result.tabletSuggestions = drug.tabletStrengthsMg + .map((strengthMg) => ({ + strengthMg, + tabletsPerDose: roundToQuarter(midpoint / strengthMg), + })) + // Only suggest practical splits (¼ to 4 tablets). + .filter((s) => s.tabletsPerDose >= 0.25 && s.tabletsPerDose <= 4); + } + + return result; +} diff --git a/apps/web/lib/dosing/formulary.ts b/apps/web/lib/dosing/formulary.ts new file mode 100644 index 0000000..4df07a5 --- /dev/null +++ b/apps/web/lib/dosing/formulary.ts @@ -0,0 +1,132 @@ +/** + * Starter veterinary formulary for the dosing calculator. + * + * IMPORTANT: These are common published reference ranges intended to assist a + * licensed clinician, NOT to replace clinical judgment or a current drug + * reference (e.g. Plumb's). Every calculated dose must be verified by the + * prescriber before use. Ranges are conservative and species-specific; some + * drugs are contraindicated in a species and are flagged accordingly. + */ + +export type DosingSpecies = "canine" | "feline"; + +export interface SpeciesDose { + species: DosingSpecies; + mgPerKgLow: number; + mgPerKgHigh: number; + /** Administration route, e.g. PO, SC, IV, IM. */ + route: string; + /** Dosing interval, e.g. q24h, q12h, q8h. */ + frequency: string; + /** Optional hard cap on a single dose, regardless of weight. */ + maxSingleDoseMg?: number; + notes?: string; +} + +export interface FormularyDrug { + id: string; + name: string; + category: string; + /** Common injectable / oral-liquid concentrations in mg/mL. */ + concentrationsMgPerMl?: number[]; + /** Common tablet / capsule strengths in mg. */ + tabletStrengthsMg?: number[]; + doses: SpeciesDose[]; + /** Species-level or drug-level cautions surfaced with every result. */ + warnings?: string[]; +} + +export const DOSING_DISCLAIMER = + "Reference ranges only. Verify every dose against a current veterinary drug " + + "reference and the patient's full clinical picture before prescribing."; + +export const FORMULARY: FormularyDrug[] = [ + { + id: "maropitant", + name: "Maropitant (Cerenia)", + category: "Antiemetic", + concentrationsMgPerMl: [10], + tabletStrengthsMg: [16, 24, 60, 160], + doses: [ + { species: "canine", mgPerKgLow: 1, mgPerKgHigh: 2, route: "PO", frequency: "q24h", notes: "1 mg/kg SC or 2 mg/kg PO" }, + { species: "feline", mgPerKgLow: 1, mgPerKgHigh: 1, route: "SC", frequency: "q24h" }, + ], + }, + { + id: "carprofen", + name: "Carprofen (Rimadyl)", + category: "NSAID", + tabletStrengthsMg: [25, 75, 100], + doses: [ + { species: "canine", mgPerKgLow: 2.2, mgPerKgHigh: 4.4, route: "PO", frequency: "q12-24h", notes: "4.4 mg/kg/day total, split q12h or once daily" }, + ], + warnings: ["Not recommended in cats. Use with caution in patients with renal, hepatic, or GI disease."], + }, + { + id: "meloxicam", + name: "Meloxicam (Metacam)", + category: "NSAID", + concentrationsMgPerMl: [1.5, 5], + doses: [ + { species: "canine", mgPerKgLow: 0.1, mgPerKgHigh: 0.2, route: "PO", frequency: "q24h", notes: "0.2 mg/kg loading dose day 1, then 0.1 mg/kg" }, + { species: "feline", mgPerKgLow: 0.05, mgPerKgHigh: 0.1, route: "PO", frequency: "q24h", maxSingleDoseMg: 0.3, notes: "Single-dose use; repeated dosing in cats is high-risk" }, + ], + warnings: ["Repeated NSAID use in cats carries significant renal risk. Confirm hydration and renal values."], + }, + { + id: "gabapentin", + name: "Gabapentin", + category: "Analgesic / Anxiolytic", + concentrationsMgPerMl: [50], + tabletStrengthsMg: [100, 300, 400], + doses: [ + { species: "canine", mgPerKgLow: 5, mgPerKgHigh: 10, route: "PO", frequency: "q8-12h" }, + { species: "feline", mgPerKgLow: 5, mgPerKgHigh: 10, route: "PO", frequency: "q12h", notes: "Higher single doses (e.g. 50-100 mg/cat) used situationally for transport anxiety" }, + ], + }, + { + id: "metronidazole", + name: "Metronidazole", + category: "Antibiotic / Antiprotozoal", + tabletStrengthsMg: [250, 500], + doses: [ + { species: "canine", mgPerKgLow: 10, mgPerKgHigh: 15, route: "PO", frequency: "q12h" }, + { species: "feline", mgPerKgLow: 10, mgPerKgHigh: 15, route: "PO", frequency: "q12h" }, + ], + warnings: ["Cumulative high doses risk neurotoxicity. Avoid prolonged high-dose courses."], + }, + { + id: "amoxicillin-clavulanate", + name: "Amoxicillin / Clavulanate (Clavamox)", + category: "Antibiotic", + tabletStrengthsMg: [62.5, 125, 250, 375], + doses: [ + { species: "canine", mgPerKgLow: 12.5, mgPerKgHigh: 25, route: "PO", frequency: "q12h" }, + { species: "feline", mgPerKgLow: 12.5, mgPerKgHigh: 25, route: "PO", frequency: "q12h" }, + ], + }, + { + id: "famotidine", + name: "Famotidine", + category: "H2 antagonist", + tabletStrengthsMg: [10, 20, 40], + doses: [ + { species: "canine", mgPerKgLow: 0.5, mgPerKgHigh: 1, route: "PO", frequency: "q12-24h" }, + { species: "feline", mgPerKgLow: 0.5, mgPerKgHigh: 1, route: "PO", frequency: "q12-24h" }, + ], + }, + { + id: "oclacitinib", + name: "Oclacitinib (Apoquel)", + category: "Antipruritic", + tabletStrengthsMg: [3.6, 5.4, 16], + doses: [ + { species: "canine", mgPerKgLow: 0.4, mgPerKgHigh: 0.6, route: "PO", frequency: "q12h", notes: "q12h for up to 14 days, then q24h maintenance" }, + ], + warnings: ["Labeled for dogs ≥12 months. Not for cats."], + }, +]; + +export function findDrug(drugId: string): FormularyDrug | undefined { + return FORMULARY.find((d) => d.id === drugId); +} diff --git a/apps/web/lib/dosing/index.ts b/apps/web/lib/dosing/index.ts new file mode 100644 index 0000000..af721b5 --- /dev/null +++ b/apps/web/lib/dosing/index.ts @@ -0,0 +1,2 @@ +export * from "./formulary"; +export * from "./calculator"; diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index aaefabc..557e2e7 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -20,6 +20,7 @@ import { templatesRouter } from "./templates"; import { controlledSubstancesRouter } from "./controlled-substances"; import { insuranceRouter } from "./insurance"; import { apiKeysRouter } from "./api-keys"; +import { dosingRouter } from "./dosing"; export const appRouter = createRouter({ auth: authRouter, @@ -43,6 +44,7 @@ export const appRouter = createRouter({ controlledSubstances: controlledSubstancesRouter, insurance: insuranceRouter, apiKeys: apiKeysRouter, + dosing: dosingRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/dosing.ts b/apps/web/server/routers/dosing.ts new file mode 100644 index 0000000..0058546 --- /dev/null +++ b/apps/web/server/routers/dosing.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure } from "../trpc"; +import { FORMULARY, DOSING_DISCLAIMER, calculateDose } from "@/lib/dosing"; + +export const dosingRouter = createRouter({ + /** List the formulary for a drug picker. */ + formulary: protectedProcedure.query(() => { + return { + disclaimer: DOSING_DISCLAIMER, + drugs: FORMULARY.map((d) => ({ + id: d.id, + name: d.name, + category: d.category, + species: d.doses.map((dose) => dose.species), + concentrationsMgPerMl: d.concentrationsMgPerMl ?? [], + tabletStrengthsMg: d.tabletStrengthsMg ?? [], + })), + }; + }), + + /** Calculate a weight-based dose range. */ + calculate: protectedProcedure + .input( + z.object({ + drugId: z.string().min(1), + species: z.enum(["canine", "feline"]), + weightKg: z.number().positive(), + concentrationMgPerMl: z.number().positive().optional(), + }) + ) + .query(({ input }) => { + try { + return calculateDose(input); + } catch (e) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: e instanceof Error ? e.message : "Unable to calculate dose", + }); + } + }), +}); From ea5356a1bfa1e3d16568281175535eb2770d5831 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 23:20:47 -0400 Subject: [PATCH 07/25] Add vital signs tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clinical-depth gap: OpenVPM tracked weight only. Leading PIMS (Cornerstone, Instinct) capture full vitals per visit. Adds a vital_signs table (temp, HR, RR, weight, body condition score, pain score, mucous membrane, CRT, notes) with patient/appointment/recorder links, plus a vitals router (listByPatient + record) gated to clinical roles with sane physiologic input bounds. Note: additive schema change — run pnpm db:push on deploy. Co-Authored-By: Claude Opus 4.8 --- apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/vitals.ts | 65 +++++++++++++++++++++++++++++++ packages/db/schema/clinical.ts | 52 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 apps/web/server/routers/vitals.ts diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index 557e2e7..800c906 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -21,6 +21,7 @@ import { controlledSubstancesRouter } from "./controlled-substances"; import { insuranceRouter } from "./insurance"; import { apiKeysRouter } from "./api-keys"; import { dosingRouter } from "./dosing"; +import { vitalsRouter } from "./vitals"; export const appRouter = createRouter({ auth: authRouter, @@ -45,6 +46,7 @@ export const appRouter = createRouter({ insurance: insuranceRouter, apiKeys: apiKeysRouter, dosing: dosingRouter, + vitals: vitalsRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/vitals.ts b/apps/web/server/routers/vitals.ts new file mode 100644 index 0000000..85d7676 --- /dev/null +++ b/apps/web/server/routers/vitals.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { eq, and, isNull, desc } from "drizzle-orm"; +import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { vitalSigns } from "@openpims/db"; + +const recordRole = requireRole("admin", "veterinarian", "technician"); + +export const vitalsRouter = createRouter({ + /** Vital-sign history for a patient, newest first. */ + listByPatient: protectedProcedure + .input( + z.object({ + patientId: z.string().uuid(), + limit: z.number().min(1).max(100).default(50), + }) + ) + .query(async ({ ctx, input }) => { + return ctx.db + .select() + .from(vitalSigns) + .where( + and( + eq(vitalSigns.patientId, input.patientId), + eq(vitalSigns.practiceId, ctx.practiceId), + isNull(vitalSigns.deletedAt) + ) + ) + .orderBy(desc(vitalSigns.recordedAt)) + .limit(input.limit); + }), + + record: protectedProcedure + .use(recordRole) + .input( + z.object({ + patientId: z.string().uuid(), + appointmentId: z.string().uuid().optional(), + // Numerics travel as strings into Drizzle numeric columns. + temperatureC: z.number().min(20).max(45).optional(), + heartRateBpm: z.number().int().min(0).max(400).optional(), + respiratoryRateBpm: z.number().int().min(0).max(300).optional(), + weightKg: z.number().positive().max(200).optional(), + bodyConditionScore: z.number().int().min(1).max(9).optional(), + painScore: z.number().int().min(0).max(10).optional(), + mucousMembrane: z.string().max(64).optional(), + capillaryRefillSec: z.number().min(0).max(10).optional(), + notes: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { temperatureC, weightKg, capillaryRefillSec, ...rest } = input; + const [row] = await ctx.db + .insert(vitalSigns) + .values({ + ...rest, + practiceId: ctx.practiceId, + recordedBy: ctx.user.id, + temperatureC: temperatureC?.toString(), + weightKg: weightKg?.toString(), + capillaryRefillSec: capillaryRefillSec?.toString(), + }) + .returning(); + return row!; + }), +}); diff --git a/packages/db/schema/clinical.ts b/packages/db/schema/clinical.ts index 5b42bd8..0b7490a 100644 --- a/packages/db/schema/clinical.ts +++ b/packages/db/schema/clinical.ts @@ -152,6 +152,39 @@ export const problemList = pgTable("problem_list", { resolvedDate: date("resolved_date"), }); +export const vitalSigns = pgTable( + "vital_signs", + { + ...baseColumns(), + practiceId: uuid("practice_id") + .notNull() + .references(() => practices.id), + patientId: uuid("patient_id") + .notNull() + .references(() => patients.id), + appointmentId: uuid("appointment_id").references(() => appointments.id), + recordedBy: uuid("recorded_by").references(() => users.id), + recordedAt: timestamp("recorded_at", { withTimezone: true }) + .notNull() + .defaultNow(), + temperatureC: numeric("temperature_c", { precision: 4, scale: 1 }), + heartRateBpm: integer("heart_rate_bpm"), + respiratoryRateBpm: integer("respiratory_rate_bpm"), + weightKg: numeric("weight_kg", { precision: 8, scale: 3 }), + /** Body condition score, 1-9 scale. */ + bodyConditionScore: integer("body_condition_score"), + /** Pain score, 0-10 scale. */ + painScore: integer("pain_score"), + mucousMembrane: varchar("mucous_membrane", { length: 64 }), + capillaryRefillSec: numeric("capillary_refill_sec", { precision: 3, scale: 1 }), + notes: text("notes"), + }, + (table) => ({ + patientIdx: index("vital_signs_patient_idx").on(table.patientId, table.recordedAt), + practiceIdx: index("vital_signs_practice_idx").on(table.practiceId, table.deletedAt), + }) +); + export const cases = pgTable("cases", { ...baseColumns(), practiceId: uuid("practice_id") @@ -287,6 +320,25 @@ export const problemListRelations = relations(problemList, ({ one }) => ({ }), })); +export const vitalSignsRelations = relations(vitalSigns, ({ one }) => ({ + practice: one(practices, { + fields: [vitalSigns.practiceId], + references: [practices.id], + }), + patient: one(patients, { + fields: [vitalSigns.patientId], + references: [patients.id], + }), + appointment: one(appointments, { + fields: [vitalSigns.appointmentId], + references: [appointments.id], + }), + recorder: one(users, { + fields: [vitalSigns.recordedBy], + references: [users.id], + }), +})); + export const casesRelations = relations(cases, ({ one, many }) => ({ practice: one(practices, { fields: [cases.practiceId], From f489cf8dc84f3f6a5745987693de8036fc018213 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 23:24:11 -0400 Subject: [PATCH 08/25] Add OpenVPM Agent: typed tool layer + Claude tool-use runner The foundation for an agent-operated practice. An AI agent can now read and act on practice data through a typed tool registry, always scoped to one practice: - lib/agent/tools.ts: 6 tools (find_client, get_patient_summary, list_appointments, book_appointment, list_overdue_vaccinations, calculate_drug_dose) each with a JSON schema for the model + a Zod schema for runtime validation. Write tools are flagged. - lib/agent/runner.ts: Claude tool-use loop (Anthropic SDK) with prompt caching on the system prompt + tool defs, write-gating (allowWrites, default false), max-iteration guard, and graceful degradation when ANTHROPIC_API_KEY is absent. - agent tRPC router (status + run), gated to admin/vet. - 8 tests: registry integrity, read/write flagging, the pure dosing tool, and validation guards. Co-Authored-By: Claude Opus 4.8 --- apps/web/lib/agent/__tests__/tools.test.ts | 70 ++++ apps/web/lib/agent/index.ts | 2 + apps/web/lib/agent/runner.ts | 146 ++++++++ apps/web/lib/agent/tools.ts | 367 +++++++++++++++++++++ apps/web/package.json | 1 + apps/web/server/routers/_app.ts | 2 + apps/web/server/routers/agent.ts | 52 +++ pnpm-lock.yaml | 32 ++ 8 files changed, 672 insertions(+) create mode 100644 apps/web/lib/agent/__tests__/tools.test.ts create mode 100644 apps/web/lib/agent/index.ts create mode 100644 apps/web/lib/agent/runner.ts create mode 100644 apps/web/lib/agent/tools.ts create mode 100644 apps/web/server/routers/agent.ts diff --git a/apps/web/lib/agent/__tests__/tools.test.ts b/apps/web/lib/agent/__tests__/tools.test.ts new file mode 100644 index 0000000..bba8a7c --- /dev/null +++ b/apps/web/lib/agent/__tests__/tools.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { AGENT_TOOLS, getTool, anthropicToolDefs } from "../tools"; +import type { AgentToolContext } from "../tools"; + +// The DB is never touched by these tests — only pure tools (calculate_drug_dose) +// are executed; DB-backed tools are exercised at the validation layer. +const fakeCtx = { practiceId: "p", userId: "u" } as unknown as AgentToolContext; + +describe("agent tool registry", () => { + it("has unique tool names", () => { + const names = AGENT_TOOLS.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("flags exactly the write tools as not read-only", () => { + const writeTools = AGENT_TOOLS.filter((t) => !t.readOnly).map((t) => t.name); + expect(writeTools).toEqual(["book_appointment"]); + }); + + it("exposes Anthropic-format defs with required fields", () => { + for (const def of anthropicToolDefs()) { + expect(typeof def.name).toBe("string"); + expect(typeof def.description).toBe("string"); + expect(def.input_schema).toMatchObject({ type: "object" }); + } + }); + + it("getTool resolves by name and returns undefined otherwise", () => { + expect(getTool("find_client")?.name).toBe("find_client"); + expect(getTool("nope")).toBeUndefined(); + }); +}); + +describe("calculate_drug_dose tool (pure)", () => { + const tool = getTool("calculate_drug_dose")!; + + it("returns a dose range for valid input", async () => { + const result = (await tool.execute( + { drugId: "carprofen", species: "canine", weightKg: 10 }, + fakeCtx + )) as { doseLowMg: number; doseHighMg: number }; + expect(result.doseLowMg).toBe(22); + expect(result.doseHighMg).toBe(44); + }); + + it("rejects invalid args via its zod schema", () => { + const parsed = tool.zod.safeParse({ drugId: "carprofen", species: "canine", weightKg: -1 }); + expect(parsed.success).toBe(false); + }); +}); + +describe("book_appointment validation", () => { + const tool = getTool("book_appointment")!; + + it("rejects end before start", () => { + const parsed = tool.zod.safeParse({ + startTime: "2026-03-01T10:00:00.000Z", + endTime: "2026-03-01T09:00:00.000Z", + }); + expect(parsed.success).toBe(false); + }); + + it("accepts a valid window", () => { + const parsed = tool.zod.safeParse({ + startTime: "2026-03-01T09:00:00.000Z", + endTime: "2026-03-01T09:30:00.000Z", + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/apps/web/lib/agent/index.ts b/apps/web/lib/agent/index.ts new file mode 100644 index 0000000..7b97739 --- /dev/null +++ b/apps/web/lib/agent/index.ts @@ -0,0 +1,2 @@ +export * from "./tools"; +export * from "./runner"; diff --git a/apps/web/lib/agent/runner.ts b/apps/web/lib/agent/runner.ts new file mode 100644 index 0000000..a2fc8c7 --- /dev/null +++ b/apps/web/lib/agent/runner.ts @@ -0,0 +1,146 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { + AGENT_TOOLS, + anthropicToolDefs, + getTool, + type AgentToolContext, +} from "./tools"; + +const DEFAULT_MODEL = process.env.AGENT_MODEL || "claude-sonnet-4-6"; +const MAX_ITERATIONS = 8; + +const SYSTEM_PROMPT = `You are the OpenVPM Agent, an operations assistant embedded in an open-source veterinary practice management system. + +You help practice staff by using the provided tools to read and act on practice data. Guidelines: +- Always use tools to ground answers in real data. Never invent client names, patient records, appointment times, or doses. +- You operate on a single practice's data; you cannot see other practices. +- For any drug dose, use calculate_drug_dose and present it as a reference range that the prescribing clinician must verify. Never present a dose as a final prescribing decision. +- Before booking an appointment, confirm you have the right client and patient (use find_client / get_patient_summary first when ids are not given). +- Be concise and clinical. Surface warnings the tools return.`; + +export interface AgentToolCall { + name: string; + input: unknown; + result?: unknown; + error?: string; +} + +export interface AgentRunResult { + text: string; + toolCalls: AgentToolCall[]; + iterations: number; + stopReason: string | null; +} + +export class AgentNotConfiguredError extends Error { + constructor() { + super( + "OpenVPM Agent is not configured. Set ANTHROPIC_API_KEY to enable agent runs." + ); + this.name = "AgentNotConfiguredError"; + } +} + +export function isAgentConfigured(): boolean { + return Boolean(process.env.ANTHROPIC_API_KEY); +} + +/** + * Run the OpenVPM Agent against a natural-language instruction. Executes a + * tool-use loop with Claude, scoped to the caller's practice. Write tools are + * gated behind `allowWrites` (default false) so a read-only run can never + * mutate data. + */ +export async function runAgent(opts: { + instruction: string; + context: AgentToolContext; + allowWrites?: boolean; + model?: string; +}): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new AgentNotConfiguredError(); + + const client = new Anthropic({ apiKey }); + const allowWrites = opts.allowWrites ?? false; + + // Prompt caching: cache the (static) system prompt and tool defs across the + // loop's turns so only the growing message tail is re-billed at full rate. + const system: Anthropic.TextBlockParam[] = [ + { type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }, + ]; + const toolDefs = anthropicToolDefs() as Anthropic.Tool[]; + if (toolDefs.length > 0) { + toolDefs[toolDefs.length - 1]!.cache_control = { type: "ephemeral" }; + } + + const messages: Anthropic.MessageParam[] = [ + { role: "user", content: opts.instruction }, + ]; + + const toolCalls: AgentToolCall[] = []; + let stopReason: string | null = null; + + for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) { + const response = await client.messages.create({ + model: opts.model || DEFAULT_MODEL, + max_tokens: 1024, + system, + tools: toolDefs, + messages, + }); + stopReason = response.stop_reason; + + messages.push({ role: "assistant", content: response.content }); + + if (response.stop_reason !== "tool_use") { + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("\n") + .trim(); + return { text, toolCalls, iterations: iteration, stopReason }; + } + + const toolUses = response.content.filter( + (b): b is Anthropic.ToolUseBlock => b.type === "tool_use" + ); + const toolResults: Anthropic.ToolResultBlockParam[] = []; + + for (const use of toolUses) { + const tool = getTool(use.name); + const call: AgentToolCall = { name: use.name, input: use.input }; + + if (!tool) { + call.error = `Unknown tool: ${use.name}`; + } else if (!tool.readOnly && !allowWrites) { + call.error = "Write tools are disabled for this run."; + } else { + try { + call.result = await tool.execute(use.input, opts.context); + } catch (e) { + call.error = e instanceof Error ? e.message : "Tool execution failed"; + } + } + + toolCalls.push(call); + toolResults.push({ + type: "tool_result", + tool_use_id: use.id, + content: JSON.stringify(call.error ? { error: call.error } : call.result), + is_error: Boolean(call.error), + }); + } + + messages.push({ role: "user", content: toolResults }); + } + + return { + text: "Agent reached the maximum number of steps without finishing.", + toolCalls, + iterations: MAX_ITERATIONS, + stopReason, + }; +} + +/** Names of tools the agent can use, for surfacing in the UI/docs. */ +export const AGENT_TOOL_NAMES = AGENT_TOOLS.map((t) => t.name); diff --git a/apps/web/lib/agent/tools.ts b/apps/web/lib/agent/tools.ts new file mode 100644 index 0000000..d570ef5 --- /dev/null +++ b/apps/web/lib/agent/tools.ts @@ -0,0 +1,367 @@ +import { z } from "zod"; +import { eq, and, isNull, or, ilike, gte, lte, lt, desc } from "drizzle-orm"; +import type { Database } from "@openpims/db/client"; +import { + clients, + patients, + appointments, + vitalSigns, + vaccinationRecords, + problemList, +} from "@openpims/db"; +import { dispatchWebhookEvent } from "@/lib/webhook-dispatcher"; +import { calculateDose } from "@/lib/dosing"; + +/** + * The agent's "hands": typed tools that operate the practice's data, always + * scoped to a single practiceId. Each tool carries a JSON schema (for the + * model) and a Zod schema (for runtime validation). Read tools are safe to + * auto-run; write tools are flagged so the runner can gate them. + */ +export interface AgentToolContext { + db: Database; + practiceId: string; + userId: string; +} + +export interface AgentTool { + name: string; + description: string; + /** JSON Schema sent to the model as the tool's input_schema. */ + inputSchema: Record; + /** Runtime validation of the model-supplied args. */ + zod: z.ZodTypeAny; + readOnly: boolean; + execute(args: unknown, ctx: AgentToolContext): Promise; +} + +function clientName(first?: string | null, last?: string | null): string { + return [first, last].filter(Boolean).join(" "); +} + +const findClient: AgentTool = { + name: "find_client", + description: + "Search clients (pet owners) by name, email, or phone. Returns up to 10 matches with their ids.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "Name, email, or phone fragment" } }, + required: ["query"], + }, + zod: z.object({ query: z.string().min(1) }), + readOnly: true, + async execute(args, ctx) { + const { query } = this.zod.parse(args) as { query: string }; + const rows = await ctx.db + .select({ + id: clients.id, + firstName: clients.firstName, + lastName: clients.lastName, + email: clients.email, + phone: clients.phone, + }) + .from(clients) + .where( + and( + eq(clients.practiceId, ctx.practiceId), + isNull(clients.deletedAt), + or( + ilike(clients.firstName, `%${query}%`), + ilike(clients.lastName, `%${query}%`), + ilike(clients.email, `%${query}%`), + ilike(clients.phone, `%${query}%`) + ) + ) + ) + .limit(10); + return rows; + }, +}; + +const getPatientSummary: AgentTool = { + name: "get_patient_summary", + description: + "Get a clinical summary for a patient: signalment, latest vitals, vaccinations, and active problems.", + inputSchema: { + type: "object", + properties: { patientId: { type: "string", description: "Patient UUID" } }, + required: ["patientId"], + }, + zod: z.object({ patientId: z.string().uuid() }), + readOnly: true, + async execute(args, ctx) { + const { patientId } = this.zod.parse(args) as { patientId: string }; + const scope = and(eq(patients.practiceId, ctx.practiceId), isNull(patients.deletedAt)); + + const [patient] = await ctx.db + .select() + .from(patients) + .where(and(eq(patients.id, patientId), scope)) + .limit(1); + if (!patient) return { error: "Patient not found" }; + + const [latestVitals, vaccinations, problems] = await Promise.all([ + ctx.db + .select() + .from(vitalSigns) + .where( + and( + eq(vitalSigns.patientId, patientId), + eq(vitalSigns.practiceId, ctx.practiceId), + isNull(vitalSigns.deletedAt) + ) + ) + .orderBy(desc(vitalSigns.recordedAt)) + .limit(1), + ctx.db + .select({ + vaccineName: vaccinationRecords.vaccineName, + administeredAt: vaccinationRecords.administeredAt, + nextDueDate: vaccinationRecords.nextDueDate, + }) + .from(vaccinationRecords) + .where( + and( + eq(vaccinationRecords.patientId, patientId), + eq(vaccinationRecords.practiceId, ctx.practiceId), + isNull(vaccinationRecords.deletedAt) + ) + ), + ctx.db + .select({ description: problemList.description, status: problemList.status }) + .from(problemList) + .where( + and( + eq(problemList.patientId, patientId), + eq(problemList.practiceId, ctx.practiceId), + isNull(problemList.deletedAt), + eq(problemList.status, "active") + ) + ), + ]); + + return { + patient: { + id: patient.id, + name: patient.name, + species: patient.species, + breed: patient.breed, + sex: patient.sex, + dob: patient.dob, + status: patient.status, + }, + latestVitals: latestVitals[0] ?? null, + vaccinations, + activeProblems: problems, + }; + }, +}; + +const listAppointments: AgentTool = { + name: "list_appointments", + description: "List appointments within a date range (inclusive). Dates are ISO-8601.", + inputSchema: { + type: "object", + properties: { + startDate: { type: "string", description: "ISO start datetime" }, + endDate: { type: "string", description: "ISO end datetime" }, + }, + required: ["startDate", "endDate"], + }, + zod: z.object({ + startDate: z.string().datetime({ offset: true }), + endDate: z.string().datetime({ offset: true }), + }), + readOnly: true, + async execute(args, ctx) { + const { startDate, endDate } = this.zod.parse(args) as { + startDate: string; + endDate: string; + }; + const rows = await ctx.db + .select({ + id: appointments.id, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + patientName: patients.name, + clientFirst: clients.firstName, + clientLast: clients.lastName, + }) + .from(appointments) + .leftJoin(patients, eq(appointments.patientId, patients.id)) + .leftJoin(clients, eq(appointments.clientId, clients.id)) + .where( + and( + eq(appointments.practiceId, ctx.practiceId), + isNull(appointments.deletedAt), + gte(appointments.startTime, new Date(startDate)), + lte(appointments.startTime, new Date(endDate)) + ) + ) + .orderBy(appointments.startTime); + return rows.map((r) => ({ + id: r.id, + startTime: r.startTime, + endTime: r.endTime, + status: r.status, + patient: r.patientName, + client: clientName(r.clientFirst, r.clientLast), + })); + }, +}; + +const bookAppointment: AgentTool = { + name: "book_appointment", + description: + "Create an appointment. Times are ISO-8601; end must be after start. client_id and patient_id are optional but recommended.", + inputSchema: { + type: "object", + properties: { + startTime: { type: "string" }, + endTime: { type: "string" }, + clientId: { type: "string" }, + patientId: { type: "string" }, + doctorId: { type: "string" }, + notes: { type: "string" }, + }, + required: ["startTime", "endTime"], + }, + zod: z + .object({ + startTime: z.string().datetime({ offset: true }), + endTime: z.string().datetime({ offset: true }), + clientId: z.string().uuid().optional(), + patientId: z.string().uuid().optional(), + doctorId: z.string().uuid().optional(), + notes: z.string().optional(), + }) + .refine((b) => new Date(b.endTime) > new Date(b.startTime), { + message: "endTime must be after startTime", + }), + readOnly: false, + async execute(args, ctx) { + const input = this.zod.parse(args) as { + startTime: string; + endTime: string; + clientId?: string; + patientId?: string; + doctorId?: string; + notes?: string; + }; + const [created] = await ctx.db + .insert(appointments) + .values({ + practiceId: ctx.practiceId, + startTime: new Date(input.startTime), + endTime: new Date(input.endTime), + clientId: input.clientId ?? null, + patientId: input.patientId ?? null, + doctorId: input.doctorId ?? null, + notes: input.notes ?? null, + }) + .returning(); + await dispatchWebhookEvent(ctx.practiceId, "appointment.created", { + id: created!.id, + startTime: created!.startTime, + endTime: created!.endTime, + source: "agent", + }); + return { id: created!.id, status: created!.status }; + }, +}; + +const listOverdueVaccinations: AgentTool = { + name: "list_overdue_vaccinations", + description: "List patients whose vaccinations are past due, for recall outreach.", + inputSchema: { type: "object", properties: {} }, + zod: z.object({}), + readOnly: true, + async execute(_args, ctx) { + const today = new Date().toISOString().slice(0, 10); + const rows = await ctx.db + .select({ + patientId: patients.id, + patientName: patients.name, + clientFirst: clients.firstName, + clientLast: clients.lastName, + vaccineName: vaccinationRecords.vaccineName, + nextDueDate: vaccinationRecords.nextDueDate, + }) + .from(vaccinationRecords) + .innerJoin(patients, eq(vaccinationRecords.patientId, patients.id)) + .leftJoin(clients, eq(patients.clientId, clients.id)) + .where( + and( + eq(vaccinationRecords.practiceId, ctx.practiceId), + isNull(vaccinationRecords.deletedAt), + isNull(patients.deletedAt), + lt(vaccinationRecords.nextDueDate, today) + ) + ) + .orderBy(vaccinationRecords.nextDueDate) + .limit(100); + return rows.map((r) => ({ + patientId: r.patientId, + patient: r.patientName, + client: clientName(r.clientFirst, r.clientLast), + vaccine: r.vaccineName, + dueDate: r.nextDueDate, + })); + }, +}; + +const calculateDrugDose: AgentTool = { + name: "calculate_drug_dose", + description: + "Calculate a weight-based drug dose from the formulary. Returns a reference range; the clinician must verify before prescribing.", + inputSchema: { + type: "object", + properties: { + drugId: { type: "string", description: "Formulary drug id, e.g. 'carprofen'" }, + species: { type: "string", enum: ["canine", "feline"] }, + weightKg: { type: "number" }, + concentrationMgPerMl: { type: "number" }, + }, + required: ["drugId", "species", "weightKg"], + }, + zod: z.object({ + drugId: z.string().min(1), + species: z.enum(["canine", "feline"]), + weightKg: z.number().positive(), + concentrationMgPerMl: z.number().positive().optional(), + }), + readOnly: true, + async execute(args) { + const input = this.zod.parse(args) as { + drugId: string; + species: "canine" | "feline"; + weightKg: number; + concentrationMgPerMl?: number; + }; + // calculateDose throws on bad input; the runner catches and returns the message. + return calculateDose(input); + }, +}; + +export const AGENT_TOOLS: AgentTool[] = [ + findClient, + getPatientSummary, + listAppointments, + bookAppointment, + listOverdueVaccinations, + calculateDrugDose, +]; + +export function getTool(name: string): AgentTool | undefined { + return AGENT_TOOLS.find((t) => t.name === name); +} + +/** Tool definitions in Anthropic Messages API format. */ +export function anthropicToolDefs() { + return AGENT_TOOLS.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.inputSchema, + })); +} diff --git a/apps/web/package.json b/apps/web/package.json index 17e8224..c8b6b66 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.0", "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", "@openpims/api": "workspace:*", diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index 800c906..fe14658 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -22,6 +22,7 @@ import { insuranceRouter } from "./insurance"; import { apiKeysRouter } from "./api-keys"; import { dosingRouter } from "./dosing"; import { vitalsRouter } from "./vitals"; +import { agentRouter } from "./agent"; export const appRouter = createRouter({ auth: authRouter, @@ -47,6 +48,7 @@ export const appRouter = createRouter({ apiKeys: apiKeysRouter, dosing: dosingRouter, vitals: vitalsRouter, + agent: agentRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/agent.ts b/apps/web/server/routers/agent.ts new file mode 100644 index 0000000..c67ea71 --- /dev/null +++ b/apps/web/server/routers/agent.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { + runAgent, + isAgentConfigured, + AGENT_TOOL_NAMES, + AgentNotConfiguredError, +} from "@/lib/agent"; + +const agentProcedure = protectedProcedure.use( + requireRole("admin", "veterinarian") +); + +export const agentRouter = createRouter({ + /** Whether the agent is enabled (API key present) and what it can do. */ + status: protectedProcedure.query(() => ({ + configured: isAgentConfigured(), + tools: AGENT_TOOL_NAMES, + })), + + /** Run the OpenVPM Agent against a natural-language instruction. */ + run: agentProcedure + .input( + z.object({ + instruction: z.string().min(1).max(2000), + // Writes (e.g. booking) are opt-in per run and require an explicit flag. + allowWrites: z.boolean().default(false), + }) + ) + .mutation(async ({ ctx, input }) => { + try { + return await runAgent({ + instruction: input.instruction, + allowWrites: input.allowWrites, + context: { + db: ctx.db, + practiceId: ctx.practiceId, + userId: ctx.user.id, + }, + }); + } catch (e) { + if (e instanceof AgentNotConfiguredError) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: e.message }); + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: e instanceof Error ? e.message : "Agent run failed", + }); + } + }), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dffb6c7..906b7ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: apps/web: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.71.0 + version: 0.71.2(zod@3.25.76) '@aws-sdk/client-s3': specifier: ^3.700.0 version: 3.1011.0 @@ -286,6 +289,15 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@anthropic-ai/sdk@0.71.2': + resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -2900,6 +2912,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -3564,6 +3580,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -3762,6 +3781,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@anthropic-ai/sdk@0.71.2(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -6362,6 +6387,11 @@ snapshots: js-tokens@4.0.0: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -7027,6 +7057,8 @@ snapshots: dependencies: is-number: 7.0.0 + ts-algebra@2.0.0: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} From 94fcd3aad25c4c07cc772fb098bb02bfa3acddc5 Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 23:26:26 -0400 Subject: [PATCH 09/25] Marketing site: 'don't switch, connect' direction + Agent + /updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes the pitch around the validated insight that practices won't rip-and-replace their PIMS. New homepage section positions OpenVPM as the open data layer you connect to your current system — a live, exportable copy of your own data you control — plus an OpenVPM Agent callout (operates the practice over the open API, every write gated, scoped to your data). Adds an /updates changelog page to push product notes out (API v1, the Agent, dosing + vitals, the new direction), wired into nav, footer, and sitemap. Email capture via the existing waitlist is linked from the updates CTA. Co-Authored-By: Claude Opus 4.8 --- apps/www/app/page.tsx | 111 ++++++++++++++++++++++++++ apps/www/app/sitemap.ts | 1 + apps/www/app/updates/page.tsx | 141 +++++++++++++++++++++++++++++++++ apps/www/components/footer.tsx | 3 + apps/www/components/nav.tsx | 1 + 5 files changed, 257 insertions(+) create mode 100644 apps/www/app/updates/page.tsx diff --git a/apps/www/app/page.tsx b/apps/www/app/page.tsx index 89f1193..72119f4 100644 --- a/apps/www/app/page.tsx +++ b/apps/www/app/page.tsx @@ -28,6 +28,9 @@ import { Bot, Terminal, Server, + RefreshCw, + ShieldCheck, + Sparkles, } from "lucide-react"; const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://demo.openvpm.com"; @@ -570,6 +573,114 @@ export default function LandingPage() { + {/* Connect / Own Your Data + Agent */} +
+
+
+
+ + + Own your data + +
+

+ Don't switch. Connect. +

+

+ You don't have to rip out the system you already run. Connect OpenVPM + to your current PIMS and keep a live, open copy of your own data — + patients, clients, visit history — in a system you control and can + export any time. A second PIMS that you own. +

+
+ +
+ {[ + { + icon: RefreshCw, + title: "Live mirror", + description: + "Connect once. Your practice data flows into OpenVPM and stays in sync. Your records, in a system you hold the keys to.", + }, + { + icon: Database, + title: "Open by default", + description: + "Everything sits behind a documented REST API with scoped keys. Read it, build on it, export it in full — no proprietary formats, no lock-in.", + }, + { + icon: ShieldCheck, + title: "Zero disruption", + description: + "Your team keeps working in the tools they know. OpenVPM runs alongside, read-only until you decide to do more with it.", + }, + ].map((item) => { + const Icon = item.icon; + return ( +
+
+ +
+

+ {item.title} +

+

+ {item.description} +

+
+ ); + })} +
+ + {/* OpenVPM Agent callout */} +
+
+
+
+ + + OpenVPM Agent + +
+

+ An agent that runs the busywork. +

+

+ OpenVPM ships with an AI agent that works directly on your practice + data through the same open API. Ask it to surface overdue vaccinations, + draft recall outreach, pull a patient's history, calculate a + weight-based dose, or book a visit. It uses real tools, not guesses, and + every write is gated for your review. +

+
+ {[ + "Find patients & clients", + "Recall lists", + "Dosing reference", + "Book appointments", + "Daily summary", + ].map((chip) => ( + + + {chip} + + ))} +
+

+ Bring your own model key. Open source, inspectable, and scoped to your + practice — the agent can only ever touch your own data. +

+
+
+
+
+ {/* Stats */}
diff --git a/apps/www/app/sitemap.ts b/apps/www/app/sitemap.ts index c31792c..3c4621e 100644 --- a/apps/www/app/sitemap.ts +++ b/apps/www/app/sitemap.ts @@ -9,5 +9,6 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${baseUrl}/features`, lastModified: now, changeFrequency: "weekly", priority: 0.8 }, { url: `${baseUrl}/why`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${baseUrl}/install`, lastModified: now, changeFrequency: "weekly", priority: 0.9 }, + { url: `${baseUrl}/updates`, lastModified: now, changeFrequency: "weekly", priority: 0.6 }, ]; } diff --git a/apps/www/app/updates/page.tsx b/apps/www/app/updates/page.tsx new file mode 100644 index 0000000..35aa673 --- /dev/null +++ b/apps/www/app/updates/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight, Github } from "lucide-react"; + +export const metadata: Metadata = { + title: "Updates", + description: + "What's new in OpenVPM — product updates, shipped features, and where the open-source veterinary PIMS is headed.", +}; + +interface Update { + date: string; + tag: "Platform" | "Clinical" | "AI" | "Direction"; + title: string; + body: string; + points?: string[]; +} + +const tagStyles: Record = { + Platform: "bg-blue-50 text-blue-700 border-blue-200", + Clinical: "bg-emerald-50 text-emerald-700 border-emerald-200", + AI: "bg-purple-50 text-purple-700 border-purple-200", + Direction: "bg-teal-50 text-teal-700 border-teal-200", +}; + +// Newest first. Update this list as work ships. +const updates: Update[] = [ + { + date: "June 1, 2026", + tag: "Direction", + title: "Don't switch — connect. A second PIMS that you own.", + body: "We heard it from every practice we talked to: changing your PIMS is a massive lift, and you won't do it on a whim. So we changed the ask. Instead of rip-and-replace, OpenVPM is becoming the open data layer that connects to the system you already run — giving you a live, exportable copy of your own data in a system you control.", + points: [ + "New positioning: run OpenVPM alongside your current PIMS, read-only until you want more.", + "Everything stays behind a documented, exportable API. No lock-in, ever.", + ], + }, + { + date: "June 1, 2026", + tag: "AI", + title: "Meet the OpenVPM Agent", + body: "OpenVPM now ships with an AI agent that operates on your practice data through real, typed tools — not guesses. It can find clients and patients, pull a clinical summary, list overdue vaccinations for recall, calculate a weight-based drug dose, and book appointments. Every write is gated for review, and the agent is scoped so it can only ever touch your own practice's data.", + points: [ + "Bring your own model key. The agent is open source and fully inspectable.", + "Foundation for an agent-operated clinic — the busywork runs itself.", + ], + }, + { + date: "June 1, 2026", + tag: "Platform", + title: "Public REST API (v1) + scoped API keys", + body: "A versioned, public REST API over your core records — clients, patients, and appointments — authenticated with scoped, per-practice API keys. Built so integrators and AI agents can plug in without touching the internal app, with response shapes frozen independently of the database so integrations never break on an internal change.", + points: [ + "Scoped keys (clients:read, patients:read, appointments:write, …) with per-key rate limits.", + "Appointment creation fires real webhooks. See docs/api for the full reference.", + ], + }, + { + date: "June 1, 2026", + tag: "Clinical", + title: "Dosing calculator + vital signs tracking", + body: "Two clinical-depth features that bring OpenVPM closer to parity with the leading systems. A weight-based drug dosing calculator with a curated formulary, species-specific reference ranges, tablet-split suggestions, and verify-before-prescribing guard rails — plus full vital-signs capture per visit (temperature, heart rate, respiration, weight, body condition, pain score, and more).", + }, +]; + +export default function UpdatesPage() { + return ( +
+
+
+

+ Updates +

+

+ What's shipping in OpenVPM and where we're headed. Built in the + open, with the veterinary community. +

+
+ +
+ {updates.map((u, i) => ( +
+
+ + {u.tag} + + +
+

+ {u.title} +

+

{u.body}

+ {u.points && ( +
    + {u.points.map((p) => ( +
  • + + {p} +
  • + ))} +
+ )} +
+ ))} +
+ +
+

+ Want these in your inbox? +

+

+ Follow along as we build. Join the list and we'll send the big ones. +

+
+ + Join the list + + + + + Watch on GitHub + +
+
+
+
+ ); +} diff --git a/apps/www/components/footer.tsx b/apps/www/components/footer.tsx index 012db4b..f9b38ac 100644 --- a/apps/www/components/footer.tsx +++ b/apps/www/components/footer.tsx @@ -42,6 +42,9 @@ export function MarketingFooter() { Why Open Source + + Updates + Pricing (free) diff --git a/apps/www/components/nav.tsx b/apps/www/components/nav.tsx index 85766a2..ac987d6 100644 --- a/apps/www/components/nav.tsx +++ b/apps/www/components/nav.tsx @@ -29,6 +29,7 @@ const navLinks = [ { label: "Features", href: "/features" }, { label: "Install", href: "/install" }, { label: "Why Open Source", href: "/why" }, + { label: "Updates", href: "/updates" }, ]; export function MarketingNav() { From 619ecbf460e298f1fa577ebcd13a5067d085fece Mon Sep 17 00:00:00 2001 From: evgauer Date: Mon, 1 Jun 2026 23:27:39 -0400 Subject: [PATCH 10/25] Add in-product Agent console Dashboard page (admin/vet) to run the OpenVPM Agent: instruction box with suggested prompts, an opt-in 'allow writes' toggle, the agent's answer, and a collapsible tool-call trace so staff can see exactly what it did. Shows a clear setup notice when ANTHROPIC_API_KEY isn't configured. Added to the sidebar. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/(dashboard)/agent/page.tsx | 149 ++++++++++++++++++++++++ apps/web/components/layout/sidebar.tsx | 2 + 2 files changed, 151 insertions(+) create mode 100644 apps/web/app/(dashboard)/agent/page.tsx diff --git a/apps/web/app/(dashboard)/agent/page.tsx b/apps/web/app/(dashboard)/agent/page.tsx new file mode 100644 index 0000000..c2830eb --- /dev/null +++ b/apps/web/app/(dashboard)/agent/page.tsx @@ -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 ( +
+
+
+ +
+
+

OpenVPM Agent

+

+ Ask the agent to work on your practice data. It uses real tools and + never invents records. +

+
+
+ + {!configured && ( +
+ +
+ The agent is not configured yet. Set ANTHROPIC_API_KEY{" "} + in your environment to enable agent runs. +
+
+ )} + +
+