diff --git a/.env.example b/.env.example index 15ab490..ed492ce 100644 --- a/.env.example +++ b/.env.example @@ -20,10 +20,27 @@ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_PHONE_NUMBER= -# Stripe +# Stripe (client invoicing — client portal payments) STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# Hosted SaaS subscriptions (managed service only — leave HOSTED_BILLING_ENABLED +# unset for self-host so the OSS edition runs fully unlocked). When enabled, +# plan tiers are enforced and these must be set. +HOSTED_BILLING_ENABLED= +# Cloud plan: $99/mo recurring Price, charged per location (Stripe quantity). +STRIPE_PRICE_CLOUD= +# Metered overage Prices for usage beyond the included monthly allowances. +STRIPE_PRICE_SMS_OVERAGE= +STRIPE_PRICE_AI_OVERAGE= +# Separate webhook endpoint secret for customer.subscription.* events. +STRIPE_SUBSCRIPTION_WEBHOOK_SECRET= +# Public base URL used to build Stripe success/return URLs (falls back to NEXTAUTH_URL). +NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Comma-separated emails allowed into the cross-tenant platform admin dashboard +# (/admin). OpenVPM operators only — separate from a practice's own admin role. +PLATFORM_ADMIN_EMAILS= + # Cron job authentication CRON_SECRET= diff --git a/.gitignore b/.gitignore index 272fee7..229ea4f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ docs/screenshots/audit/ test-results/ CAVSG_AI_Innovator_Survey_COMPLETED.docx package-lock.json + +# Local MCP server config (not for the public repo) +.mcp.json diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..1f95da4 --- /dev/null +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + + const request = trpc.auth.requestPasswordReset.useMutation({ + onSuccess: () => setSent(true), + onError: (err) => toast.error(err.message), + }); + + return ( +
+
+
+

OpenVPM

+

Reset your password

+
+ + {sent ? ( +

+ If an account exists for {email}, we've sent a reset link. + Check your inbox. +

+ ) : ( +
{ + e.preventDefault(); + request.mutate({ email }); + }} + className="space-y-4" + > +
+ + setEmail(e.target.value)} + required + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="you@clinic.com" + /> +
+ +
+ )} + +

+ + Back to sign in + +

