Portal-Access Seat Caps & Storage Caps Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add hard caps on (a) patient portal accounts and (b) R2 storage per clinic, with monthly recurring addons requiring superadmin approval; replace the legacy patient-quota.ts / PATIENT_ADDON:-in-licenseRequests kludge with a clean addon_requests ledger.
Architecture: Single source of truth for plan limits in server/src/lib/plan-limits.ts. Per-tenant counters live on the clinics table (portalSeatAddon, storageQuotaBytes, storageAddonBytes, storageUsedBytes). Portal access becomes an explicit patients.portalAccess boolean (backfilled from userId IS NOT NULL). Storage is tracked transactionally with atomic UPDATE ... SET col = col + delta. Addon requests use a dedicated addon_requests table that doubles as the active-addons ledger (status='approved' AND cancelledAt IS NULL).
Tech Stack: Hono + Drizzle + Postgres (Neon), Cloudflare Workers + R2, React 19 + Vite UI, Vitest for tests.
Spec: docs/superpowers/specs/2026-05-13-portal-seats-storage-addons.md
Context & gotchas an outside engineer needs to know
- Legacy module being replaced:
server/src/lib/patient-quota.tsenforces an old model withtotalPatientLimit(records cap) +includedPortalPatients(portal-within-records cap). The new spec makes records uncapped and portal seats the only cap. Old callers inserver/src/routes/billing.ts(syncLicenseFromPlan,calculatePatientQuotaUsage) andui/src/components/settings/AdminBillingSettings.tsxmust be migrated. - Legacy addon storage: Existing patient-portal addons are stored in
license_requeststable withreasonfield prefixedPATIENT_ADDON:<json>. Phase 9 migrates these intoaddon_requests. - Drizzle migrations: Migrations live in
server/drizzle/NNNN_<name>.sql. Apply withnpm run db:migrate(callsscripts/run-drizzle-migrations.ts). Next number is0033. - Schema in DB is
app.*: All tables are in theappPostgres schema. Migrations and queries must useapp.<table>. - R2Service:
server/src/lib/r2.tsis the only upload entrypoint. Gating happens via a thin wrapper, not by modifying R2Service itself. - Tests:
vitestinserver/. Run withnpm testfromserver/dir. Frontend has no automated tests; UI work verified by manual smoke + type check (npm run typecheckinui/). - Auth: All clinic routes mounted under
protectedRoutes(seeserver/src/api.ts) already havedualAuthMiddleware+clinicContextMiddleware. Don’t re-apply. - Permissions: Registered in
server/src/lib/permissions.tsPERMISSION_KEYSarray. Middleware:requirePermission('key')fromserver/src/middleware/permissions.ts. - Superadmin auth: Lives in
server/src/routes/superadmin/under/api/v1/protected/superadmin/*withrequireSuperadminmiddleware. - Audit logs: Use
recordAuditLog()fromserver/src/lib/audit-helper.ts. Shape:{ clinicId, userId, action, entityType, entityId, changes }. - Email:
sendEmailViaZepto(recipients, subject, html, opts)fromserver/src/lib/email.ts. Templates inserver/src/emails/as React components rendered via@react-email/render.
Phase 1: Foundation (data model + constants)
Subagent assignment: 1 subagent end-to-end. Blocks all other phases.
Task 1.1: Centralized plan limits config
Files:-
Create:
server/src/lib/plan-limits.ts -
Test:
server/src/lib/plan-limits.test.ts - Step 1: Write failing test
server/src/lib/plan-limits.test.ts:
- Step 2: Run test, verify fail
cd server && npx vitest run src/lib/plan-limits.test.ts
Expected: cannot find module.
- Step 3: Implement module
server/src/lib/plan-limits.ts:
- Step 4: Run test, verify pass
cd server && npx vitest run src/lib/plan-limits.test.ts
Expected: 8 passed.
- Step 5: Commit
Task 1.2: SQL migration — clinics columns, patients.portalAccess, addon_requests
Files:-
Create:
server/drizzle/0033_portal_seats_storage_addons.sql - Step 1: Write migration
server/drizzle/0033_portal_seats_storage_addons.sql:
- Step 2: Apply locally and verify
IF NOT EXISTS guards).
- Step 3: Sanity-check schema in Postgres
- Step 4: Commit
Task 1.2b: Addon pricing config table
Files:-
Modify:
server/drizzle/0033_portal_seats_storage_addons.sql(append to same migration) - Step 1: Append to the migration
0033_portal_seats_storage_addons.sql:
- Step 2: Re-apply migration
current).
- Step 3: Commit
Note for implementer: If migration 0033 was already pushed to a shared/remote branch, do NOT amend. Instead, create migration 0034_addon_pricing.sql containing only the addon_pricing tables.
Task 1.3: Drizzle schema updates
Files:-
Modify:
server/src/schema/clinics.ts -
Modify:
server/src/schema/patients.ts -
Create:
server/src/schema/addon_requests.ts -
Modify:
server/src/schema/index.ts - Step 1: Update clinics schema
server/src/schema/clinics.ts, add to the imports if missing:
clinics table definition (anywhere after referralPayoutMode), add:
- Step 2: Update patients schema
server/src/schema/patients.ts, replace the import line with:
portalAccess field after status:
(table) => { return { ... } } part), add:
- Step 3: Create addon_requests schema
server/src/schema/addon_requests.ts:
- Step 4: Update schema barrel
server/src/schema/index.ts, add near the other admin/billing exports:
server/src/schema/addon_pricing.ts:
- Step 5: Type-check
- Step 6: Commit
Phase 2: Server core (storage tracker + portal toggle)
Subagent assignment: 1 subagent. Depends on Phase 1.
Task 2.1: Storage tracker module
Files:-
Create:
server/src/lib/storage-tracker.ts -
Test:
server/src/lib/storage-tracker.test.ts - Step 1: Write failing test
server/src/lib/storage-tracker.test.ts:
- Step 2: Run test — fail
cd server && npx vitest run src/lib/storage-tracker.test.ts
Expected: cannot find module.
- Step 3: Implement
server/src/lib/storage-tracker.ts:
- Step 4: Run tests — pass
cd server && npx vitest run src/lib/storage-tracker.test.ts
Expected: 5 passed.
- Step 5: Commit
Task 2.2: Trial helper
Files:-
Create:
server/src/lib/trial-status.ts -
Test:
server/src/lib/trial-status.test.ts - Step 1: Write test
- Step 2: Implement
server/src/lib/trial-status.ts:
- Step 3: Run tests, commit
Task 2.3: Wire checkUploadAllowed into all R2 upload routes
Files (modify each):
server/src/routes/patient-files.tsserver/src/routes/signatures.ts(if separate; else inline in users.ts)server/src/routes/prescriptions.tsserver/src/routes/letterhead.ts(or wherever clinic doc uploads live)server/src/routes/lab-cases.tsserver/src/routes/insurance-claims.tsserver/src/routes/branding.ts(clinic logo/favicon)server/src/routes/dicom.ts(if exists)server/src/routes/messages.ts(attachment uploads)
For each route file: find every r2.uploadFile(...) call and wrap with the check + record pattern below.
- Step 1: Identify all upload sites
- Step 2: For each upload site, apply this pattern
await r2.uploadFile(...):
uploadFile:
Subscription plan name/tier must be available. If the existing handler doesn’t load them, joinsubscription_plansinto the clinic lookup, e.g.:
- Step 3: Type-check each modified route
cd server && npx tsc --noEmit
Expected: 0 errors.
- Step 4: Verify with a manual integration test
npm run dev, upload one X-ray, query SELECT storage_used_bytes FROM app.clinics WHERE id = '<your-test-clinic>' — should equal the file size.
- Step 5: Commit once all upload sites are gated
Task 2.4: Portal-access toggle endpoint
Files:-
Modify:
server/src/routes/patients.ts(addPATCH /:id/portal-access) -
Modify:
server/src/routes/patients.ts(patient create — respectportalAccessbody field) - Step 1: Write integration test (skip if no integration test infrastructure; rely on smoke)
- Step 2: Add seat-counting helper
server/src/lib/portal-seats.ts (new file):
server/src/lib/portal-seats.test.ts:
- Step 3: Add the PATCH endpoint
server/src/routes/patients.ts, register:
- Step 4: Update patient-create handler
POST / in server/src/routes/patients.ts. When the body has portalAccess: true, perform the same seat-cap check before inserting. Set portalAccess: body.portalAccess === true on the new patient row. If portalAccess=true AND no user row exists, also provision one (reuse existing patient-user creation code path; do not duplicate).
- Step 5: Type-check, smoke, commit
Phase 3: Addon request flow
Subagent assignment: 1 subagent. Depends on Phase 1.
Task 3.1: Pricing calculator helper (DB-backed)
Files:- Create:
server/src/lib/addon-pricing.ts - Test:
server/src/lib/addon-pricing.test.ts
Note: The calculator reads live prices fromapp.addon_pricing(row id ='current'). The constants inplan-limits.ADDON_PRICINGbecome fallback defaults only (used when DB read fails). Pricing snapshotted onto eachaddon_requestsrow at request creation time; subsequent price changes don’t affect pending/approved requests.
- Step 1: Test
- Step 2: Implement
server/src/lib/addon-pricing.ts:
Test update needed: Update the test in step 1 to passpricingin eachcalculateAddonPricecall (the unit test no longer needs DB).
- Step 3: Run + commit
Task 3.2: Clinic-facing addon-request routes
Files:-
Create:
server/src/routes/addon-requests.ts -
Modify:
server/src/api.ts(mount new router) - Step 1: Build the router
server/src/routes/addon-requests.ts:
- Step 2: Add superadmin-alerts helper
server/src/lib/superadmin-alerts.ts, append:
alertUpgradeRequest in this same file and mirror its shape. Save the email subject as New addon request: <Pro / Pro+> · <type> x<qty> · PKR <total>.
- Step 3: Mount router
server/src/api.ts, find where billing route is mounted and add:
- Step 4: Type-check, commit
Task 3.3: Superadmin approval API
Files:-
Create:
server/src/routes/superadmin/addon-requests.ts -
Modify:
server/src/api.ts -
Create:
server/src/emails/AddonApprovedEmail.tsx -
Create:
server/src/emails/AddonRejectedEmail.tsx - Step 1: Build the router
server/src/routes/superadmin/addon-requests.ts:
- Step 2: Email templates
server/src/emails/AddonApprovedEmail.tsx:
server/src/emails/AddonRejectedEmail.tsx: same shape with a reason prop and copy explaining the rejection.
- Step 3: Mount router
server/src/api.ts, find superadmin routes section, add:
Check existing mount pattern — adapt to whatever superadmin uses. Likely a single superadminProtected.route('/addons', superadminAddonRoute).
- Step 4: Type-check, commit
Task 3.3b: Superadmin pricing-management endpoints
Files:-
Create:
server/src/routes/superadmin/addon-pricing.ts -
Modify:
server/src/api.ts(mount) - Step 1: Build the router
server/src/routes/superadmin/addon-pricing.ts:
IfrecordAuditLogdoesn’t acceptclinicId: null, either pass a sentinel like'platform'or extend the helper. Use whatever pattern the existing platform-wide audit logs (e.g.cron_jobs,platform_settings) use.
- Step 2: Mount the router
server/src/api.ts superadmin section:
- Step 3: Type-check, commit
Task 3.4: Permission keys + usage endpoint
Files:-
Modify:
server/src/lib/permissions.ts -
Modify:
server/src/routes/billing.ts(or createserver/src/routes/billing-usage.ts) - Step 1: Register new permissions
server/src/lib/permissions.ts, find the PERMISSION_KEYS array and add (in the billing section):
admin and deny to receptionist. The file already has a DEFAULT_ROLE_PERMISSIONS (or similar) shape — add the key in the admin list.
- Step 2: Build usage endpoint
server/src/routes/billing.ts (add at the bottom before export default billingRoute):
Note: Check thatusers.lastLoginAtexists inserver/src/schema/users.ts. If not, the MAU stat returns 0 — that’s acceptable for v1. Add a TODO comment to instrumentlast_login_atseparately.
- Step 3: Type-check, commit
Phase 4: Replace legacy patient-quota module
Subagent assignment: 1 subagent. Depends on Phase 1, 3.
Task 4.1: Migrate billing.ts callers off patient-quota.ts
Files:-
Modify:
server/src/routes/billing.ts -
Modify:
server/src/lib/patient-quota.ts(delete or replace) - Step 1: Find every call site
- Step 2: For each call, replace with new portal-seats/plan-limits APIs
server/src/routes/billing.ts:
getLicenseLimitsForPlanalready exists and is correct — leave it.syncLicenseFromPlancallsresolvePatientQuotaRulesto derivemaxPatientsforlicenses.maxPatients. Replace this solicenses.maxPatientsis set to null (records are unlimited under the new model). The license-check middleware that gates on this value also needs adjustment — find callers oflicenses.maxPatientsand stop enforcing it for new tenants.- Any code that reads
PATIENT_ADDON:prefix fromlicenseRequests.reasonbecomes legacy. Leave it for historical display but do NOT process new ones; new addons useaddon_requests.
syncLicenseFromPlan (around line 164–195), change:
- Step 3: Remove patient-quota.ts
plan-limits / portal-seats / addon-pricing APIs.
- Step 4: Type-check, commit
Task 4.2: Data migration for legacy PATIENT_ADDON: license_requests
Files:
-
Create:
server/scripts/migrate-patient-addons.ts - Step 1: Write script
- Step 2: Dry-run locally
- Step 3: Commit script (do not run on prod yet)
Phase 5: Clinic-facing UI
Subagent assignment: 1–3 subagents (these tasks can run in parallel after Phase 1–4 land). Each touches different files.
Task 5.1: useUsage hook + types
Files:
-
Create:
ui/src/hooks/use-usage.ts - Step 1: Implement
- Step 2: Commit
Task 5.2: Patient form — “Provide portal access” checkbox
Files:-
Modify:
ui/src/components/patients/PatientForm.tsx(or equivalent) -
Create:
ui/src/components/patients/PortalAccessField.tsx -
Create:
ui/src/components/patients/PortalAccessHelpDrawer.tsx - Step 1: Locate the patient form
- Step 2: Build the field component
ui/src/components/patients/PortalAccessField.tsx:
- Step 3: Build the help drawer
ui/src/components/patients/PortalAccessHelpDrawer.tsx:
- Step 4: Wire into PatientForm
PatientForm.tsx), find the form-state shape, add portalAccess: boolean (default false), and render <PortalAccessField value={form.portalAccess} onChange={...} editingPatientId={patient?.id} /> near the contact info block. On submit, include portalAccess in the POST/PATCH body.
For editing, if the user flips from true → false, surface a confirm modal first (“Revoke portal access? This will sign the patient out of id.odontox.io. Their records stay.”).
- Step 5: Commit
Task 5.3: Bulk import overflow handling
Files:-
Modify:
ui/src/components/superadmin/DataImportManager.tsx -
Modify:
server/src/routes/superadmin/import.ts(or wherever the import endpoint lives) - Step 1: Find the import endpoint
- Step 2: Update the import server to honor
portal_accesscolumn
- If column
provide_portal_access(orportal_access) equalsyes/true/1, setportalAccess: trueon the insert. - Before inserting the batch, run a cap pre-check: count current
portal_access=trueplus rows in this batch withyes. If over the cap, downgrade the overflow rows in batch order toportalAccess: falseand collect them into anotEnrolledlist returned to the client.
- Step 3: Update DataImportManager UI
notEnrolled.length > 0, render a callout:
downloadCsv(rows, name) as a small helper if not already present.
- Step 4: Commit
Task 5.4: AdminBillingSettings — usage meters + addon request widgets
Files:-
Modify:
ui/src/components/settings/AdminBillingSettings.tsx -
Create:
ui/src/components/settings/StorageRequestCard.tsx -
Create:
ui/src/components/settings/PortalSeatRequestCard.tsx -
Create:
ui/src/components/settings/UsageMeters.tsx -
Create:
ui/src/components/settings/RecentAddonRequests.tsx - Step 1: Remove old patient-quota import
AdminBillingSettings.tsx, remove the import of calculatePatientQuotaUsage / resolvePatientQuotaRules and the hardcoded limits map. Replace them with useUsage().
- Step 2: Build
UsageMeters.tsx
- Step 3: Build
StorageRequestCard.tsx
LivePricing shape). Add this endpoint inside server/src/routes/billing.ts:
- Step 4: Build
PortalSeatRequestCard.tsx
- Step 5: Build
RecentAddonRequests.tsx
- Step 6: Wire into
AdminBillingSettings
- Step 7: Type-check, commit
Task 5.5: Storage-full block component
Files:-
Create:
ui/src/components/storage/StorageFullBlock.tsx -
Modify: file-upload components (find all that handle
413from upload routes) - Step 1: Build component
- Step 2: Wire into upload components
<StorageFullBlock /> in place of the dropzone. Locate them:
onError:
- Step 3: Commit
Task 5.6: In-app upgrade page addon widgets
Files:- Modify:
ui/src/pages/UpgradePage.tsx(or wherever in-app upgrade UI lives — must be inside go.odontox.io / authenticated, NOT q.odontox.io)
Public-pricing rule: Concrete addon prices must NEVER appear on q.odontox.io. The marketing site may mention “addons available” in plain copy, but no PKR figures. This task only edits the authenticated in-app upgrade page.
- Step 1: Verify the file you’re editing is inside the authenticated app
/dashboard/upgrade or similar). If you find a public/marketing upgrade page (under q-odontox/ or similar marketing app folder), STOP — the marketing site keeps its current “talk to us” copy with no prices.
- Step 2: Add the same two cards (
StorageRequestCard,PortalSeatRequestCard) below the existing plan-selection section
- Step 3: Audit the q.odontox.io codebase for any leaked prices
q-odontox, public-marketing, or similar root), open a follow-up task to remove them. Within the authenticated app the prices are fine.
Also check q.odontox.io source repo (it may be a separate repo or app). If it lives here, search:
- Step 4: Commit
Phase 6: Superadmin UI
Subagent assignment: 1 subagent.
Task 6.1: Addons & Quotas tab on ClinicDetailsPage
Files:
-
Create:
ui/src/components/superadmin/AddonsQuotasTab.tsx -
Modify:
ui/src/components/superadmin/ClinicDetailsPage.tsx - Step 1: Build the tab component
- Step 2: Register the tab on
ClinicDetailsPage
- Step 3: Commit
Task 6.1b: Superadmin “Addon Pricing” settings panel
Files:-
Create:
ui/src/components/superadmin/AddonPricingSettings.tsx -
Modify: superadmin settings/platform router (wherever “platform settings” tab lives — likely
ui/src/pages/superadmin/Settings.tsxor similar) - Step 1: Locate superadmin platform settings page
- Step 2: Build the editor
- Step 3: Register on superadmin platform settings
<AddonPricingSettings /> as a section/tab on the platform settings page.
- Step 4: Commit
Task 6.2: Pending requests badge on superadmin dashboard
Files:-
Modify:
ui/src/pages/superadmin/Dashboard.tsx(or alerts panel) - Step 1: Add a small query that counts pending requests across all tenants
- Step 2: Commit
Phase 7: Help article
Subagent assignment: 1 subagent (lightweight, can run in parallel with UI).
Task 7.1: Write the help article
Files:-
Create:
docs/help/patient-portal-access.md(consumed by drawer; mirror inline content used in Phase 5.2’s drawer for now) - Step 1: Write the article
- Step 2: Commit
Phase 8: End-to-end smoke
Subagent assignment: do this last, after all other phases land.
Task 8.1: Manual smoke checklist
Runnpm run dev in both server/ and ui/. Login as a clinic admin on a Pro-tier test tenant. Verify in order:
- Settings → My Billing renders usage meters reflecting current state (0 portal seats used if fresh).
- Create a new patient with “Provide portal access” checked → seat count increments to 1.
-
Click
(?)next to the checkbox → drawer opens, shows the three-subdomain table. - Edit a patient with portal access ON, toggle OFF → confirm modal appears, on confirm the seat count drops.
-
Submit a 10 GB storage addon request → toast confirms; row appears in “Recent addon requests” with status
pending. - Submit a 1-pack portal seat addon (3 seats) → same.
- Switch to superadmin, open the tenant’s “Addons & Quotas” tab → both pending requests visible.
- Approve the portal-seat request → seat limit increments on the clinic side (refresh meter).
- Approve the storage request → storage quota increments.
- Reject a fresh request with a reason → status flips to rejected.
- Cancel an active addon as superadmin → quota decrements back.
- On Pro tenant at 100/100 seats, attempt to enable portal-access on a new patient → checkbox disabled with link to billing.
-
Fill the storage to the cap (mock by setting
storageUsedBytesdirectly in DB or by uploading), try to upload a file → 413 response, StorageFullBlock renders. -
Bulk import 5 patients on a tenant 1 seat under the cap with all rows
portal_access=yes→ 1 enrolls, 4 land in not-enrolled.csv. - Verify free-trial tenant has caps shown but never enforced (toggle the trial flag, retry).
-
Audit logs include
addon_request.created,.approved,.rejected,.cancelled,portal_access.granted/revoked,storage_quota.override. - Step 1: Document failures
- Step 2: Tag the release
Self-review against the spec
| Spec section | Task(s) covering it |
|---|---|
| Plan tier table | 1.1 (plan-limits.ts) |
| Pricing defaults | 1.1, 3.1 |
| Dynamic pricing config table | 1.2b, 1.3 (schema) |
| Pricing snapshot on request | 3.2 |
| Live pricing API for clinic UI | 5.4 (step 3) |
| Superadmin pricing editor | 3.3b (API), 6.1b (UI) |
| Pricing history / audit | 3.3b |
| No-pricing-on-q.odontox.io | 5.6 |
clinics columns | 1.2, 1.3 |
patients.portal_access | 1.2, 1.3 |
addon_requests table | 1.2, 1.3 |
plan-limits.ts central config | 1.1 |
| Storage usage tracking | 2.1, 2.3 |
| Clinic-facing API | 3.2, 3.4 |
| Superadmin API | 3.3 |
| Patient form checkbox | 5.2 |
| Bulk import overflow | 5.3 |
| Settings → My Billing | 5.1, 5.4 |
| Storage-full block UI | 5.5 |
| In-app upgrade page | 5.6 |
| Superadmin tenant detail tab | 6.1 |
| Pending requests badge | 6.2 |
| Help article | 5.2 (in-app), 7.1 (file) |
| Free-trial bypass | 2.2 (helper), 2.3 (upload gating), 2.4 (toggle), 5.2 (UI) |
| Permissions | 3.4 |
| Audit logs | 2.4, 3.2, 3.3, 3.3b |
| Legacy patient-quota replacement | 4.1 |
Legacy PATIENT_ADDON: migration | 4.2 |
PlanKey, AddonType, getEffective* helpers). Ready to dispatch.
Execution
Per the user’s auto-pilot rule, the next step is to dispatch parallel subagents. Phase dependency graph:- Phase 1 → blocks everything
- Phase 2 → blocks Phase 5 (UI needs APIs)
- Phase 3 → blocks Phase 5.4, 5.6, Phase 6
- Phase 4 → blocks nothing (cleanup; can run last)
- Phase 5–7 → can parallelize after Phase 2+3
- Phase 8 → after everything
- Run Phase 1 as a single subagent (sequential, blocking).
- Once Phase 1 lands: dispatch Phase 2, Phase 3 in parallel.
- Once Phase 2+3 land: dispatch Phase 4, Phase 5 (split into 5.1+5.2+5.3 and 5.4+5.5+5.6), Phase 6, Phase 7 in parallel.
- Phase 8 smoke after all others green.