+
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 7baf303..3be4530 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -177,6 +177,11 @@ export default function LoginPage() {

+ + Forgot your password? + +

+

Don't have an account?{" "} Register your practice diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx index 7ec99f1..1406f78 100644 --- a/apps/web/app/(auth)/register/page.tsx +++ b/apps/web/app/(auth)/register/page.tsx @@ -16,16 +16,23 @@ export default function RegisterPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [verifySent, setVerifySent] = useState(false); + const registerMutation = trpc.auth.register.useMutation({ - onSuccess: async () => { + onSuccess: async (data) => { + // Hosted: email verification required → show a "check your inbox" notice. + if (data?.verificationRequired) { + setVerifySent(true); + setLoading(false); + return; + } + // Self-host: auto-sign in after registration. toast.success("Account created! Redirecting..."); - // Auto-sign in after registration const result = await signIn("credentials", { email, password, redirect: false, }); - if (result?.ok) { router.push("/"); router.refresh(); @@ -38,6 +45,23 @@ export default function RegisterPage() { }, }); + if (verifySent) { + return ( +

+
+

Check your email

+

+ We sent a verification link to {email}. Click it to activate + your account and start your 14-day free trial. +

+ + Back to sign in + +
+
+ ); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); @@ -55,6 +79,9 @@ export default function RegisterPage() {

Register your practice

+

+ 14-day free trial · no credit card required +

diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..d800335 --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; + +function ResetPasswordInner() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [password, setPassword] = useState(""); + const [done, setDone] = useState(false); + + const reset = trpc.auth.resetPassword.useMutation({ + onSuccess: () => setDone(true), + onError: (err) => toast.error(err.message), + }); + + return ( +
+
+
+

OpenVPM

+

Choose a new password

+
+ + {done ? ( +
+

Your password has been reset.

+ + Sign in + +
+ ) : !token ? ( +

+ This reset link is invalid. Request a new one from the sign-in page. +

+ ) : ( + { + e.preventDefault(); + reset.mutate({ token, password }); + }} + className="space-y-4" + > +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="At least 8 characters" + /> +
+ + + )} +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(auth)/verify-email/page.tsx b/apps/web/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..d653eff --- /dev/null +++ b/apps/web/app/(auth)/verify-email/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { trpc } from "@/lib/trpc"; + +function VerifyEmailInner() { + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [status, setStatus] = useState<"verifying" | "ok" | "error">("verifying"); + const ran = useRef(false); + + const verify = trpc.auth.verifyEmail.useMutation({ + onSuccess: () => setStatus("ok"), + onError: () => setStatus("error"), + }); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + if (!token) { + setStatus("error"); + return; + } + verify.mutate({ token }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + return ( +
+
+

OpenVPM

+ {status === "verifying" && ( +

Verifying your email…

+ )} + {status === "ok" && ( + <> +

+ Your email is verified. You can now sign in and start your free trial. +

+ + Sign in + + + )} + {status === "error" && ( + <> +

+ This verification link is invalid or has expired. +

+ + Back to sign in + + + )} +
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(dashboard)/admin/page.tsx b/apps/web/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..f2440cf --- /dev/null +++ b/apps/web/app/(dashboard)/admin/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { ShieldAlert, Loader2, Building2, DollarSign, Clock, CheckCircle, AlertTriangle } from "lucide-react"; +import { trpc } from "@/lib/trpc"; + +function formatUsd(n: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(n); +} + +function formatDate(d: Date | string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +const statusStyles: Record = { + active: "bg-green-100 text-green-700", + trialing: "bg-blue-100 text-blue-700", + past_due: "bg-red-100 text-red-700", + canceled: "bg-gray-100 text-gray-500", + none: "bg-gray-100 text-gray-500", +}; + +export default function AdminPage() { + const { data, isLoading, error } = trpc.admin.overview.useQuery(undefined, { + retry: false, + }); + + if (error) { + return ( +
+ +

Access Denied

+

+ This area is for OpenVPM platform operators only. +

+
+ ); + } + + if (isLoading || !data) { + return ( +
+ +
+ ); + } + + const kpis = [ + { label: "Practices", value: String(data.totals.practices), icon: Building2 }, + { label: "Est. MRR", value: formatUsd(data.totals.estimatedMrr), icon: DollarSign }, + { label: "On trial", value: String(data.totals.trialing), icon: Clock }, + { label: "Active", value: String(data.totals.active), icon: CheckCircle }, + { label: "Past due", value: String(data.totals.pastDue), icon: AlertTriangle }, + ]; + + return ( +
+
+

Platform Admin

+

+ Cross-tenant operations overview +

+
+ + {/* KPIs */} +
+ {kpis.map((k) => { + const Icon = k.icon; + return ( +
+
+ + {k.label} +
+

{k.value}

+
+ ); + })} +
+ + {/* Practices table */} +
+ + + + + + + + + + + + + + + + {data.practices.map((p) => ( + + + + + + + + + + + + ))} + {data.practices.length === 0 && ( + + + + )} + +
PracticePlanStatusTrial endsStaffClientsPatientsCountryJoined
{p.name}{p.tier} + + {p.billingStatus.replace("_", " ")} + + {formatDate(p.trialEndsAt)}{p.userCount}{p.clientCount}{p.patientCount}{p.country}{formatDate(p.createdAt)}
+ No practices yet. +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/onboarding/page.tsx b/apps/web/app/(dashboard)/onboarding/page.tsx new file mode 100644 index 0000000..e5784c3 --- /dev/null +++ b/apps/web/app/(dashboard)/onboarding/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Loader2, Check, Building2, Users, Database, Rocket } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; + +const STEPS = [ + { + icon: Building2, + title: "Confirm your practice details", + body: "Set your practice name, country, currency, tax rate, and timezone.", + href: "/settings?tab=practice", + cta: "Open practice settings", + }, + { + icon: Users, + title: "Invite your team", + body: "Add veterinarians, technicians, and front-desk staff so everyone can log in.", + href: "/settings?tab=staff", + cta: "Manage staff", + }, + { + icon: Database, + title: "Bring in your data", + body: "Import clients and patients from your current system, or start fresh.", + href: "/settings?tab=data", + cta: "Import data", + }, +]; + +export default function OnboardingPage() { + const router = useRouter(); + const utils = trpc.useUtils(); + const status = trpc.settings.onboardingStatus.useQuery(undefined, { retry: false }); + + const clearDemo = trpc.settings.clearDemoData.useMutation({ + onSuccess: () => { + utils.settings.onboardingStatus.invalidate(); + toast.success("Demo data cleared"); + }, + onError: (e) => toast.error(e.message), + }); + const complete = trpc.settings.completeOnboarding.useMutation({ + onSuccess: () => { + toast.success("You're all set!"); + router.push("/"); + router.refresh(); + }, + onError: (e) => toast.error(e.message), + }); + + if (status.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +

Welcome to OpenVPM

+

+ A few quick steps to get your practice running. Your dashboard is + pre-filled with demo data so you can explore right away. +

+
+ +
+ {STEPS.map((s) => { + const Icon = s.icon; + return ( +
+
+ +
+
+

{s.title}

+

{s.body}

+
+ + + +
+ ); + })} +
+ + {status.data?.hasDemoData && ( +
+
+

Clear the demo data

+

+ Remove the sample clients, patients, and appointments when you're + ready to go live with real data. +

+
+ +
+ )} + +
+ + + + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx index be9c9ae..2a3cfba 100644 --- a/apps/web/app/(dashboard)/page.tsx +++ b/apps/web/app/(dashboard)/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { Calendar, PawPrint, DollarSign, FileText, Clock } from "lucide-react"; +import Link from "next/link"; +import { Calendar, PawPrint, DollarSign, FileText, Clock, Rocket } from "lucide-react"; import { cn } from "@/lib/utils"; import { trpc } from "@/lib/trpc"; import { formatCurrency, localeForCountry } from "@/lib/locale/format"; @@ -138,6 +139,30 @@ function PieLabel({ ); } +function OnboardingBanner() { + // Admin-only; non-admins get FORBIDDEN → no banner. + const { data } = trpc.settings.onboardingStatus.useQuery(undefined, { + retry: false, + staleTime: 5 * 60 * 1000, + }); + if (!data || data.completedAt) return null; + return ( + + +
+

Finish setting up your practice

+

+ Confirm your details, invite your team, and clear the demo data when you're ready. +

+
+ Continue → + + ); +} + export default function DashboardPage() { const stats = trpc.dashboard.getStats.useQuery(); const charts = trpc.dashboard.getCharts.useQuery(); @@ -174,6 +199,7 @@ export default function DashboardPage() { return (
+ {/* KPI Cards */}
{stats.isLoading diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 64cb477..87e11fe 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, Suspense } from "react"; import { useSession } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; import { Settings, Users, @@ -20,6 +21,7 @@ import { FileSpreadsheet, Check, Layers, + CreditCard, } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; @@ -30,7 +32,7 @@ import { regionDefaults } from "@/lib/locale/format"; import { useCurrencyFormatter } from "@/lib/locale/useCurrency"; // ── Types ─────────────────────────────────────────────────── -type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates"; +type Tab = "practice" | "staff" | "appointmentTypes" | "rooms" | "data" | "templates" | "billing"; const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ { id: "practice", label: "Practice Info", icon: Settings }, @@ -39,6 +41,7 @@ const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [ { id: "rooms", label: "Rooms", icon: DoorOpen }, { id: "data", label: "Data", icon: Database }, { id: "templates", label: "Templates", icon: Layers }, + { id: "billing", label: "Plan & Billing", icon: CreditCard }, ]; const TIMEZONES = [ @@ -88,8 +91,26 @@ const ROOM_TYPES = ["exam", "surgery", "treatment", "boarding"] as const; // ── Main Page ─────────────────────────────────────────────── export default function SettingsPage() { + return ( + + +
+ } + > + + + ); +} + +function SettingsPageInner() { const { data: session } = useSession(); - const [activeTab, setActiveTab] = useState("practice"); + const searchParams = useSearchParams(); + const initialTab = (searchParams.get("tab") as Tab) || "practice"; + const [activeTab, setActiveTab] = useState( + tabs.some((t) => t.id === initialTab) ? initialTab : "practice" + ); if (session?.user?.role !== "admin") { return ( @@ -144,6 +165,7 @@ export default function SettingsPage() { {activeTab === "rooms" && } {activeTab === "data" && } {activeTab === "templates" && } + {activeTab === "billing" && }
); @@ -349,6 +371,268 @@ function PracticeInfoTab() { ); } +// ── Plan & Billing ────────────────────────────────────────── +const FEATURE_LABELS: Record = { + agent: "OpenVPM Agent (AI)", + sms: "SMS sending", + advancedReporting: "Advanced reporting", + apiAccess: "API access + webhooks", + multiLocation: "Multi-location", + integrations: "Integrations", +}; + +function BillingTab() { + const { data, isLoading } = trpc.subscription.get.useQuery(); + const checkout = trpc.subscription.createCheckout.useMutation({ + onSuccess: (r) => { + if (r.url) window.location.href = r.url; + }, + onError: (e) => toast.error(e.message), + }); + const portal = trpc.subscription.openBillingPortal.useMutation({ + onSuccess: (r) => { + if (r.url) window.location.href = r.url; + }, + onError: (e) => toast.error(e.message), + }); + + if (isLoading || !data) { + return ( +
+ +
+ ); + } + + // Self-host: nothing to buy — everything is unlocked. + if (!data.billingEnforced) { + return ( +
+
+
+ +

+ Self-hosted — all features unlocked +

+
+

+ You're running OpenVPM on your own infrastructure. Every feature + is available and there's no subscription — free forever. Plans + below are how the managed OpenVPM Cloud is priced, for reference. +

+
+ {}} busyTier={null} /> +
+ ); + } + + const trialEnds = data.trialEndsAt ? new Date(data.trialEndsAt) : null; + const daysLeft = trialEnds + ? Math.max(0, Math.ceil((trialEnds.getTime() - Date.now()) / (24 * 60 * 60 * 1000))) + : 0; + const currentPlan = data.plans.find((p) => p.tier === data.tier); + + return ( +
+ {/* Current status */} +
+
+
+

Current plan

+

+ {currentPlan?.name ?? data.tier} +

+
+ + {data.billingStatus.replace("_", " ")} + +
+ {data.billingStatus === "trialing" && ( +

+ You're on a free trial with full Cloud access —{" "} + {daysLeft} days left. Subscribe + below to keep your features after it ends. +

+ )} + {data.billingStatus === "past_due" && ( +

+ Your last payment failed. Update your payment method to avoid losing access. +

+ )} + {/* Billing summary: locations × price + this month's metered usage */} +
+
+

Locations

+

+ {data.locationCount} × ${currentPlan?.priceMonthlyUsd ?? 99}/mo ={" "} + ${data.locationCount * (currentPlan?.priceMonthlyUsd ?? 99)}/mo +

+
+
+

SMS this month

+

+ {data.usage.sms} + {currentPlan?.includedSmsPerMonth != null + ? ` / ${currentPlan.includedSmsPerMonth} included` + : ""} +

+
+
+

AI runs this month

+

+ {data.usage.aiRuns} + {currentPlan?.includedAiRunsPerMonth != null + ? ` / ${currentPlan.includedAiRunsPerMonth} included` + : ""} +

+
+
+ {data.hasBillingAccount && ( + + )} +
+ + checkout.mutate({ tier })} + busyTier={checkout.isPending ? checkout.variables?.tier ?? null : null} + /> +
+ ); +} + +function PlanGrid({ + plans, + currentTier, + enforced, + onChoose, + busyTier, +}: { + plans: Array<{ + tier: string; + name: string; + priceMonthlyUsd: number | null; + pricePerLocation: boolean; + blurb: string; + features: string[]; + seatLimit: number | null; + locationLimit: number | null; + includedSmsPerMonth: number | null; + includedAiRunsPerMonth: number | null; + selfServe: boolean; + purchasable: boolean; + }>; + currentTier: string; + enforced: boolean; + onChoose: (tier: "cloud") => void; + busyTier: string | null; +}) { + return ( +
+ {plans.map((p) => { + const isCurrent = p.tier === currentTier; + const canBuy = enforced && p.purchasable && p.tier === "cloud" && !isCurrent; + return ( +
+

{p.name}

+

+ {p.priceMonthlyUsd === null ? ( + "Custom" + ) : p.priceMonthlyUsd === 0 ? ( + "Free" + ) : ( + <> + ${p.priceMonthlyUsd} + + /mo{p.pricePerLocation ? " / location" : ""} + + + )} +

+

{p.blurb}

+
    +
  • {p.seatLimit === null ? "Unlimited" : p.seatLimit} staff seats
  • +
  • + {p.locationLimit === null ? "Unlimited" : p.locationLimit} location + {p.locationLimit === 1 ? "" : "s"} +
  • + {p.includedSmsPerMonth ? ( +
  • {p.includedSmsPerMonth} SMS/mo included
  • + ) : null} + {p.includedAiRunsPerMonth ? ( +
  • {p.includedAiRunsPerMonth} AI runs/mo included
  • + ) : null} + {p.features.length > 0 ? ( + p.features.map((f) => ( +
  • + + {FEATURE_LABELS[f] ?? f} +
  • + )) + ) : ( +
  • Full core PIMS
  • + )} +
+
+ {isCurrent ? ( + Current plan + ) : canBuy ? ( + + ) : !p.selfServe ? ( + + Contact sales + + ) : null} +
+
+ ); + })} +
+ ); +} + // ── Staff ─────────────────────────────────────────────────── function StaffTab() { const utils = trpc.useUtils(); diff --git a/apps/web/app/api/cron/backup/route.ts b/apps/web/app/api/cron/backup/route.ts index 4561dd9..6b266bb 100644 --- a/apps/web/app/api/cron/backup/route.ts +++ b/apps/web/app/api/cron/backup/route.ts @@ -5,6 +5,7 @@ import { practices } from "@openpims/db"; import { exportPracticeData, backupKey } from "@/lib/backup/export"; import { uploadFile } from "@/lib/s3"; import { alertOps } from "@/lib/alerts"; +import { withSystem, withTenant } from "@/lib/tenant-db"; export const dynamic = "force-dynamic"; export const maxDuration = 300; @@ -22,14 +23,20 @@ export async function GET(request: Request) { let failed = 0; try { - const allPractices = await db - .select({ id: practices.id }) - .from(practices) - .where(isNull(practices.deletedAt)); + // Cross-tenant sweep → system context (RLS bypass). + const allPractices = await withSystem(db, (tx) => + tx + .select({ id: practices.id }) + .from(practices) + .where(isNull(practices.deletedAt)) + ); for (const p of allPractices) { try { - const data = await exportPracticeData(db, p.id, new Date().toISOString()); + // Export each practice in its own tenant context (RLS-scoped). + const data = await withTenant(db, p.id, (tx) => + exportPracticeData(tx, p.id, new Date().toISOString()) + ); const key = backupKey(p.id, today); await uploadFile(key, Buffer.from(JSON.stringify(data)), "application/json"); ok++; diff --git a/apps/web/app/api/cron/reminders/route.ts b/apps/web/app/api/cron/reminders/route.ts index 27ecc75..7d45cfa 100644 --- a/apps/web/app/api/cron/reminders/route.ts +++ b/apps/web/app/api/cron/reminders/route.ts @@ -10,6 +10,7 @@ import { } from "@openpims/db"; import { sendAppointmentReminder } from "@/lib/email"; import { alertOps } from "@/lib/alerts"; +import { withSystem, withTenant } from "@/lib/tenant-db"; export async function GET(request: Request) { // Validate the cron secret to prevent unauthorized access @@ -22,8 +23,9 @@ export async function GET(request: Request) { const now = new Date(); const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000); - // Find all upcoming appointments across all practices that are eligible for reminders - const upcomingAppointments = await db + // Cross-tenant sweep across all practices → system context (RLS bypass). + const upcomingAppointments = await withSystem(db, (tx) => + tx .select({ id: appointments.id, startTime: appointments.startTime, @@ -48,7 +50,8 @@ export async function GET(request: Request) { inArray(appointments.status, ["scheduled", "confirmed"]), ), ) - .orderBy(appointments.startTime); + .orderBy(appointments.startTime) + ); let sent = 0; let failed = 0; @@ -78,15 +81,17 @@ export async function GET(request: Request) { practiceName: "", }); - await db.insert(communications).values({ - practiceId: appt.practiceId, - clientId: appt.clientId, - channel: "email", - direction: "outbound", - subject: "Appointment Reminder", - content: `Automated appointment reminder sent for ${appt.patientName} on ${appt.startTime.toISOString()}`, - status: "sent", - }); + await withTenant(db, appt.practiceId, (tx) => + tx.insert(communications).values({ + practiceId: appt.practiceId, + clientId: appt.clientId!, + channel: "email", + direction: "outbound", + subject: "Appointment Reminder", + content: `Automated appointment reminder sent for ${appt.patientName} on ${appt.startTime.toISOString()}`, + status: "sent", + }) + ); sent++; } catch (error) { diff --git a/apps/web/app/api/portal/checkout/route.ts b/apps/web/app/api/portal/checkout/route.ts index e40dcce..5f5d5f8 100644 --- a/apps/web/app/api/portal/checkout/route.ts +++ b/apps/web/app/api/portal/checkout/route.ts @@ -3,6 +3,7 @@ import { eq, and, isNull } from "drizzle-orm"; import { db } from "@openpims/db/client"; import { clients, invoices, patients, practices } from "@openpims/db"; import { createCheckoutSession } from "@/lib/stripe"; +import { withSystem } from "@/lib/tenant-db"; export async function POST(req: NextRequest) { try { @@ -15,8 +16,10 @@ export async function POST(req: NextRequest) { ); } + // Public token flow → run cross-tenant lookups in system context (RLS bypass). + return await withSystem(db, async (tx) => { // Validate client token (same pattern as portal router) - const [client] = await db + const [client] = await tx .select() .from(clients) .where(and(eq(clients.accessToken, token), isNull(clients.deletedAt))) @@ -30,7 +33,7 @@ export async function POST(req: NextRequest) { } // Fetch invoice and verify it belongs to this client - const [invoice] = await db + const [invoice] = await tx .select({ id: invoices.id, total: invoices.total, @@ -80,7 +83,7 @@ export async function POST(req: NextRequest) { // Build description let description = `Invoice payment`; if (invoice.patientId) { - const [patient] = await db + const [patient] = await tx .select({ name: patients.name }) .from(patients) .where(eq(patients.id, invoice.patientId)) @@ -91,7 +94,7 @@ export async function POST(req: NextRequest) { } // Charge in the practice's configured currency (region-aware). - const [practice] = await db + const [practice] = await tx .select({ currency: practices.currency }) .from(practices) .where(eq(practices.id, invoice.practiceId)) @@ -117,6 +120,7 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ url: result.url }); + }); } catch (err) { console.error("[Portal Checkout] Error:", err); return NextResponse.json( diff --git a/apps/web/app/api/upload/route.ts b/apps/web/app/api/upload/route.ts index 1cd5a52..225821c 100644 --- a/apps/web/app/api/upload/route.ts +++ b/apps/web/app/api/upload/route.ts @@ -5,6 +5,7 @@ import { authOptions } from "@/lib/auth"; import { uploadFile } from "@/lib/s3"; import { db } from "@openpims/db/client"; import { files } from "@openpims/db"; +import { withTenant } from "@/lib/tenant-db"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -92,17 +93,19 @@ export async function POST(req: NextRequest) { const buffer = Buffer.from(await file.arrayBuffer()); const url = await uploadFile(key, buffer, mimeType); - // Persist metadata in the database - await db.insert(files).values({ - practiceId, - uploadedBy: session.user.id, - fileName: file.name, - fileKey: key, - fileUrl: url, - mimeType, - fileSizeBytes: file.size, - category, - }); + // Persist metadata in the database (tenant-scoped for RLS). + await withTenant(db, practiceId, (tx) => + tx.insert(files).values({ + practiceId, + uploadedBy: session.user.id, + fileName: file.name, + fileKey: key, + fileUrl: url, + mimeType, + fileSizeBytes: file.size, + category, + }) + ); return NextResponse.json({ url, key }, { status: 201 }); } catch (err) { diff --git a/apps/web/app/api/v1/agent/route.ts b/apps/web/app/api/v1/agent/route.ts index 5e9a341..757f331 100644 --- a/apps/web/app/api/v1/agent/route.ts +++ b/apps/web/app/api/v1/agent/route.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { withErrorHandling, apiError, validationError } from "@/lib/compat/shared/errors"; import { runAgent, AgentNotConfiguredError } from "@/lib/agent"; @@ -31,16 +32,18 @@ export async function POST(req: Request) { if (!parsed.success) return validationError(parsed.error); try { - const result = await runAgent({ - instruction: parsed.data.instruction, - allowWrites: parsed.data.allow_writes, - context: { - db, - practiceId: auth.ctx.practiceId, - // No human user on an API call; identify the actor by key. - userId: `apikey:${auth.ctx.apiKeyId}`, - }, - }); + const result = await withTenant(db, auth.ctx.practiceId, (tx) => + runAgent({ + instruction: parsed.data.instruction, + allowWrites: parsed.data.allow_writes, + context: { + db: tx, + practiceId: auth.ctx.practiceId, + // No human user on an API call; identify the actor by key. + userId: `apikey:${auth.ctx.apiKeyId}`, + }, + }) + ); return NextResponse.json({ data: result }); } catch (e) { if (e instanceof AgentNotConfiguredError) { diff --git a/apps/web/app/api/v1/appointments/route.ts b/apps/web/app/api/v1/appointments/route.ts index 138008e..ce08a10 100644 --- a/apps/web/app/api/v1/appointments/route.ts +++ b/apps/web/app/api/v1/appointments/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { appointments } from "@openpims/db"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { dispatchWebhookEvent } from "@/lib/webhook-dispatcher"; import { withErrorHandling, apiError, validationError } from "@/lib/compat/shared/errors"; import { @@ -28,15 +29,18 @@ export async function POST(req: Request) { 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!); + const created = await withTenant(db, auth.ctx.practiceId, async (tx) => { + const [row] = await tx + .insert(appointments) + .values({ + ...fromApiAppointmentCreate(parsed.data), + practiceId: auth.ctx.practiceId, + }) + .returning(); + return row!; + }); + + const apiAppointment = toApiAppointment(created); // Fire the (previously never-triggered) webhook for this practice. await dispatchWebhookEvent( diff --git a/apps/web/app/api/v1/clients/[id]/route.ts b/apps/web/app/api/v1/clients/[id]/route.ts index e190150..5db5fae 100644 --- a/apps/web/app/api/v1/clients/[id]/route.ts +++ b/apps/web/app/api/v1/clients/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { clients } from "@openpims/db"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { withErrorHandling, notFound } from "@/lib/compat/shared/errors"; import { toApiClient } from "@/lib/compat/openvpm"; @@ -16,20 +17,22 @@ export async function GET( 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) + return withErrorHandling(() => + withTenant(db, auth.ctx.practiceId, async (tx) => { + const [row] = await tx + .select() + .from(clients) + .where( + and( + eq(clients.id, params.id), + eq(clients.practiceId, auth.ctx.practiceId), + isNull(clients.deletedAt) + ) ) - ) - .limit(1); + .limit(1); - if (!row) return notFound("Client"); - return NextResponse.json({ data: toApiClient(row) }); - }); + 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 index d87f227..62aa114 100644 --- a/apps/web/app/api/v1/clients/route.ts +++ b/apps/web/app/api/v1/clients/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { clients } from "@openpims/db"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { withErrorHandling } from "@/lib/compat/shared/errors"; import { parsePagination, paginated } from "@/lib/compat/shared/pagination"; import { toApiClient } from "@/lib/compat/openvpm"; @@ -14,29 +15,31 @@ 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); + return withErrorHandling(() => + withTenant(db, auth.ctx.practiceId, async (tx) => { + const { searchParams } = new URL(req.url); + const { limit, offset } = parsePagination(searchParams); - const where = and( - eq(clients.practiceId, auth.ctx.practiceId), - isNull(clients.deletedAt) - ); + 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 [rows, countResult] = await Promise.all([ + tx + .select() + .from(clients) + .where(where) + .orderBy(desc(clients.createdAt)) + .limit(limit) + .offset(offset), + tx.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) - ); - }); + 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 index c0fb165..00b303f 100644 --- a/apps/web/app/api/v1/patients/[id]/route.ts +++ b/apps/web/app/api/v1/patients/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { patients } from "@openpims/db"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { withErrorHandling, notFound } from "@/lib/compat/shared/errors"; import { toApiPatient } from "@/lib/compat/openvpm"; @@ -16,20 +17,22 @@ export async function GET( 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) + return withErrorHandling(() => + withTenant(db, auth.ctx.practiceId, async (tx) => { + const [row] = await tx + .select() + .from(patients) + .where( + and( + eq(patients.id, params.id), + eq(patients.practiceId, auth.ctx.practiceId), + isNull(patients.deletedAt) + ) ) - ) - .limit(1); + .limit(1); - if (!row) return notFound("Patient"); - return NextResponse.json({ data: toApiPatient(row) }); - }); + 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 index 202a5e3..6ff2302 100644 --- a/apps/web/app/api/v1/patients/route.ts +++ b/apps/web/app/api/v1/patients/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { db } from "@openpims/db/client"; import { patients } from "@openpims/db"; import { authenticateApiKey } from "@/lib/api-auth"; +import { withTenant } from "@/lib/tenant-db"; import { withErrorHandling } from "@/lib/compat/shared/errors"; import { parsePagination, paginated } from "@/lib/compat/shared/pagination"; import { toApiPatient } from "@/lib/compat/openvpm"; @@ -14,32 +15,34 @@ 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"); + return withErrorHandling(() => + withTenant(db, auth.ctx.practiceId, async (tx) => { + 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 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 [rows, countResult] = await Promise.all([ + tx + .select() + .from(patients) + .where(where) + .orderBy(desc(patients.createdAt)) + .limit(limit) + .offset(offset), + tx.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) - ); - }); + const total = Number(countResult[0]?.count ?? 0); + return NextResponse.json( + paginated(rows.map(toApiPatient), { limit, offset }, total) + ); + }) + ); } diff --git a/apps/web/app/api/webhooks/stripe-subscription/route.ts b/apps/web/app/api/webhooks/stripe-subscription/route.ts new file mode 100644 index 0000000..58b558d --- /dev/null +++ b/apps/web/app/api/webhooks/stripe-subscription/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import type Stripe from "stripe"; +import { db } from "@openpims/db/client"; +import { practices } from "@openpims/db"; +import { constructSubscriptionWebhookEvent } from "@/lib/stripe"; +import { tierForStripePrice, normalizeBillingStatus } from "@/lib/billing/plans"; +import { alertOps } from "@/lib/alerts"; +import { withSystem } from "@/lib/tenant-db"; + +/** + * Stripe webhook for hosted-SaaS subscriptions — a SEPARATE endpoint from the + * client-invoice webhook (different signing secret). Keeps the two Stripe + * surfaces isolated so neither can spoof the other. + */ +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get("stripe-signature"); + if (!signature) { + return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 }); + } + + let event: Stripe.Event | null; + try { + event = await constructSubscriptionWebhookEvent(body, signature); + } catch (err) { + console.error("[Stripe Subscription Webhook] signature verification failed:", err); + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + if (!event) { + return NextResponse.json( + { error: "Webhook verification failed or Stripe not configured" }, + { status: 400 }, + ); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const s = event.data.object as Stripe.Checkout.Session; + const practiceId = s.client_reference_id ?? s.metadata?.practiceId ?? null; + if (practiceId && s.customer) { + await withSystem(db, (tx) => + tx + .update(practices) + .set({ + stripeCustomerId: + typeof s.customer === "string" ? s.customer : s.customer!.id, + stripeSubscriptionId: + typeof s.subscription === "string" + ? s.subscription + : (s.subscription?.id ?? null), + }) + .where(eq(practices.id, practiceId)) + ); + } + break; + } + + case "customer.subscription.created": + case "customer.subscription.updated": { + await applySubscription(event.data.object as Stripe.Subscription); + break; + } + + case "customer.subscription.deleted": { + const sub = event.data.object as Stripe.Subscription; + const practiceId = sub.metadata?.practiceId; + if (practiceId) { + await withSystem(db, (tx) => + tx + .update(practices) + .set({ + subscriptionTier: "free", + billingStatus: "canceled", + stripeSubscriptionId: null, + }) + .where(eq(practices.id, practiceId)) + ); + } + break; + } + + case "invoice.payment_failed": { + const inv = event.data.object as Stripe.Invoice; + const customerId = + typeof inv.customer === "string" ? inv.customer : inv.customer?.id; + if (customerId) { + await withSystem(db, (tx) => + tx + .update(practices) + .set({ billingStatus: "past_due" }) + .where(eq(practices.stripeCustomerId, customerId)) + ); + await alertOps( + "Subscription payment failed", + `Stripe customer ${customerId} had a failed subscription payment; marked past_due.`, + ); + } + break; + } + + default: + // Ignore other event types. + break; + } + + return NextResponse.json({ received: true }); + } catch (err) { + console.error("[Stripe Subscription Webhook] handler error:", err); + await alertOps( + "Subscription webhook handler error", + `Event ${event.type} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return NextResponse.json({ error: "Handler error" }, { status: 500 }); + } +} + +/** Apply a subscription's tier/status/trial to its practice (via metadata.practiceId). */ +async function applySubscription(sub: Stripe.Subscription) { + const practiceId = sub.metadata?.practiceId; + if (!practiceId) { + console.warn("[Stripe Subscription Webhook] subscription without practiceId metadata:", sub.id); + return; + } + const priceId = sub.items?.data?.[0]?.price?.id ?? null; + const tier = tierForStripePrice(priceId); + const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer?.id; + + await withSystem(db, (tx) => + tx + .update(practices) + .set({ + ...(tier ? { subscriptionTier: tier } : {}), + billingStatus: normalizeBillingStatus(sub.status), + stripeSubscriptionId: sub.id, + ...(customerId ? { stripeCustomerId: customerId } : {}), + trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1000) : null, + }) + .where(eq(practices.id, practiceId)) + ); +} diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index b400d9d..641cf05 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -3,6 +3,7 @@ import { eq, and, isNull, sum } from "drizzle-orm"; import { db } from "@openpims/db/client"; import { invoices, payments } from "@openpims/db"; import { constructWebhookEvent } from "@/lib/stripe"; +import { withSystem } from "@/lib/tenant-db"; export async function POST(req: NextRequest) { try { @@ -41,44 +42,47 @@ export async function POST(req: NextRequest) { const amountCents = session.amount_total ?? 0; const amountDollars = (amountCents / 100).toFixed(2); - // Record the payment - await db.insert(payments).values({ - invoiceId, - amount: amountDollars, - method: "online", - notes: "Paid via Stripe Checkout", - }); + // Webhook has no tenant session and only the invoiceId → system context. + await withSystem(db, async (tx) => { + // Record the payment + await tx.insert(payments).values({ + invoiceId, + amount: amountDollars, + method: "online", + notes: "Paid via Stripe Checkout", + }); - // Sum all payments for this invoice - const [result] = await db - .select({ total: sum(payments.amount) }) - .from(payments) - .where( - and( - eq(payments.invoiceId, invoiceId), - isNull(payments.deletedAt), - ), - ); + // Sum all payments for this invoice + const [result] = await tx + .select({ total: sum(payments.amount) }) + .from(payments) + .where( + and( + eq(payments.invoiceId, invoiceId), + isNull(payments.deletedAt), + ), + ); - const paidAmount = result?.total ?? "0"; + const paidAmount = result?.total ?? "0"; - // Get invoice total to check if fully paid - const [invoice] = await db - .select({ total: invoices.total }) - .from(invoices) - .where(eq(invoices.id, invoiceId)); + // Get invoice total to check if fully paid + const [invoice] = await tx + .select({ total: invoices.total }) + .from(invoices) + .where(eq(invoices.id, invoiceId)); - if (invoice) { - const updates: Record = { paidAmount }; - if (parseFloat(paidAmount) >= parseFloat(invoice.total)) { - updates.status = "paid"; - } + if (invoice) { + const updates: Record = { paidAmount }; + if (parseFloat(paidAmount) >= parseFloat(invoice.total)) { + updates.status = "paid"; + } - await db - .update(invoices) - .set(updates) - .where(eq(invoices.id, invoiceId)); - } + await tx + .update(invoices) + .set(updates) + .where(eq(invoices.id, invoiceId)); + } + }); } return NextResponse.json({ received: true }); diff --git a/apps/web/lib/agent/runner.ts b/apps/web/lib/agent/runner.ts index a2fc8c7..00e7745 100644 --- a/apps/web/lib/agent/runner.ts +++ b/apps/web/lib/agent/runner.ts @@ -5,6 +5,7 @@ import { getTool, type AgentToolContext, } from "./tools"; +import { recordUsage } from "@/lib/billing/usage"; const DEFAULT_MODEL = process.env.AGENT_MODEL || "claude-sonnet-4-6"; const MAX_ITERATIONS = 8; @@ -63,6 +64,9 @@ export async function runAgent(opts: { const client = new Anthropic({ apiKey }); const allowWrites = opts.allowWrites ?? false; + // Meter the agent run for hosted billing (no-op on self-host). + void recordUsage({ practiceId: opts.context.practiceId, kind: "ai_run" }); + // 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[] = [ diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts index ea2d771..2fbee45 100644 --- a/apps/web/lib/api-auth.ts +++ b/apps/web/lib/api-auth.ts @@ -3,8 +3,10 @@ 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 { apiKeys, practices } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; +import { billingEnforced, isEntitled, effectiveTier } from "@/lib/billing/plans"; +import { withSystem } from "@/lib/tenant-db"; /** Public prefix for every issued key. Also used as the human-visible label. */ export const API_KEY_PREFIX = "ovpm_"; @@ -81,10 +83,13 @@ export async function authenticateApiKey( let candidates; try { - candidates = await db - .select() - .from(apiKeys) - .where(and(eq(apiKeys.keyPrefix, prefix), isNull(apiKeys.deletedAt))); + // Key lookup spans tenants (we don't know the practice yet) → system context. + candidates = await withSystem(db, (tx) => + tx + .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); @@ -107,6 +112,29 @@ export async function authenticateApiKey( return err(`API key missing required scope: ${requiredScope}`, 403); } + // Public API access is a Pro feature on the hosted service (no-op on self-host). + if (billingEnforced()) { + const [practice] = await withSystem(db, (tx) => + tx + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + }) + .from(practices) + .where(eq(practices.id, matched.practiceId)) + .limit(1) + ); + const tier = effectiveTier( + practice?.tier, + practice?.billingStatus, + practice?.trialEndsAt + ); + if (!isEntitled(tier, "apiAccess", true)) { + return err("API access is not included in your plan. Upgrade to Pro to use the API.", 403); + } + } + const { success, remaining } = rateLimit({ key: `apikey:${matched.id}`, limit: RATE_LIMIT, @@ -130,11 +158,12 @@ export async function authenticateApiKey( } // 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)); + void withSystem(db, (tx) => + tx + .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, diff --git a/apps/web/lib/auth-tokens.ts b/apps/web/lib/auth-tokens.ts new file mode 100644 index 0000000..efa1acf --- /dev/null +++ b/apps/web/lib/auth-tokens.ts @@ -0,0 +1,72 @@ +import { randomBytes, createHash } from "crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { db } from "@openpims/db/client"; +import { authTokens } from "@openpims/db"; +import { withSystem } from "@/lib/tenant-db"; + +export type AuthTokenType = "email_verify" | "password_reset"; + +const TTL_MS: Record = { + email_verify: 24 * 60 * 60 * 1000, // 24h + password_reset: 60 * 60 * 1000, // 1h +}; + +function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +/** + * Issue a single-use token. Returns the RAW token (emailed to the user); only + * its hash is persisted. Pre-tenant flow → system context. + */ +export async function createAuthToken(opts: { + userId: string; + email: string; + type: AuthTokenType; + now?: Date; +}): Promise { + const raw = randomBytes(32).toString("hex"); + const now = opts.now ?? new Date(); + await withSystem(db, (tx) => + tx.insert(authTokens).values({ + userId: opts.userId, + email: opts.email.toLowerCase(), + tokenHash: hashToken(raw), + type: opts.type, + expiresAt: new Date(now.getTime() + TTL_MS[opts.type]), + }) + ); + return raw; +} + +/** + * Validate + consume a token. Returns the matching record (userId/email) if it's + * the right type, unused, and unexpired — and marks it used. Otherwise null. + */ +export async function consumeAuthToken( + raw: string, + type: AuthTokenType, + now: Date = new Date() +): Promise<{ userId: string; email: string } | null> { + const tokenHash = hashToken(raw); + return withSystem(db, async (tx) => { + const [row] = await tx + .select() + .from(authTokens) + .where( + and( + eq(authTokens.tokenHash, tokenHash), + eq(authTokens.type, type), + isNull(authTokens.usedAt), + gt(authTokens.expiresAt, now) + ) + ) + .limit(1); + if (!row) return null; + await tx + .update(authTokens) + .set({ usedAt: now }) + .where(eq(authTokens.id, row.id)); + return { userId: row.userId, email: row.email }; + }); +} diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 06bd32d..5ff9c6f 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -4,6 +4,8 @@ import { compare } from "bcryptjs"; import { db } from "@openpims/db/client"; import { users } from "@openpims/db"; import { eq } from "drizzle-orm"; +import { withSystem } from "@/lib/tenant-db"; +import { billingEnforced } from "@/lib/billing/plans"; declare module "next-auth" { interface Session { @@ -45,17 +47,25 @@ export const authOptions: NextAuthOptions = { return null; } - const [user] = await db - .select() - .from(users) - .where(eq(users.email, credentials.email)) - .limit(1); + // Login looks up by email with no tenant context yet → system context. + const [user] = await withSystem(db, (tx) => + tx + .select() + .from(users) + .where(eq(users.email, credentials.email)) + .limit(1) + ); if (!user) return null; const isValid = await compare(credentials.password, user.passwordHash); if (!isValid) return null; + // On the hosted service, require a verified email before login. + if (billingEnforced() && !user.emailVerifiedAt) { + throw new Error("Please verify your email before signing in."); + } + return { id: user.id, email: user.email, diff --git a/apps/web/lib/billing/__tests__/plans.test.ts b/apps/web/lib/billing/__tests__/plans.test.ts new file mode 100644 index 0000000..85ac2b3 --- /dev/null +++ b/apps/web/lib/billing/__tests__/plans.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + PLANS, + getPlan, + planHasFeature, + isEntitled, + withinSeatLimit, + withinLocationLimit, + isTrialActive, + effectiveTier, + ALL_FEATURES, +} from "../plans"; + +describe("getPlan", () => { + it("returns the matching plan and falls back to free", () => { + expect(getPlan("cloud").tier).toBe("cloud"); + expect(getPlan(null).tier).toBe("free"); + expect(getPlan("nonsense").tier).toBe("free"); + }); + it("maps legacy starter/pro tiers onto cloud", () => { + expect(getPlan("starter").tier).toBe("cloud"); + expect(getPlan("pro").tier).toBe("cloud"); + }); +}); + +describe("planHasFeature (parity)", () => { + it("cloud and enterprise include every feature; free (lapsed/unpaid) includes none", () => { + for (const f of ALL_FEATURES) { + expect(planHasFeature("cloud", f)).toBe(true); + expect(planHasFeature("enterprise", f)).toBe(true); + expect(planHasFeature("pro", f)).toBe(true); // legacy → cloud + expect(planHasFeature("free", f)).toBe(false); + } + }); +}); + +describe("isEntitled", () => { + it("self-host (not enforced) unlocks everything regardless of tier", () => { + expect(isEntitled("free", "agent", false)).toBe(true); + expect(isEntitled(null, "sms", false)).toBe(true); + }); + it("hosted (enforced) gates free/lapsed but allows cloud + enterprise", () => { + expect(isEntitled("free", "agent", true)).toBe(false); + expect(isEntitled("cloud", "agent", true)).toBe(true); + expect(isEntitled("enterprise", "apiAccess", true)).toBe(true); + }); +}); + +describe("seat + location limits", () => { + it("not enforced always passes", () => { + expect(withinSeatLimit("free", 999, false)).toBe(true); + expect(withinLocationLimit("cloud", 999, false)).toBe(true); + }); + it("cloud has unlimited seats + locations (billed by quantity)", () => { + expect(withinSeatLimit("cloud", 100000, true)).toBe(true); + expect(withinLocationLimit("cloud", 50, true)).toBe(true); + }); +}); + +describe("trials", () => { + const now = new Date("2026-06-07T00:00:00Z"); + const future = new Date("2026-06-20T00:00:00Z"); + const past = new Date("2026-06-01T00:00:00Z"); + + it("isTrialActive only when status=trialing and not expired", () => { + expect(isTrialActive("trialing", future, now)).toBe(true); + expect(isTrialActive("trialing", past, now)).toBe(false); + expect(isTrialActive("active", future, now)).toBe(false); + expect(isTrialActive("trialing", null, now)).toBe(false); + }); + + it("effectiveTier grants cloud during an active trial, then reverts to stored tier", () => { + expect(effectiveTier("free", "trialing", future, now)).toBe("cloud"); + expect(effectiveTier("free", "trialing", past, now)).toBe("free"); + expect(effectiveTier("cloud", "active", future, now)).toBe("cloud"); + expect(effectiveTier("pro", "active", future, now)).toBe("cloud"); // legacy → cloud + }); + + it("an active trial unlocks gated features even on the free tier", () => { + const tier = effectiveTier("free", "trialing", future, now); + expect(isEntitled(tier, "agent", true)).toBe(true); + }); +}); + +describe("PLANS pricing", () => { + it("is one simple Cloud tier at $99/location, free self-host, enterprise custom", () => { + expect(PLANS.free.priceMonthlyUsd).toBe(0); + expect(PLANS.cloud.priceMonthlyUsd).toBe(99); + expect(PLANS.cloud.pricePerLocation).toBe(true); + expect(PLANS.enterprise.priceMonthlyUsd).toBeNull(); + }); +}); diff --git a/apps/web/lib/billing/__tests__/usage.test.ts b/apps/web/lib/billing/__tests__/usage.test.ts new file mode 100644 index 0000000..a8f305e --- /dev/null +++ b/apps/web/lib/billing/__tests__/usage.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import { currentPeriodMonth } from "../usage"; + +describe("currentPeriodMonth", () => { + it("formats the billing period as YYYY-MM (UTC)", () => { + expect(currentPeriodMonth(new Date("2026-06-07T12:00:00Z"))).toBe("2026-06"); + expect(currentPeriodMonth(new Date("2026-01-31T23:59:59Z"))).toBe("2026-01"); + expect(currentPeriodMonth(new Date("2026-12-01T00:00:00Z"))).toBe("2026-12"); + }); +}); diff --git a/apps/web/lib/billing/plans.ts b/apps/web/lib/billing/plans.ts new file mode 100644 index 0000000..df72cb2 --- /dev/null +++ b/apps/web/lib/billing/plans.ts @@ -0,0 +1,227 @@ +/** + * Hosted plan model + entitlements. + * + * Open-source posture: the OSS / self-host edition is NEVER crippled. Billing is + * OFF by default (`HOSTED_BILLING_ENABLED` unset) — self-host gets the full + * product. We monetize hosting + scale (locations) + heavy usage, not by locking + * the open core. + * + * Pricing (managed Cloud): ONE simple self-serve tier — $99/mo per location, + * ALL features included (feature parity with self-host), with metered overage + * for SMS / AI usage beyond generous monthly allowances. Enterprise = custom. + */ + +export type PlanTier = "free" | "cloud" | "enterprise"; + +/** + * Legacy tier strings (from the earlier starter/pro model) map onto `cloud`, so + * existing practice rows and any old Stripe prices keep resolving cleanly. + */ +const LEGACY_CLOUD_TIERS = new Set(["starter", "pro"]); + +/** Capabilities. With feature parity these are unlocked on every paid tier. */ +export type Feature = + | "agent" // OpenVPM Agent (AI) + | "sms" // SMS sending + | "advancedReporting" + | "apiAccess" // public /api/v1 + webhooks + | "multiLocation" + | "integrations"; + +export const ALL_FEATURES: Feature[] = [ + "agent", + "sms", + "advancedReporting", + "apiAccess", + "multiLocation", + "integrations", +]; + +/** Env vars holding Stripe Price IDs for metered overage (hosted only, PRIVATE). */ +export const STRIPE_PRICE_SMS_OVERAGE_ENV = "STRIPE_PRICE_SMS_OVERAGE"; +export const STRIPE_PRICE_AI_OVERAGE_ENV = "STRIPE_PRICE_AI_OVERAGE"; + +export interface PlanDefinition { + tier: PlanTier; + name: string; + /** Monthly price in USD (per location for `cloud`). null = custom / contact sales. */ + priceMonthlyUsd: number | null; + /** Whether the monthly price is charged per location (Stripe subscription quantity). */ + pricePerLocation: boolean; + blurb: string; + /** Max staff seats. null = unlimited. */ + seatLimit: number | null; + /** Max locations. null = unlimited (billed by quantity). */ + locationLimit: number | null; + /** Premium features included in this tier. */ + features: Feature[]; + /** Included monthly SMS before metered overage. null = unlimited/custom. */ + includedSmsPerMonth: number | null; + /** Included monthly AI agent runs before metered overage. null = unlimited/custom. */ + includedAiRunsPerMonth: number | null; + /** Env var holding the Stripe Price ID for this tier (hosted only). */ + stripePriceEnv?: string; + /** Whether this tier is self-serve purchasable (vs. contact sales). */ + selfServe: boolean; +} + +export const PLANS: Record = { + free: { + // Self-host (free, full product) gets everything via billingEnforced()=false, + // NOT via this feature list. On hosted, `free` is also the lapsed/unpaid + // fallback tier — so its entitlements MUST be empty to gate non-payers. + tier: "free", + name: "Free (self-host)", + priceMonthlyUsd: 0, + pricePerLocation: false, + blurb: "The full product, on your own infrastructure. Free forever, no lock-in.", + seatLimit: null, + locationLimit: null, + features: [], + includedSmsPerMonth: 0, + includedAiRunsPerMonth: 0, + selfServe: true, + }, + cloud: { + tier: "cloud", + name: "Cloud", + priceMonthlyUsd: 99, + pricePerLocation: true, + blurb: + "We host it: the full PIMS, managed, with every feature — agent, SMS, reporting, API, multi-location, integrations. $99/mo per location.", + seatLimit: null, // unlimited staff included + locationLimit: null, // billed by quantity, not capped + features: [...ALL_FEATURES], + includedSmsPerMonth: 500, + includedAiRunsPerMonth: 200, + stripePriceEnv: "STRIPE_PRICE_CLOUD", + selfServe: true, + }, + enterprise: { + tier: "enterprise", + name: "Enterprise", + priceMonthlyUsd: null, + pricePerLocation: false, + blurb: + "Dedicated or in-region instance, SSO, BAA/DPA compliance, white-glove migration, and priority support.", + seatLimit: null, + locationLimit: null, + features: [...ALL_FEATURES], + includedSmsPerMonth: null, + includedAiRunsPerMonth: null, + selfServe: false, + }, +}; + +export const PLAN_ORDER: PlanTier[] = ["free", "cloud", "enterprise"]; + +export function getPlan(tier?: string | null): PlanDefinition { + const t = tier ?? "free"; + if (LEGACY_CLOUD_TIERS.has(t)) return PLANS.cloud; + return PLANS[t as PlanTier] ?? PLANS.free; +} + +/** Map a Stripe Price ID back to a plan tier (via the configured env vars). */ +export function tierForStripePrice(priceId: string | null | undefined): PlanTier | null { + if (!priceId) return null; + for (const t of PLAN_ORDER) { + const env = PLANS[t].stripePriceEnv; + if (env && process.env[env] === priceId) return t; + } + return null; +} + +/** Normalize a Stripe subscription status to our billingStatus values. */ +export function normalizeBillingStatus(status: string | null | undefined): string { + switch (status) { + case "trialing": + return "trialing"; + case "active": + return "active"; + case "past_due": + case "unpaid": + return "past_due"; + case "canceled": + case "incomplete_expired": + return "canceled"; + default: + return status ?? "none"; + } +} + +/** Pure: does this tier include this feature? (With parity, every paid tier does.) */ +export function planHasFeature(tier: string | null | undefined, feature: Feature): boolean { + return getPlan(tier).features.includes(feature); +} + +/** Length of the new-practice free trial on the hosted service. */ +export const TRIAL_DAYS = 14; + +/** Whether a practice is in an unexpired trial window. */ +export function isTrialActive( + billingStatus: string | null | undefined, + trialEndsAt: Date | string | null | undefined, + now: Date = new Date() +): boolean { + if (billingStatus !== "trialing" || !trialEndsAt) return false; + return new Date(trialEndsAt).getTime() > now.getTime(); +} + +/** + * The tier whose entitlements actually apply right now. An active trial grants + * full Cloud access; once it lapses we fall back to the stored tier. + */ +export function effectiveTier( + tier: string | null | undefined, + billingStatus: string | null | undefined, + trialEndsAt: Date | string | null | undefined, + now: Date = new Date() +): PlanTier { + if (isTrialActive(billingStatus, trialEndsAt, now)) return "cloud"; + const t = tier ?? "free"; + if (LEGACY_CLOUD_TIERS.has(t)) return "cloud"; + return (PLANS[t as PlanTier] ? (t as PlanTier) : "free"); +} + +/** + * Whether hosted billing is enforced (i.e. we're the managed service). Off by + * default so self-host / OSS runs with everything unlocked. + */ +export function billingEnforced(): boolean { + return process.env.HOSTED_BILLING_ENABLED === "true"; +} + +/** + * Effective entitlement check. When billing is not enforced (self-host), every + * feature is entitled. `enforced` is injectable for testing. + */ +export function isEntitled( + tier: string | null | undefined, + feature: Feature, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + return planHasFeature(tier, feature); +} + +/** Seat-limit check. Unlimited (null) or self-host (not enforced) always passes. */ +export function withinSeatLimit( + tier: string | null | undefined, + currentSeats: number, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + const limit = getPlan(tier).seatLimit; + return limit === null || currentSeats < limit; +} + +/** Location-limit check. Unlimited (null) or self-host (not enforced) always passes. */ +export function withinLocationLimit( + tier: string | null | undefined, + currentLocations: number, + enforced: boolean = billingEnforced() +): boolean { + if (!enforced) return true; + const limit = getPlan(tier).locationLimit; + return limit === null || currentLocations < limit; +} diff --git a/apps/web/lib/billing/usage.ts b/apps/web/lib/billing/usage.ts new file mode 100644 index 0000000..11fc1b9 --- /dev/null +++ b/apps/web/lib/billing/usage.ts @@ -0,0 +1,58 @@ +import { and, eq, sql } from "drizzle-orm"; +import { db } from "@openpims/db/client"; +import { usageRecords } from "@openpims/db"; +import { withSystem } from "@/lib/tenant-db"; +import { billingEnforced } from "./plans"; + +export type UsageKind = "sms" | "ai_run"; + +/** Current billing period as YYYY-MM (UTC). */ +export function currentPeriodMonth(now: Date = new Date()): string { + return now.toISOString().slice(0, 7); +} + +/** + * Record a metered usage event. No-op on self-host (billing not enforced). + * Fire-and-forget safe: never throws into the caller. + */ +export async function recordUsage(opts: { + practiceId: string; + kind: UsageKind; + quantity?: number; + now?: Date; +}): Promise { + if (!billingEnforced()) return; // self-host never meters + try { + await withSystem(db, (tx) => + tx.insert(usageRecords).values({ + practiceId: opts.practiceId, + kind: opts.kind, + quantity: opts.quantity ?? 1, + periodMonth: currentPeriodMonth(opts.now), + }) + ); + } catch (e) { + console.error("[usage] failed to record", opts.kind, e); + } +} + +/** Sum a practice's usage of one kind in a period (defaults to current month). */ +export async function usageForPractice( + practiceId: string, + kind: UsageKind, + periodMonth: string = currentPeriodMonth() +): Promise { + const [row] = await withSystem(db, (tx) => + tx + .select({ total: sql`coalesce(sum(${usageRecords.quantity}), 0)::int` }) + .from(usageRecords) + .where( + and( + eq(usageRecords.practiceId, practiceId), + eq(usageRecords.kind, kind), + eq(usageRecords.periodMonth, periodMonth) + ) + ) + ); + return Number(row?.total ?? 0); +} diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts index 110a886..3662e7f 100644 --- a/apps/web/lib/email.ts +++ b/apps/web/lib/email.ts @@ -271,3 +271,47 @@ export async function sendInvoiceEmail(data: { return { success: result.success }; } + +// --------------------------------------------------------------------------- +// Account: email verification + password reset (hosted auth) +// --------------------------------------------------------------------------- + +export async function sendVerificationEmail(data: { + to: string; + name: string; + verifyUrl: string; +}): Promise<{ success: boolean }> { + const body = ` +

Hi ${data.name},

+

Welcome to OpenVPM! Please confirm your email address to activate your account and start your free trial.

+ ${ctaButton("Verify my email", data.verifyUrl)} +

This link expires in 24 hours. If you didn't create an OpenVPM account, you can ignore this email.

+ `; + const html = emailLayout("OpenVPM", body); + const result = await sendEmail({ + to: data.to, + subject: "Verify your OpenVPM email", + html, + }); + return { success: result.success }; +} + +export async function sendPasswordResetEmail(data: { + to: string; + name: string; + resetUrl: string; +}): Promise<{ success: boolean }> { + const body = ` +

Hi ${data.name},

+

We received a request to reset your OpenVPM password. Click below to choose a new one.

+ ${ctaButton("Reset my password", data.resetUrl)} +

This link expires in 1 hour. If you didn't request a password reset, you can safely ignore this email — your password won't change.

+ `; + const html = emailLayout("OpenVPM", body); + const result = await sendEmail({ + to: data.to, + subject: "Reset your OpenVPM password", + html, + }); + return { success: result.success }; +} diff --git a/apps/web/lib/onboarding/defaults.ts b/apps/web/lib/onboarding/defaults.ts index 75406fa..2d8f4c4 100644 --- a/apps/web/lib/onboarding/defaults.ts +++ b/apps/web/lib/onboarding/defaults.ts @@ -1,5 +1,12 @@ import type { Database } from "@openpims/db/client"; -import { appointmentTypes, rooms, services } from "@openpims/db"; +import { + appointmentTypes, + rooms, + services, + clients, + patients, + appointments, +} from "@openpims/db"; /** * Sensible defaults seeded for a brand-new practice so it's usable immediately @@ -96,3 +103,61 @@ export async function seedPractice( })) ); } + +export interface DemoDataIds { + clientIds: string[]; + patientIds: string[]; + appointmentIds: string[]; +} + +/** + * Seed a small set of demo clients/patients/appointments so a hosted trial + * lands on a lively dashboard instead of empty states. The returned IDs are + * stored on the practice so the onboarding wizard can clear them with one click. + * Call only on hosted trials; non-fatal. + */ +export async function seedDemoData( + db: Database, + opts: { practiceId: string } +): Promise { + const insertedClients = await db + .insert(clients) + .values([ + { practiceId: opts.practiceId, firstName: "Jordan", lastName: "Avery", email: "jordan.avery@example.com", phone: "(555) 200-1001" }, + { practiceId: opts.practiceId, firstName: "Sam", lastName: "Rivera", email: "sam.rivera@example.com", phone: "(555) 200-1002" }, + { practiceId: opts.practiceId, firstName: "Taylor", lastName: "Brooks", email: "taylor.brooks@example.com", phone: "(555) 200-1003" }, + ]) + .returning({ id: clients.id }); + + const insertedPatients = await db + .insert(patients) + .values([ + { practiceId: opts.practiceId, clientId: insertedClients[0]!.id, name: "Biscuit", species: "canine" as const, sex: "male_neutered" as const, breed: "Golden Retriever" }, + { practiceId: opts.practiceId, clientId: insertedClients[1]!.id, name: "Luna", species: "feline" as const, sex: "female_spayed" as const, breed: "Domestic Shorthair" }, + { practiceId: opts.practiceId, clientId: insertedClients[2]!.id, name: "Mango", species: "avian" as const, breed: "Sun Conure" }, + ]) + .returning({ id: patients.id }); + + const now = Date.now(); + const mkAppt = (clientIdx: number, patientIdx: number, offsetHours: number) => { + const start = new Date(now + offsetHours * 60 * 60 * 1000); + const end = new Date(start.getTime() + 30 * 60 * 1000); + return { + practiceId: opts.practiceId, + clientId: insertedClients[clientIdx]!.id, + patientId: insertedPatients[patientIdx]!.id, + startTime: start, + endTime: end, + }; + }; + const insertedAppts = await db + .insert(appointments) + .values([mkAppt(0, 0, 26), mkAppt(1, 1, 50)]) + .returning({ id: appointments.id }); + + return { + clientIds: insertedClients.map((c) => c.id), + patientIds: insertedPatients.map((p) => p.id), + appointmentIds: insertedAppts.map((a) => a.id), + }; +} diff --git a/apps/web/lib/platform-admin.ts b/apps/web/lib/platform-admin.ts new file mode 100644 index 0000000..3592e97 --- /dev/null +++ b/apps/web/lib/platform-admin.ts @@ -0,0 +1,17 @@ +/** + * Platform (operator) admin — distinct from a practice's own "admin" role. These + * are OpenVPM staff who can see the cross-tenant operations dashboard. Gated by + * an env allowlist so there's no in-app way to escalate into it. + */ + +export function platformAdminEmails(): string[] { + return (process.env.PLATFORM_ADMIN_EMAILS ?? "") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); +} + +export function isPlatformAdmin(email?: string | null): boolean { + if (!email) return false; + return platformAdminEmails().includes(email.toLowerCase()); +} diff --git a/apps/web/lib/sms.ts b/apps/web/lib/sms.ts index 9bd7d91..ffc0a82 100644 --- a/apps/web/lib/sms.ts +++ b/apps/web/lib/sms.ts @@ -1,4 +1,5 @@ import Twilio from "twilio"; +import { recordUsage } from "@/lib/billing/usage"; // --------------------------------------------------------------------------- // Twilio client – initialised lazily so the module can be imported even when @@ -27,6 +28,8 @@ function getFromNumber(): string { export async function sendSms(options: { to: string; body: string; + /** When set (and a real send occurs), meters the SMS for hosted billing. */ + practiceId?: string; }): Promise<{ success: boolean; sid?: string; error?: string }> { const client = getTwilio(); @@ -47,6 +50,11 @@ export async function sendSms(options: { body: options.body, }); + // Meter the real send for hosted billing (no-op on self-host). + if (options.practiceId) { + void recordUsage({ practiceId: options.practiceId, kind: "sms" }); + } + return { success: true, sid: message.sid }; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown SMS error"; diff --git a/apps/web/lib/stripe.ts b/apps/web/lib/stripe.ts index ee6d0f3..8e6902f 100644 --- a/apps/web/lib/stripe.ts +++ b/apps/web/lib/stripe.ts @@ -49,4 +49,66 @@ export async function constructWebhookEvent( ); } +// ── Hosted-SaaS subscriptions (separate surface from client invoicing) ────── + +/** + * Create a Checkout Session for a recurring plan subscription. The practiceId is + * stamped on both the session and the subscription metadata so the webhook can + * map the resulting subscription back to a practice. + */ +export async function createSubscriptionCheckoutSession(data: { + priceId: string; + practiceId: string; + customerId?: string | null; + customerEmail?: string | null; + successUrl: string; + cancelUrl: string; + /** Billed quantity — e.g. number of locations for a per-location plan. */ + quantity?: number; +}): Promise<{ url: string | null } | null> { + if (!stripe) { + console.log("[Stripe] No API key configured, skipping subscription checkout"); + return null; + } + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: data.priceId, quantity: Math.max(1, data.quantity ?? 1) }], + ...(data.customerId + ? { customer: data.customerId } + : { customer_email: data.customerEmail ?? undefined }), + client_reference_id: data.practiceId, + metadata: { practiceId: data.practiceId }, + subscription_data: { metadata: { practiceId: data.practiceId } }, + success_url: data.successUrl, + cancel_url: data.cancelUrl, + }); + return { url: session.url }; +} + +/** Create a Stripe Billing Portal session so a practice can manage its plan. */ +export async function createBillingPortalSession(data: { + customerId: string; + returnUrl: string; +}): Promise<{ url: string } | null> { + if (!stripe) return null; + const session = await stripe.billingPortal.sessions.create({ + customer: data.customerId, + return_url: data.returnUrl, + }); + return { url: session.url }; +} + +/** Verify a subscription-webhook signature using its dedicated endpoint secret. */ +export async function constructSubscriptionWebhookEvent( + body: string, + signature: string, +): Promise { + if (!stripe) return null; + return stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_SUBSCRIPTION_WEBHOOK_SECRET!, + ); +} + export { stripe }; diff --git a/apps/web/lib/tenant-db.ts b/apps/web/lib/tenant-db.ts new file mode 100644 index 0000000..bb5c42d --- /dev/null +++ b/apps/web/lib/tenant-db.ts @@ -0,0 +1,43 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "@openpims/db/client"; + +/** + * Tenant database context for Postgres Row-Level Security (defense-in-depth + * behind the app-layer practiceId filters). + * + * RLS policies key off the `app.current_practice_id` GUC. Because postgres-js + * pools connections, the GUC must be set on the SAME connection that runs the + * queries — so we set it inside a transaction (set_config(..., true) = local to + * the tx) and hand the transaction handle to the callback. + * + * On the owner DB role (used in dev/self-host) RLS is bypassed, so these + * wrappers are a harmless no-op there. Enforcement activates when the app + * connects as the least-privilege role (see packages/db/rls/enable-rls.sql). + */ +export async function withTenant( + db: Database, + practiceId: string, + fn: (tx: Database) => Promise +): Promise { + return db.transaction(async (tx) => { + await tx.execute( + sql`select set_config('app.current_practice_id', ${practiceId}, true)` + ); + return fn(tx as unknown as Database); + }); +} + +/** + * System context that bypasses tenant RLS — for cross-tenant operations + * (platform admin, cron sweeps) and pre-tenant flows (registration, login, + * subscription webhooks that look up a practice by Stripe id). + */ +export async function withSystem( + db: Database, + fn: (tx: Database) => Promise +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`select set_config('app.rls_bypass', 'on', true)`); + return fn(tx as unknown as Database); + }); +} diff --git a/apps/web/lib/webhook-dispatcher.ts b/apps/web/lib/webhook-dispatcher.ts index c71ad77..99699cb 100644 --- a/apps/web/lib/webhook-dispatcher.ts +++ b/apps/web/lib/webhook-dispatcher.ts @@ -3,6 +3,7 @@ import { eq, and, isNull } from "drizzle-orm"; import { db } from "@openpims/db/client"; import { webhooks } from "@openpims/db"; import { alertOps } from "@/lib/alerts"; +import { withTenant } from "@/lib/tenant-db"; export async function dispatchWebhookEvent( practiceId: string, @@ -11,16 +12,19 @@ export async function dispatchWebhookEvent( ): Promise { let activeWebhooks; try { - activeWebhooks = await db - .select() - .from(webhooks) - .where( - and( - eq(webhooks.practiceId, practiceId), - eq(webhooks.active, true), - isNull(webhooks.deletedAt), + // Tenant-scoped read (works under RLS regardless of the caller's context). + activeWebhooks = await withTenant(db, practiceId, (tx) => + tx + .select() + .from(webhooks) + .where( + and( + eq(webhooks.practiceId, practiceId), + eq(webhooks.active, true), + isNull(webhooks.deletedAt), + ), ), - ); + ); } catch (err) { console.error("[WebhookDispatcher] Failed to query webhooks:", err); return; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 98f1b1e..fc3bfd9 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -30,6 +30,6 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - "/((?!login|register|api/auth|_next|favicon.ico|api/trpc|portal|api-docs|api/portal|api/webhooks|api/cron).*)", + "/((?!login|register|verify-email|forgot-password|reset-password|api/auth|_next|favicon.ico|api/trpc|portal|api-docs|api/portal|api/webhooks|api/cron|api/health).*)", ], }; diff --git a/apps/web/server/__tests__/guards.test.ts b/apps/web/server/__tests__/guards.test.ts index 25b7841..f2389fa 100644 --- a/apps/web/server/__tests__/guards.test.ts +++ b/apps/web/server/__tests__/guards.test.ts @@ -15,7 +15,15 @@ function callerFor(role: string) { practiceId: "00000000-0000-0000-0000-0000000000aa", }, }; - return appRouter.createCaller({ db: {} as never, session } as never); + // Minimal db mock: transaction() runs its callback with the same object and + // execute() is a no-op (for the RLS set_config call). Any real table access + // (.select/.insert/...) is undefined → throws, so a db-free resolver passing + // proves the guard let it through, and FORBIDDEN proves it short-circuited. + const db: Record = { + transaction: async (fn: (tx: unknown) => unknown) => fn(db), + execute: async () => undefined, + }; + return appRouter.createCaller({ db, session } as never); } describe("viewer read-only guard", () => { diff --git a/apps/web/server/routers/_app.ts b/apps/web/server/routers/_app.ts index e818bf8..4ab0355 100644 --- a/apps/web/server/routers/_app.ts +++ b/apps/web/server/routers/_app.ts @@ -26,6 +26,8 @@ import { agentRouter } from "./agent"; import { treatmentPlansRouter } from "./treatment-plans"; import { wellnessRouter } from "./wellness"; import { waitlistRouter } from "./waitlist"; +import { subscriptionRouter } from "./subscription"; +import { adminRouter } from "./admin"; export const appRouter = createRouter({ auth: authRouter, @@ -55,6 +57,8 @@ export const appRouter = createRouter({ treatmentPlans: treatmentPlansRouter, wellness: wellnessRouter, waitlist: waitlistRouter, + subscription: subscriptionRouter, + admin: adminRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/web/server/routers/admin.ts b/apps/web/server/routers/admin.ts new file mode 100644 index 0000000..8525f43 --- /dev/null +++ b/apps/web/server/routers/admin.ts @@ -0,0 +1,100 @@ +import { isNull, sql, desc } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure } from "../trpc"; +import { db } from "@openpims/db/client"; +import { practices, users, clients, patients } from "@openpims/db"; +import { isPlatformAdmin } from "@/lib/platform-admin"; +import { getPlan, type PlanTier } from "@/lib/billing/plans"; +import { withSystem } from "@/lib/tenant-db"; + +/** + * Platform-operator only. Crosses tenant boundaries deliberately, so it is + * gated by the PLATFORM_ADMIN_EMAILS allowlist (not the practice "admin" role). + */ +const platformAdminProcedure = protectedProcedure.use(async ({ ctx, next }) => { + if (!isPlatformAdmin(ctx.session?.user?.email)) { + throw new TRPCError({ code: "FORBIDDEN", message: "Platform admin access only." }); + } + return next(); +}); + +export const adminRouter = createRouter({ + /** Am I a platform admin? (drives whether the /admin nav shows.) */ + isPlatformAdmin: protectedProcedure.query(({ ctx }) => { + return isPlatformAdmin(ctx.session?.user?.email); + }), + + /** Cross-tenant operations overview: practices, plans, status, usage, MRR. */ + overview: platformAdminProcedure.query(async () => + // Bypass tenant RLS — this view legitimately spans all practices. + withSystem(db, async (tx) => { + const rows = await tx + .select({ + id: practices.id, + name: practices.name, + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + country: practices.country, + createdAt: practices.createdAt, + }) + .from(practices) + .where(isNull(practices.deletedAt)) + .orderBy(desc(practices.createdAt)); + + const countBy = async ( + table: typeof users | typeof clients | typeof patients + ) => { + const res = await tx + .select({ + practiceId: table.practiceId, + c: sql`count(*)::int`, + }) + .from(table) + .where(isNull(table.deletedAt)) + .groupBy(table.practiceId); + return new Map(res.map((r) => [r.practiceId, Number(r.c)])); + }; + + const [userCounts, clientCounts, patientCounts] = await Promise.all([ + countBy(users), + countBy(clients), + countBy(patients), + ]); + + const practiceRows = rows.map((p) => ({ + ...p, + userCount: userCounts.get(p.id) ?? 0, + clientCount: clientCounts.get(p.id) ?? 0, + patientCount: patientCounts.get(p.id) ?? 0, + })); + + // MRR: sum list price of practices on a paid, active subscription. + const estimatedMrr = practiceRows + .filter((p) => p.billingStatus === "active") + .reduce((sum, p) => sum + (getPlan(p.tier).priceMonthlyUsd ?? 0), 0); + + const byTier: Record = { + free: 0, + cloud: 0, + enterprise: 0, + }; + for (const p of practiceRows) { + const t = getPlan(p.tier).tier; + byTier[t] += 1; + } + + return { + practices: practiceRows, + totals: { + practices: practiceRows.length, + estimatedMrr, + byTier, + trialing: practiceRows.filter((p) => p.billingStatus === "trialing").length, + active: practiceRows.filter((p) => p.billingStatus === "active").length, + pastDue: practiceRows.filter((p) => p.billingStatus === "past_due").length, + }, + }; + }) + ), +}); diff --git a/apps/web/server/routers/agent.ts b/apps/web/server/routers/agent.ts index c67ea71..ccc6320 100644 --- a/apps/web/server/routers/agent.ts +++ b/apps/web/server/routers/agent.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { createRouter, protectedProcedure, requireRole, requireFeature } from "../trpc"; import { runAgent, isAgentConfigured, @@ -8,9 +8,10 @@ import { AgentNotConfiguredError, } from "@/lib/agent"; -const agentProcedure = protectedProcedure.use( - requireRole("admin", "veterinarian") -); +// The OpenVPM Agent is a Pro feature on hosted; unrestricted on self-host. +const agentProcedure = protectedProcedure + .use(requireRole("admin", "veterinarian")) + .use(requireFeature("agent")); export const agentRouter = createRouter({ /** Whether the agent is enabled (API key present) and what it can do. */ diff --git a/apps/web/server/routers/auth.ts b/apps/web/server/routers/auth.ts index 92af189..8bc7aa9 100644 --- a/apps/web/server/routers/auth.ts +++ b/apps/web/server/routers/auth.ts @@ -5,7 +5,18 @@ import { TRPCError } from "@trpc/server"; import { createRouter, publicProcedure, protectedProcedure } from "../trpc"; import { users, practices, locations } from "@openpims/db"; import { rateLimit } from "@/lib/rate-limit"; -import { seedPractice } from "@/lib/onboarding/defaults"; +import { seedPractice, seedDemoData } from "@/lib/onboarding/defaults"; +import { billingEnforced, TRIAL_DAYS } from "@/lib/billing/plans"; +import { createAuthToken, consumeAuthToken } from "@/lib/auth-tokens"; +import { sendVerificationEmail, sendPasswordResetEmail } from "@/lib/email"; + +function appBaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.NEXTAUTH_URL ?? + "http://localhost:3000" + ); +} export const authRouter = createRouter({ register: publicProcedure @@ -45,11 +56,21 @@ export const authRouter = createRouter({ const passwordHash = await hash(input.password, 10); + // On the hosted service, start a full-featured trial so the new practice + // is immediately usable before entering payment. Self-host ignores this. + const trial = billingEnforced() + ? { + billingStatus: "trialing" as const, + trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000), + } + : {}; + // Create practice const [practice] = await ctx.db .insert(practices) .values({ name: input.practiceName, + ...trial, }) .returning(); @@ -87,7 +108,129 @@ export const authRouter = createRouter({ console.error("[register] practice seeding failed:", err); } - return { id: user!.id, email: user!.email }; + // On the hosted service, seed demo data + start onboarding so the trial + // lands on a lively dashboard. Non-fatal. Self-host skips this. + if (billingEnforced()) { + try { + const demoData = await seedDemoData(ctx.db, { practiceId: practice!.id }); + await ctx.db + .update(practices) + .set({ + settings: { demoData, onboardingCompletedAt: null }, + }) + .where(eq(practices.id, practice!.id)); + } catch (err) { + console.error("[register] demo seeding failed:", err); + } + } + + // On the hosted service, require email verification before login. Issue a + // token + send the email. Non-fatal: signup still succeeds. Self-host + // skips this (frictionless). + let verificationRequired = false; + if (billingEnforced()) { + verificationRequired = true; + try { + const token = await createAuthToken({ + userId: user!.id, + email: user!.email, + type: "email_verify", + }); + await sendVerificationEmail({ + to: user!.email, + name: user!.name, + verifyUrl: `${appBaseUrl()}/verify-email?token=${token}`, + }); + } catch (err) { + console.error("[register] verification email failed:", err); + } + } + + return { id: user!.id, email: user!.email, verificationRequired }; + }), + + /** Confirm an email-verification token (hosted). */ + verifyEmail: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const result = await consumeAuthToken(input.token, "email_verify"); + if (!result) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This verification link is invalid or has expired.", + }); + } + await ctx.db + .update(users) + .set({ emailVerifiedAt: new Date() }) + .where(eq(users.id, result.userId)); + return { ok: true }; + }), + + /** Request a password-reset email. Always succeeds (no account enumeration). */ + requestPasswordReset: publicProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + const { success } = rateLimit({ + key: `pwreset:${input.email}`, + limit: 5, + windowMs: 3600000, + }); + if (!success) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Too many requests. Please try again later.", + }); + } + + const [user] = await ctx.db + .select({ id: users.id, email: users.email, name: users.name }) + .from(users) + .where(eq(users.email, input.email)) + .limit(1); + + if (user) { + try { + const token = await createAuthToken({ + userId: user.id, + email: user.email, + type: "password_reset", + }); + await sendPasswordResetEmail({ + to: user.email, + name: user.name, + resetUrl: `${appBaseUrl()}/reset-password?token=${token}`, + }); + } catch (err) { + console.error("[requestPasswordReset] email failed:", err); + } + } + // Generic response regardless of whether the email exists. + return { ok: true }; + }), + + /** Complete a password reset with a valid token. */ + resetPassword: publicProcedure + .input( + z.object({ + token: z.string().min(1), + password: z.string().min(8), + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await consumeAuthToken(input.token, "password_reset"); + if (!result) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This reset link is invalid or has expired.", + }); + } + const passwordHash = await hash(input.password, 10); + await ctx.db + .update(users) + .set({ passwordHash }) + .where(eq(users.id, result.userId)); + return { ok: true }; }), me: protectedProcedure.query(async ({ ctx }) => { diff --git a/apps/web/server/routers/reports.ts b/apps/web/server/routers/reports.ts index beeb01d..f15042b 100644 --- a/apps/web/server/routers/reports.ts +++ b/apps/web/server/routers/reports.ts @@ -1,5 +1,5 @@ import { eq, and, gte, lte, isNull, sql, desc } from "drizzle-orm"; -import { createRouter, protectedProcedure } from "../trpc"; +import { createRouter, protectedProcedure, requireFeature } from "../trpc"; import { invoices, invoiceItems, @@ -9,8 +9,11 @@ import { users, } from "@openpims/db"; +// Advanced reporting is a Pro feature on hosted; unrestricted on self-host. +const reportProcedure = protectedProcedure.use(requireFeature("advancedReporting")); + export const reportsRouter = createRouter({ - revenue: protectedProcedure.query(async ({ ctx }) => { + revenue: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); @@ -70,7 +73,7 @@ export const reportsRouter = createRouter({ }; }), - appointments: protectedProcedure.query(async ({ ctx }) => { + appointments: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); @@ -136,7 +139,7 @@ export const reportsRouter = createRouter({ }; }), - topServices: protectedProcedure.query(async ({ ctx }) => { + topServices: reportProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ name: sql`coalesce(${services.name}, ${invoiceItems.description})`, @@ -170,7 +173,7 @@ export const reportsRouter = createRouter({ })); }), - inventoryAlerts: protectedProcedure.query(async ({ ctx }) => { + inventoryAlerts: reportProcedure.query(async ({ ctx }) => { const now = new Date(); const ninetyDaysFromNow = new Date(now); ninetyDaysFromNow.setDate(ninetyDaysFromNow.getDate() + 90); diff --git a/apps/web/server/routers/settings.ts b/apps/web/server/routers/settings.ts index 4536d44..1dab4be 100644 --- a/apps/web/server/routers/settings.ts +++ b/apps/web/server/routers/settings.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, isNull, inArray } from "drizzle-orm"; import { hash } from "bcryptjs"; import { createRouter, protectedProcedure, requireRole } from "../trpc"; import { @@ -7,11 +7,20 @@ import { users, appointmentTypes, rooms, + clients, + patients, + appointments, } from "@openpims/db"; import { regionDefaults } from "@/lib/locale/format"; const adminProcedure = protectedProcedure.use(requireRole("admin")); +interface PracticeSettings { + onboardingCompletedAt?: string | null; + demoData?: { clientIds: string[]; patientIds: string[]; appointmentIds: string[] }; + [k: string]: unknown; +} + export const settingsRouter = createRouter({ // ── Practice ────────────────────────────────────────────── @@ -66,6 +75,90 @@ export const settingsRouter = createRouter({ return updated!; }), + // ── Onboarding ──────────────────────────────────────────── + + /** Onboarding state for the first-run wizard / dashboard banner. */ + onboardingStatus: adminProcedure.query(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + return { + completedAt: settings.onboardingCompletedAt ?? null, + hasDemoData: !!settings.demoData, + }; + }), + + /** Mark onboarding complete. */ + completeOnboarding: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + await ctx.db + .update(practices) + .set({ settings: { ...settings, onboardingCompletedAt: new Date().toISOString() } }) + .where(eq(practices.id, ctx.practiceId)); + return { ok: true }; + }), + + /** Remove the seeded demo clients/patients/appointments (soft delete). */ + clearDemoData: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ settings: practices.settings }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + const settings = (practice?.settings ?? {}) as PracticeSettings; + const demo = settings.demoData; + if (demo) { + const now = new Date(); + if (demo.appointmentIds?.length) { + await ctx.db + .update(appointments) + .set({ deletedAt: now }) + .where( + and( + eq(appointments.practiceId, ctx.practiceId), + inArray(appointments.id, demo.appointmentIds) + ) + ); + } + if (demo.patientIds?.length) { + await ctx.db + .update(patients) + .set({ deletedAt: now }) + .where( + and( + eq(patients.practiceId, ctx.practiceId), + inArray(patients.id, demo.patientIds) + ) + ); + } + if (demo.clientIds?.length) { + await ctx.db + .update(clients) + .set({ deletedAt: now }) + .where( + and( + eq(clients.practiceId, ctx.practiceId), + inArray(clients.id, demo.clientIds) + ) + ); + } + } + const { demoData: _omit, ...rest } = settings; + await ctx.db + .update(practices) + .set({ settings: rest }) + .where(eq(practices.id, ctx.practiceId)); + return { ok: true }; + }), + // ── Staff / Users ───────────────────────────────────────── listUsers: adminProcedure.query(async ({ ctx }) => { diff --git a/apps/web/server/routers/subscription.ts b/apps/web/server/routers/subscription.ts new file mode 100644 index 0000000..b0cff86 --- /dev/null +++ b/apps/web/server/routers/subscription.ts @@ -0,0 +1,170 @@ +import { z } from "zod"; +import { eq, and, isNull, sql } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { createRouter, protectedProcedure, requireRole } from "../trpc"; +import { practices, locations } from "@openpims/db"; +import type { Database } from "@openpims/db/client"; +import { + createSubscriptionCheckoutSession, + createBillingPortalSession, +} from "@/lib/stripe"; +import { + PLANS, + PLAN_ORDER, + billingEnforced, +} from "@/lib/billing/plans"; +import { usageForPractice, currentPeriodMonth } from "@/lib/billing/usage"; + +/** Count active locations for a practice (the billed quantity for Cloud). */ +async function countLocations(db: Database, practiceId: string): Promise { + const [row] = await db + .select({ c: sql`count(*)::int` }) + .from(locations) + .where(and(eq(locations.practiceId, practiceId), isNull(locations.deletedAt))); + return Math.max(1, Number(row?.c ?? 1)); +} + +const adminProcedure = protectedProcedure.use(requireRole("admin")); + +function appBaseUrl(): string { + return ( + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.NEXTAUTH_URL ?? + "http://localhost:3000" + ); +} + +/** Whether a tier can be bought self-serve (Stripe price configured). */ +function purchasable(tier: keyof typeof PLANS): boolean { + const env = PLANS[tier].stripePriceEnv; + return !!(env && process.env[env]); +} + +export const subscriptionRouter = createRouter({ + /** Current plan + status, plus the catalog for display. */ + get: adminProcedure.query(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + stripeCustomerId: practices.stripeCustomerId, + }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + const enforced = billingEnforced(); + const locationCount = await countLocations(ctx.db, ctx.practiceId); + const period = currentPeriodMonth(); + const [smsUsed, aiUsed] = enforced + ? await Promise.all([ + usageForPractice(ctx.practiceId, "sms", period), + usageForPractice(ctx.practiceId, "ai_run", period), + ]) + : [0, 0]; + + return { + tier: practice?.tier ?? "free", + billingStatus: practice?.billingStatus ?? "none", + trialEndsAt: practice?.trialEndsAt ?? null, + hasBillingAccount: !!practice?.stripeCustomerId, + billingEnforced: enforced, + locationCount, + usage: { period, sms: smsUsed, aiRuns: aiUsed }, + plans: PLAN_ORDER.map((t) => { + const p = PLANS[t]; + return { + tier: p.tier, + name: p.name, + priceMonthlyUsd: p.priceMonthlyUsd, + pricePerLocation: p.pricePerLocation, + blurb: p.blurb, + features: p.features, + seatLimit: p.seatLimit, + locationLimit: p.locationLimit, + includedSmsPerMonth: p.includedSmsPerMonth, + includedAiRunsPerMonth: p.includedAiRunsPerMonth, + selfServe: p.selfServe, + purchasable: purchasable(t), + }; + }), + }; + }), + + /** Start a Stripe Checkout for the self-serve Cloud plan. */ + createCheckout: adminProcedure + .input(z.object({ tier: z.enum(["cloud"]).default("cloud") })) + .mutation(async ({ ctx, input }) => { + const plan = PLANS[input.tier]; + const priceId = plan.stripePriceEnv + ? process.env[plan.stripePriceEnv] + : undefined; + if (!priceId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This plan isn't available for checkout yet.", + }); + } + + const [practice] = await ctx.db + .select({ + stripeCustomerId: practices.stripeCustomerId, + email: practices.email, + }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + // Cloud is billed per location → Stripe subscription quantity. + const quantity = plan.pricePerLocation + ? await countLocations(ctx.db, ctx.practiceId) + : 1; + + const base = appBaseUrl(); + const result = await createSubscriptionCheckoutSession({ + priceId, + practiceId: ctx.practiceId, + customerId: practice?.stripeCustomerId ?? undefined, + customerEmail: practice?.email ?? ctx.session.user.email, + quantity, + successUrl: `${base}/settings?tab=billing&checkout=success`, + cancelUrl: `${base}/settings?tab=billing&checkout=cancelled`, + }); + if (!result) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Billing is not configured on this server.", + }); + } + return { url: result.url }; + }), + + /** Open the Stripe Billing Portal to manage/cancel an existing subscription. */ + openBillingPortal: adminProcedure.mutation(async ({ ctx }) => { + const [practice] = await ctx.db + .select({ stripeCustomerId: practices.stripeCustomerId }) + .from(practices) + .where(eq(practices.id, ctx.practiceId)) + .limit(1); + + if (!practice?.stripeCustomerId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "No billing account yet — start a plan first.", + }); + } + + const result = await createBillingPortalSession({ + customerId: practice.stripeCustomerId, + returnUrl: `${appBaseUrl()}/settings?tab=billing`, + }); + if (!result) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Billing is not configured on this server.", + }); + } + return { url: result.url }; + }), +}); diff --git a/apps/web/server/trpc.ts b/apps/web/server/trpc.ts index 9c22462..56d3041 100644 --- a/apps/web/server/trpc.ts +++ b/apps/web/server/trpc.ts @@ -3,10 +3,19 @@ import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import superjson from "superjson"; import { ZodError } from "zod"; +import { eq } from "drizzle-orm"; import { authOptions } from "@/lib/auth"; import { recordAuditLog } from "@/lib/audit"; import { db } from "@openpims/db/client"; import type { Database } from "@openpims/db/client"; +import { withTenant, withSystem } from "@/lib/tenant-db"; +import { practices } from "@openpims/db"; +import { + billingEnforced, + isEntitled, + effectiveTier, + type Feature, +} from "@/lib/billing/plans"; type UserRole = | "admin" @@ -60,7 +69,17 @@ const t = initTRPC.context().create({ }); export const createRouter = t.router; -export const publicProcedure = t.procedure; + +/** + * Public / pre-auth endpoints (registration, the token-based client portal). + * They have no tenant session and do their own scoping (tokens, email, rate + * limits), so they run in a system DB context that bypasses tenant RLS. + */ +export const publicProcedure = t.procedure.use(async ({ ctx, next }) => { + return withSystem(ctx.db, (tx) => + next({ ctx: { ...ctx, db: tx } }) + ); +}); /** Requires an authenticated session */ export const protectedProcedure = t.procedure.use( @@ -78,28 +97,79 @@ export const protectedProcedure = t.procedure.use( } const user = ctx.session.user; - const result = await next({ - ctx: { session: ctx.session, user, practiceId: user.practiceId }, - }); - - // Audit every successful mutation: who changed what, when, from where. - // Fire-and-forget — never block or fail the request on the audit write. - if (type === "mutation" && result.ok) { - const rawInput = await getRawInput().catch(() => undefined); - void recordAuditLog(ctx.db, { - practiceId: user.practiceId, - userId: user.id, - ip: ctx.ip, - path, - rawInput, - resultData: (result as { data?: unknown }).data, + // Run the whole request in a tenant DB context so Postgres RLS scopes every + // query to this practice (defense-in-depth behind the app-layer filters). + return withTenant(ctx.db, user.practiceId, async (tx) => { + const result = await next({ + ctx: { session: ctx.session, user, practiceId: user.practiceId, db: tx }, }); - } - return result; + // Audit every successful mutation: who changed what, when, from where. + // Runs in its own system-context tx so it's independent of this request's + // transaction lifecycle and never blocks or fails the request. + if (type === "mutation" && result.ok) { + const rawInput = await getRawInput().catch(() => undefined); + void withSystem(db, (sysTx) => + recordAuditLog(sysTx, { + practiceId: user.practiceId, + userId: user.id, + ip: ctx.ip, + path, + rawInput, + resultData: (result as { data?: unknown }).data, + }) + ).catch(() => {}); + } + + return result; + }); } ); +/** + * Requires the practice's plan to include a premium feature. + * + * No-op on self-host: when HOSTED_BILLING_ENABLED is unset, billingEnforced() + * is false and this allows everything (and skips the DB lookup entirely), so + * the OSS edition is never gated. Only the managed hosted service enforces it. + */ +export function requireFeature(feature: Feature) { + return t.middleware(async ({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + if (billingEnforced()) { + const [practice] = await ctx.db + .select({ + tier: practices.subscriptionTier, + billingStatus: practices.billingStatus, + trialEndsAt: practices.trialEndsAt, + }) + .from(practices) + .where(eq(practices.id, ctx.session.user.practiceId)) + .limit(1); + const tier = effectiveTier( + practice?.tier, + practice?.billingStatus, + practice?.trialEndsAt + ); + if (!isEntitled(tier, feature, true)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Your plan doesn't include this feature. Upgrade to unlock it.`, + }); + } + } + return next({ + ctx: { + session: ctx.session, + user: ctx.session.user, + practiceId: ctx.session.user.practiceId, + }, + }); + }); +} + /** Requires specific roles */ export function requireRole(...roles: UserRole[]) { return t.middleware(async ({ ctx, next }) => { diff --git a/docs/security/row-level-security.md b/docs/security/row-level-security.md new file mode 100644 index 0000000..8581989 --- /dev/null +++ b/docs/security/row-level-security.md @@ -0,0 +1,83 @@ +# Row-Level Security (RLS) + +OpenVPM enforces multi-tenant isolation in two layers: + +1. **Application layer** — every query is scoped by `ctx.practiceId` (tRPC) or + the API key's practice. This is the primary guard and works on any database. +2. **Database layer (RLS)** — Postgres policies that independently reject any row + whose `practice_id` doesn't match the active tenant context. Defense in depth: + even a query that forgets its `practiceId` filter returns nothing. + +## How it works + +Policies key off a per-transaction GUC, `app.current_practice_id`, set by the +app (`apps/web/lib/tenant-db.ts`): + +- `withTenant(db, practiceId, fn)` — opens a transaction, sets the tenant GUC, + runs `fn` on that transaction. Used by `protectedProcedure` so the whole + authenticated request is tenant-scoped. +- `withSystem(db, fn)` — sets `app.rls_bypass = on` for legitimately + cross-tenant or pre-tenant work (login, registration, the client portal, + platform admin, the API-key lookup). + +The policy on every tenant table is: + +```sql +USING (app_rls_bypass() OR practice_id = app_current_practice_id()) +WITH CHECK (app_rls_bypass() OR practice_id = app_current_practice_id()) +``` + +With no context set, the GUC is NULL → the policy denies by default. + +## The owner-bypass model (why dev/self-host is unaffected) + +We deliberately do **not** use `FORCE ROW LEVEL SECURITY`. The table **owner** +bypasses RLS, so: + +- Migrations, `pnpm db:push`, `pnpm db:seed`, and **dev / self-host** (which + connect as the owner `openpims`) are completely unaffected — RLS is a no-op. +- Enforcement turns on only when the app connects as the **least-privilege role** + `openpims_app` (created by the migration), which is subject to RLS. + +This means RLS is safe to ship: it adds protection for the restricted role +without any risk to the default configuration. + +## Applying it + +```bash +# Set a strong password for the app role; the script creates the role if missing +# (no credential is stored in the repo) and applies the policies + grants. +OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls # run as the DB owner +pnpm db:rls:test # live verification: proves cross-tenant isolation +``` + +`db:rls:test` checks, against a real database as `openpims_app`: a tenant sees +only its own rows, cross-tenant INSERT is rejected, no-context queries return +nothing, and the system bypass sees everything. + +## Activating enforcement in production + +1. `OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls` against the production database + (creates the `openpims_app` role with that password — or rotates it if it + already exists — then applies policies + grants). +2. Point the hosted `DATABASE_URL` at the `openpims_app` role. + +### Entrypoint coverage (all wired) + +Every DB access path now sets a tenant or system context, so the app runs +correctly under the enforcing `openpims_app` role: + +- tRPC `protectedProcedure` → `withTenant`; `publicProcedure` + login → `withSystem`; + platform admin → `withSystem`; API-key auth lookup → `withSystem`. +- `app/api/v1/*` data queries → `withTenant(auth.ctx.practiceId, ...)`. +- `app/api/cron/*` (reminders, backup) → broad reads in `withSystem`, per-practice + writes/exports in `withTenant`. +- `app/api/webhooks/*` (client + subscription) and `lib/webhook-dispatcher.ts` → + `withSystem` / `withTenant(practiceId)`. +- `app/api/upload` → `withTenant(session.practiceId, ...)`; + `app/api/portal/checkout` → `withSystem`. + +These are no-ops on the owner connection (dev/self-host). To activate enforcement +in production: run `OPENPIMS_APP_DB_PASSWORD='' pnpm db:rls` and point +`DATABASE_URL` at the `openpims_app` role (Phase 5 infra). Re-run `pnpm db:rls:test` against +staging to confirm isolation under the restricted role. diff --git a/package.json b/package.json index 20fd147..78a091f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "db:migrate": "pnpm --filter @openpims/db db:migrate", "db:seed": "pnpm --filter @openpims/db db:seed", "db:reset": "pnpm --filter @openpims/db db:reset", + "db:rls": "pnpm --filter @openpims/db db:rls", "db:studio": "pnpm --filter @openpims/db db:studio", "clean": "turbo clean", "test:e2e": "playwright test", diff --git a/packages/db/apply-rls.ts b/packages/db/apply-rls.ts new file mode 100644 index 0000000..4382a3c --- /dev/null +++ b/packages/db/apply-rls.ts @@ -0,0 +1,49 @@ +import { config } from "dotenv"; +config({ path: "../../.env" }); + +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import postgres from "postgres"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL not set"); + process.exit(1); +} + +const here = dirname(fileURLToPath(import.meta.url)); +const sqlText = readFileSync(join(here, "rls", "enable-rls.sql"), "utf8"); + +const sql = postgres(url, { max: 1 }); +try { + // Ensure the least-privilege app role exists BEFORE the grants run. No + // credential is committed: the password comes from OPENPIMS_APP_DB_PASSWORD. + const [exists] = await sql`select 1 from pg_roles where rolname = 'openpims_app'`; + const appPw = process.env.OPENPIMS_APP_DB_PASSWORD; + if (!exists) { + if (!appPw) { + console.error( + "openpims_app role does not exist. Create it with a strong password, " + + "or set OPENPIMS_APP_DB_PASSWORD and re-run `pnpm db:rls`." + ); + process.exit(1); + } + const q = appPw.replace(/'/g, "''"); // escape single quotes + await sql.unsafe(`CREATE ROLE openpims_app LOGIN PASSWORD '${q}'`); + console.log("✓ created openpims_app role"); + } else if (appPw) { + const q = appPw.replace(/'/g, "''"); + await sql.unsafe(`ALTER ROLE openpims_app PASSWORD '${q}'`); + console.log("✓ rotated openpims_app password"); + } + + // Simple protocol so the multi-statement / DO-block migration runs as one. + await sql.unsafe(sqlText).simple(); + console.log("✓ RLS policies applied (run as the DB owner)."); +} catch (err) { + console.error("✗ Failed to apply RLS policies:", err); + process.exitCode = 1; +} finally { + await sql.end(); +} diff --git a/packages/db/package.json b/packages/db/package.json index 9e2e3b6..98b0d6d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,6 +13,8 @@ "db:migrate": "drizzle-kit migrate", "db:seed": "tsx seed.ts", "db:reset": "tsx reset.ts", + "db:rls": "tsx apply-rls.ts", + "db:rls:test": "tsx test-rls.ts", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/packages/db/rls/enable-rls.sql b/packages/db/rls/enable-rls.sql new file mode 100644 index 0000000..29d5c7a --- /dev/null +++ b/packages/db/rls/enable-rls.sql @@ -0,0 +1,69 @@ +-- OpenVPM — Postgres Row-Level Security (defense-in-depth multi-tenant isolation) +-- ============================================================================ +-- These policies are a SECOND guard behind the app-layer practiceId filters. +-- They key off the `app.current_practice_id` GUC the app sets per request +-- (see apps/web/lib/tenant-db.ts: withTenant / withSystem). +-- +-- The table OWNER bypasses RLS (we do NOT use FORCE), so: +-- • Migrations + dev/self-host on the owner connection are unaffected. +-- • Enforcement activates when the app connects as the least-privilege role +-- `openpims_app`, which you point the hosted DATABASE_URL at. +-- +-- Apply with: pnpm db:rls (idempotent — safe to re-run after schema changes) +-- +-- ROLE CREATION: this file contains NO credentials. The `openpims_app` role is +-- created/managed by the apply script (packages/db/apply-rls.ts) using the +-- OPENPIMS_APP_DB_PASSWORD env var, or you create the role yourself beforehand. +-- The grants below assume the role already exists. +-- ============================================================================ + +-- 1) Grants for the least-privilege application role (must already exist). +GRANT USAGE ON SCHEMA public TO openpims_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO openpims_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO openpims_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO openpims_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO openpims_app; + +-- 2) Context helpers (NULL/false when the GUC is unset → deny by default). +CREATE OR REPLACE FUNCTION app_current_practice_id() RETURNS uuid + LANGUAGE sql STABLE AS +$fn$ SELECT nullif(current_setting('app.current_practice_id', true), '')::uuid $fn$; + +CREATE OR REPLACE FUNCTION app_rls_bypass() RETURNS boolean + LANGUAGE sql STABLE AS +$fn$ SELECT coalesce(current_setting('app.rls_bypass', true), '') = 'on' $fn$; + +-- 3) The practices root table is keyed on its own id. +ALTER TABLE practices ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON practices; +CREATE POLICY tenant_isolation ON practices + USING (app_rls_bypass() OR id = app_current_practice_id()) + WITH CHECK (app_rls_bypass() OR id = app_current_practice_id()); + +-- 4) Every practice_id-scoped table gets the same policy. +DO $$ +DECLARE + t text; + tbls text[] := array[ + 'api_keys','appointment_types','appointment_waitlist','appointments','audit_log', + 'cases','clients','clinical_notes','communications','controlled_substance_log', + 'files','insurance_claims','insurance_policies','invoices','lab_results','locations', + 'patients','prescriptions','problem_list','procedures','products','purchase_orders', + 'recurring_series','rooms','services','soap_notes','staff_schedules','suppliers', + 'treatment_plans','treatment_templates','usage_records','users','vaccination_records', + 'vital_signs','webhooks','wellness_enrollments','wellness_plans' + ]; +BEGIN + FOREACH t IN ARRAY tbls LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', t); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I ' + 'USING (app_rls_bypass() OR practice_id = app_current_practice_id()) ' + 'WITH CHECK (app_rls_bypass() OR practice_id = app_current_practice_id())', + t + ); + END LOOP; +END$$; diff --git a/packages/db/schema/auth-tokens.ts b/packages/db/schema/auth-tokens.ts new file mode 100644 index 0000000..0b7dfa5 --- /dev/null +++ b/packages/db/schema/auth-tokens.ts @@ -0,0 +1,29 @@ +import { pgTable, uuid, varchar, timestamp, index } from "drizzle-orm/pg-core"; +import { baseColumns } from "./common"; +import { users } from "./users"; + +/** + * Single-use, expiring tokens for email verification + password reset. Only the + * SHA-256 hash of the token is stored (never the raw value). These are + * pre-tenant / cross-tenant by nature (a user may be locked out), so they are + * NOT under RLS and are accessed via withSystem. + */ +export const authTokens = pgTable( + "auth_tokens", + { + ...baseColumns(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + email: varchar("email", { length: 255 }).notNull(), + /** SHA-256 hex of the raw token. */ + tokenHash: varchar("token_hash", { length: 64 }).notNull(), + /** "email_verify" | "password_reset" */ + type: varchar("type", { length: 24 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + }, + (t) => ({ + tokenHashIdx: index("auth_tokens_hash_idx").on(t.tokenHash), + }) +); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 4279504..ed1f239 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -13,3 +13,5 @@ export * from "./files"; export * from "./templates"; export * from "./insurance"; export * from "./wellness"; +export * from "./usage"; +export * from "./auth-tokens"; diff --git a/packages/db/schema/practices.ts b/packages/db/schema/practices.ts index a0d8ec4..8b38838 100644 --- a/packages/db/schema/practices.ts +++ b/packages/db/schema/practices.ts @@ -21,7 +21,14 @@ export const practices = pgTable("practices", { timezone: varchar("timezone", { length: 64 }).notNull().default("America/New_York"), logoUrl: varchar("logo_url", { length: 512 }), settings: jsonb("settings").default({}), - subscriptionTier: varchar("subscription_tier", { length: 32 }).default("free"), + // Hosted-SaaS subscription (ignored by self-host unless HOSTED_BILLING_ENABLED). + // subscriptionTier is the canonical plan tier: free | starter | pro | enterprise. + subscriptionTier: varchar("subscription_tier", { length: 32 }).notNull().default("free"), + stripeCustomerId: varchar("stripe_customer_id", { length: 64 }), + stripeSubscriptionId: varchar("stripe_subscription_id", { length: 64 }), + // Stripe-style billing lifecycle: none | trialing | active | past_due | canceled. + billingStatus: varchar("billing_status", { length: 24 }).notNull().default("none"), + trialEndsAt: timestamp("trial_ends_at", { withTimezone: true }), // Region/locale — gates currency, tax, formatting, and (later) regulatory // behavior. Defaults keep existing US practices working unchanged. country: varchar("country", { length: 2 }).notNull().default("US"), // ISO 3166-1 alpha-2 diff --git a/packages/db/schema/usage.ts b/packages/db/schema/usage.ts new file mode 100644 index 0000000..b245335 --- /dev/null +++ b/packages/db/schema/usage.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, varchar, integer, index } from "drizzle-orm/pg-core"; +import { baseColumns } from "./common"; +import { practices } from "./practices"; + +/** + * Metered usage events for the hosted service (SMS sends, AI agent runs). Rolled + * up per billing period to charge overage beyond a plan's included allowance. + * Practice-scoped → covered by RLS (see packages/db/rls/enable-rls.sql). + * Only written when HOSTED_BILLING_ENABLED — self-host never meters. + */ +export const usageRecords = pgTable( + "usage_records", + { + ...baseColumns(), + practiceId: uuid("practice_id") + .notNull() + .references(() => practices.id), + /** "sms" | "ai_run" */ + kind: varchar("kind", { length: 16 }).notNull(), + quantity: integer("quantity").notNull().default(1), + /** Billing period as YYYY-MM (UTC). */ + periodMonth: varchar("period_month", { length: 7 }).notNull(), + }, + (t) => ({ + practicePeriodIdx: index("usage_practice_period_idx").on( + t.practiceId, + t.periodMonth + ), + }) +); diff --git a/packages/db/test-rls.ts b/packages/db/test-rls.ts new file mode 100644 index 0000000..fea63cc --- /dev/null +++ b/packages/db/test-rls.ts @@ -0,0 +1,89 @@ +/** + * Live Row-Level Security verification. Connects as the least-privilege + * `openpims_app` role and proves tenant isolation against a real database. + * + * Run with: pnpm db:rls:test (requires the DB up + pnpm db:rls applied) + */ +import { config } from "dotenv"; +config({ path: "../../.env" }); + +import postgres from "postgres"; +import { randomUUID } from "crypto"; + +const ownerUrl = process.env.DATABASE_URL; +if (!ownerUrl) { + console.error("DATABASE_URL not set"); + process.exit(1); +} +// Derive the restricted-role URL by swapping the credentials. +const appUrl = ownerUrl.replace(/\/\/[^:]+:[^@]+@/, "//openpims_app:openpims_app@"); + +const owner = postgres(ownerUrl, { max: 1 }); +const app = postgres(appUrl, { max: 1 }); + +const aId = randomUUID(); +const bId = randomUUID(); +let failures = 0; +function check(name: string, ok: boolean) { + console.log(` ${ok ? "✓" : "✗"} ${name}`); + if (!ok) failures++; +} + +try { + // Arrange (as owner — bypasses RLS). + await owner`insert into practices (id, name) values (${aId}, 'RLS Test A'), (${bId}, 'RLS Test B')`; + await owner`insert into clients (practice_id, first_name, last_name) values + (${aId}, 'Alice', 'A'), (${bId}, 'Bob', 'B')`; + + // Tenant A context sees only A's rows. + const aRows = await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${aId}, true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("tenant A sees only A's clients", aRows.length === 1 && aRows[0]!.practice_id === aId); + + // Tenant B context sees only B's rows. + const bRows = await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${bId}, true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("tenant B sees only B's clients", bRows.length === 1 && bRows[0]!.practice_id === bId); + + // Cross-tenant WRITE is rejected by the WITH CHECK clause. + let writeBlocked = false; + try { + await app.begin(async (tx) => { + await tx`select set_config('app.current_practice_id', ${aId}, true)`; + await tx`insert into clients (practice_id, first_name, last_name) values (${bId}, 'Evil', 'X')`; + }); + } catch { + writeBlocked = true; + } + check("cross-tenant INSERT is blocked", writeBlocked); + + // No tenant context → deny by default. + const noneRows = await app`select practice_id from clients where practice_id in (${aId}, ${bId})`; + check("no tenant context → zero rows", noneRows.length === 0); + + // System bypass sees both (for cron / platform admin). + const allRows = await app.begin(async (tx) => { + await tx`select set_config('app.rls_bypass', 'on', true)`; + return tx`select practice_id from clients where practice_id in (${aId}, ${bId})`; + }); + check("system bypass sees both practices", allRows.length === 2); +} catch (err) { + console.error("Unexpected error:", err); + failures++; +} finally { + // Cleanup (as owner). + await owner`delete from clients where practice_id in (${aId}, ${bId})`; + await owner`delete from practices where id in (${aId}, ${bId})`; + await owner.end(); + await app.end(); +} + +if (failures > 0) { + console.error(`\n✗ ${failures} RLS check(s) FAILED`); + process.exit(1); +} +console.log("\n✓ All RLS isolation checks passed.");