Tenant Cleanup + Visiting Doctors 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: Ship four independent improvements: (A) hide Finance for Dental Square reception, (B) remove stale internal-chat UI entry points, (C) allow unassigned bookings with a reception reassignment flow, (D) add visiting (no-login) doctors.
Architecture: Four feature branches off main, each independently mergeable. Workstreams A & B are low-risk config/UI cleanup; C & D add new server endpoints, a migration (D), and shared UI components (doctor picker reused between C and D). The doctor picker is the only cross-workstream shared component — C is built second and D third so the picker has visiting-doctor support from day one.
Tech Stack: Hono + Drizzle on Cloudflare Workers (server), React + TanStack Query + shadcn/ui (web), Neon Postgres.
Spec: docs/superpowers/specs/2026-05-23-tenant-cleanup-and-visiting-doctors-design.md
Branch strategy: One branch per workstream — feat/finance-hide-dental-square, feat/chat-deprecation-cleanup, feat/unassigned-booking-reassign, feat/visiting-doctors. The four can run in parallel; merge order does not matter except that D should land before C ships to prod so the doctor picker has visiting-doctor support in production (otherwise the C UI shows a never-populated “Visiting” badge column).
Workstream A — Hide Finance for Dental Square reception
Branch:feat/finance-hide-dental-square
Task A1: Add a unit test for role-template merging in clinic-modules
Files:-
Create:
server/src/routes/__tests__/clinic-modules.test.ts - Step 1: Write the failing test
- Step 2: Run test to verify it fails
cd server && pnpm test src/routes/__tests__/clinic-modules.test.ts
Expected: FAIL with “expected [‘finance’] not to contain ‘finance’” (current code ignores clinic_permission_templates).
If the existing test harness for Hono routes is different from this skeleton, mirror an existing route test (e.g., server/src/routes/__tests__/*.test.ts) before running.
- Step 3: Commit test
Task A2: Wire role-template merge into /active endpoint
Files:-
Modify:
server/src/routes/clinic-modules.ts:48-62 -
Modify:
server/src/schema/index.ts(exportclinicPermissionTemplatesif not already) - Step 1: Read the existing role-template schema to confirm column names
- Step 2: Update
clinic-modules.tsto merge role template
ALWAYS_ON declaration) with:
clinicPermissionTemplates to the import at line 3:
- Step 3: Run tests to verify they pass
cd server && pnpm test src/routes/__tests__/clinic-modules.test.ts
Expected: both tests PASS.
- Step 4: Commit
Task A3: Verify Finance UI entry points all gate on hasModule('finance')
Files:
- Audit (no edits expected unless gap found): sidebar, dashboard cards, patient detail Finance shortcuts
- Step 1: Find every Finance reference in the UI
- Step 2: For each entry point, confirm it routes through
useModules().hasModule('finance')
- Step 3: Commit any gating fixes (skip if no gaps)
Task A4: Build the Dental Square DB write (held for user confirmation)
Files:-
Create:
server/scripts/dental-square-hide-finance.sql - Step 1: Write the SQL script
clinic_permission_templates schema after Step 1 of A2 confirms them.)
- Step 2: Commit the script
- Step 3: Stop. Surface to user for execution approval.
“Dental Square SQL is committed at server/scripts/dental-square-hide-finance.sql. Need your explicit go-ahead and the production connection string to execute (per the live-tenant-execution rule). Should I run it now, or do you want to run it yourself?”
Do not execute against any live DB without per-action confirmation.
Task A5: Update API docs
Files:-
Modify:
docs/api-reference.md -
Step 1: Add a section on the merge order for
/clinic-modules/active
/clinic-modules/active section:
- Step 2: Commit
Workstream B — Remove stale internal-chat entry points
Branch:feat/chat-deprecation-cleanup
Context: The chat module is already globally disabled in AppLayout.tsx (line 144 strips ?chat= URL params; line 463 explicit comment). Only stale entry points remain. WhatsApp v2 is unaffected.
Task B1: Delete the receptionist “Messages” card
Files:-
Modify:
ui/src/components/receptionist/ReceptionistOverview.tsx - Step 1: Locate the card (already known: lines ~452-470)
- Step 2: Delete the entire Card block
onClick={() => navigate('/dashboard?chat=1')}) including its CardHeader, CardTitle, CardContent, and any wrapping <div> that exists solely for this card. Leave the surrounding grid layout intact.
- Step 3: Verify dev server renders without the card
cd ui && pnpm dev (in another terminal) — open reception dashboard, confirm no Messages card.
- Step 4: Commit
Task B2: Delete the patient-detail Message button
Files:-
Modify:
ui/src/components/patients/PatientDetails.tsx -
Step 1: Remove
handleMessagehandler at lines 124-128
handleMessage function (the one that calls navigate('/dashboard?chat=1&patientId=...')).
- Step 2: Remove the Message button + helper text
<Button onClick={handleMessage}> block at line ~368 and the “To message a patient…” helper text at line ~380. Leave the surrounding action row intact.
- Step 3: Remove the
Messageicon import if now unused
- Step 4: Verify TypeScript clean
cd ui && pnpm tsc --noEmit
Expected: no errors involving PatientDetails.tsx.
- Step 5: Commit
Task B3: Remove the stale toast action in NotificationProvider
Files:-
Modify:
ui/src/components/providers/NotificationProvider.tsx:455-468 -
Step 1: Remove the toast
actionfield
?chat=1. Since chat is globally disabled, remove the entire action field from the toast options. Keep the toast itself (it still informs the user of unread notifications).
- Step 2: Commit
Task B4: Clean up dead chat strip handler in AppLayout
Files:-
Modify:
ui/src/components/layout/AppLayout.tsx:140-155(the URL-param-strip useEffect) - Step 1: Confirm no other entry points push chat=1
- Step 2: If clean, delete the URL-param-strip useEffect
useEffect block (lines ~144-155) that strips ?chat= from URL. With B1-B3 done, no code can produce this URL anymore.
- Step 3: Verify TS + dev server still loads
cd ui && pnpm tsc --noEmit
Expected: clean.
- Step 4: Commit
Task B5: Remove chat from mobile receptionist permission defaults
Files:
-
Modify:
server/src/schema/mobile_role_permissions.ts(or wherever the seed lives — confirm) - Step 1: Find the seed
- Step 2: Remove
'chat'from the receptionist default permission list
'chat' entry from any defaultModules / defaultPermissions arrays keyed to receptionist (and any other role that has it).
- Step 3: Commit
Workstream C — Unassigned booking + reassignment
Branch:feat/unassigned-booking-reassign
Context: canCreateAppointment.ts:119 already gates all conflict checks behind if (doctorId) — unassigned appointments already bypass conflict detection. The work here is (1) adding a PATCH /assign-doctor endpoint that runs the conflict check at assignment time, (2) blocking in_progress transitions without a doctor, (3) UI to show unassigned status, the picker, and an “Unassigned” calendar lane, (4) reception permission grant.
Task C1: Write a failing test for unassigned booking stacking
Files:-
Create:
server/src/lib/rules/scheduling/__tests__/unassigned-booking.test.ts - Step 1: Write the test
- Step 2: Run — first test should already PASS, second should already PASS
cd server && pnpm test src/lib/rules/scheduling/__tests__/unassigned-booking.test.ts
Expected: both PASS (this codifies the existing behavior so future changes don’t regress it).
- Step 3: Commit
Task C2: Status-transition guard — block in_progress without doctor
Files:-
Modify:
server/src/routes/appointments.ts(find the PATCH/PUT endpoint that handles status changes) - Step 1: Locate the status update path
- Step 2: Write a failing integration test
server/src/routes/__tests__/appointments.test.ts:
- Step 3: Run test — should FAIL
cd server && pnpm test src/routes/__tests__/appointments.test.ts
Expected: FAIL (currently allows the transition).
- Step 4: Add the guard
- Step 5: Run test — should PASS
cd server && pnpm test src/routes/__tests__/appointments.test.ts
Expected: PASS.
- Step 6: Commit
Task C3: New PATCH /appointments/:id/assign-doctor endpoint
Files:-
Modify:
server/src/routes/appointments.ts - Step 1: Write a failing test
- Step 2: Run — both FAIL
cd server && pnpm test src/routes/__tests__/appointments.test.ts -t "assign-doctor"
Expected: 404 (route doesn’t exist).
- Step 3: Implement the endpoint
server/src/routes/appointments.ts:
getDb, getReadDb, auditLog, broadcastAppointmentChange, users, userClinicAssignments to match the existing route imports.)
- Step 4: Run tests — should PASS
cd server && pnpm test src/routes/__tests__/appointments.test.ts -t "assign-doctor"
Expected: both PASS.
- Step 5: Commit
Task C4: Update doctor-list response to include accountType
Files:-
Modify:
server/src/routes/staff.ts(the staff GET endpoint) orserver/src/routes/users.ts(getClinicUsers) - Step 1: Locate the doctor-list endpoint used by the calendar/booking flow
- Step 2: Add
accountTypeto the select payload
db.select({...}) for users and append:
users.accountType column to exist — Workstream D’s migration must be applied first if this is run after D. If running before D, hardcode accountType: sql<string>\’login’“.
- Step 3: Commit
Task C5: Shared DoctorPicker component
Files:-
Create:
ui/src/components/appointments/DoctorPicker.tsx - Step 1: Implement the picker
- Step 2: Add
getClinicDoctorstoserverComm.tsif missing
- Step 3: Commit
Task C6: Render “Unassigned” badge on appointment rows + calendar cards
Files:-
Modify:
ui/src/components/appointments/AppointmentsTable.tsx(or whichever list component renders the doctor column) -
Modify:
ui/src/components/appointments/EventCard.tsx(calendar card) - Step 1: Find the doctor column / card label
- Step 2: Add the badge
{doctorName}, replace with:
Badge import if missing. Use the existing project amber/warning token if there is one — don’t introduce new design tokens.
- Step 3: Wire click on the badge to open DoctorPicker
onChange calling the assign API:
- Step 4: Add
assignDoctorto serverComm
- Step 5: Commit
Task C7: AppointmentRightRail / DetailPage reassign affordance
Files:-
Modify:
ui/src/components/appointments/AppointmentRightRail.tsx -
Modify:
ui/src/components/appointments/AppointmentDetailPage.tsx - Step 1: Find the doctor row in the right rail
- Step 2: Replace the doctor display with an inline DoctorPicker
-
Step 3: Mirror in
AppointmentDetailPage.tsx(where the same doctor field renders, if separate from the rail). - Step 4: Disable the “Start appointment” button when doctor is null
in_progress. Add:
- Step 5: Commit
Task C8: “Unassigned” lane in calendar day/week views
Files:-
Modify:
ui/src/components/appointments/AppointmentCalendar.tsx(or the v2 day/week view components) - Step 1: Find the lane-rendering logic
- Step 2: Add an “Unassigned” lane
{ id: null, label: 'Unassigned', accountType: 'login' as const } entry to the lanes array, and route appointments where doctorId === null to it. When the doctor filter is non-null, hide the Unassigned lane.
- Step 3: Style the Unassigned lane header
bg-amber-50), italic label “Unassigned”, to visually flag it.
- Step 4: Verify in the dev server
cd ui && pnpm dev. Open the calendar, create an appointment with no doctor, confirm it lands in the Unassigned lane. Filter by a specific doctor — confirm the lane disappears.
- Step 5: Commit
Task C9: Update API docs
Files:-
Modify:
docs/api-reference.md - Step 1: Add the new endpoint
- Step 2: Commit
Workstream D — Visiting doctors (no-login staff)
Branch:feat/visiting-doctors
Task D1: Drizzle migration — add account_type column
Files:
-
Create:
server/drizzle/0052_user_account_type.sql -
Modify:
server/src/schema/users.ts - Step 1: Write the migration SQL
- Step 2: Update the Drizzle schema to expose the column
server/src/schema/users.ts, after the lastSessionId field, add:
- Step 3: Commit migration (do NOT apply yet)
- Step 4: Stop. Apply migration only with user confirmation.
“Migration 0052 ready. Need confirmation to apply against the production DB.”
Task D2: Reject visiting accounts at sign-in
Files:-
Modify:
server/src/routes/auth.ts(sign-in handler) - Step 1: Write a failing test
- Step 2: Add the guard right after fetching the user
- Step 3: Run tests → PASS
- Step 4: Commit
Task D3: POST /staff/visiting-doctor endpoint
Files:-
Modify:
server/src/routes/staff.ts - Step 1: Locate the middleware guard at staff.ts:72
- Step 2: Write a failing test
- Step 3: Implement — add a route that uses a per-permission auth, not the file’s admin-only middleware
staffRoute.use('*', ...) admin middleware to a named middleware applied per-route, then add the visiting-doctor route with its own permission middleware:
- Step 4: Ensure
requirePermissionmiddleware exists or create it
server/src/middleware/permissions.ts:
- Step 5: Run tests → PASS
- Step 6: Commit
Task D4: Include visiting doctors in staff GET response
Files:-
Modify:
server/src/routes/staff.ts(the GET handler at line ~101) - Step 1: Add accountType to the select
permissions field in the select object (line ~123), add:
- Step 2: Confirm the WHERE clause doesn’t filter visiting out
status='active' on userClinicAssignments is fine — visiting doctors are inserted with status='active'. No change.
- Step 3: Commit
Task D5: AddVisitingDoctorSheet UI component
Files:-
Create:
ui/src/components/settings/AddVisitingDoctorSheet.tsx - Step 1: Implement the sheet
- Step 2: Add
addVisitingDoctorto serverComm
- Step 3: Commit
Task D6: Wire the sheet into StaffManagement
Files:-
Modify:
ui/src/components/settings/StaffManagement.tsx - Step 1: Add the button next to “Invite staff”
AddVisitingDoctorSheet and UserPlus from lucide-react.
- Step 2: Render the “Visiting” badge on staff rows
- Step 3: Hide Resend-invite / Reset-password actions for visiting rows
- Step 4: Verify in dev server
- Step 5: Commit
Task D7: Superadmin parity — visiting doctor count in tenant inspector
Files:-
Modify:
ui/src/components/superadmin/tenants/...(the Staff tab of the tenant inspector — locate during impl) - Step 1: Find the staff tab
- Step 2: Add the visiting-count split
loginCount and visitingCount from the existing staff query by filtering on accountType.
- Step 3: Add a “Visiting” badge in the table column too (mirror Task D6 step 2).
- Step 4: Commit
Task D8: Permission tree update
Files:-
Modify: wherever the canonical permission key list is defined (likely
server/src/lib/permissions.tsor similar — search) - Step 1: Find the permission key list
- Step 2: Add
staff.create.visitingwith description
- Step 3: Commit
Task D9: Update API docs
Files:-
Modify:
docs/api-reference.md - Step 1: Document the new endpoint
201 { id, accountType: 'visiting' }— created.409— email already in use.403— caller lacksstaff.create.visiting.
users row with account_type='visiting', password_hash=null, plus a user_clinic_assignments row. Sign-in attempts return generic 401.
Wrap-up
Final tasks (after all four workstreams merge)
- Update
RELEASES.md— add v1.9 entry with the four workstream summaries. - Update login version tag — bump
APP_VERSIONinui/src/pages/sign-in.tsx. - Deploy via the odontox-commit-deploy skill — staging first, then canonical promotion.
Out of scope (do NOT add)
- Deleting
ui/src/components/chat/orui/src/components/chat-v2/files (separate cleanup task). - Visiting → login conversion UI.
- Multi-clinic visiting doctors (each is scoped to one clinic for v1).
- Superadmin write override of
account_type. - Room-conflict detection for unassigned slots (existing behavior preserved).
Self-review
- Every workstream has at least one test task before the implementation task (TDD).
- Every server change has a corresponding API docs task (D9, C9, A5).
- The shared DoctorPicker (C5) consumes
accountTypefrom D — D should land first in prod, but the picker handles missingaccountTypegracefully (defaults to ‘login’). - No placeholders, no “TBD”, no “implement similar to above”. Each code block is complete.
- DB writes (A4) and migrations (D1) are committed but gated on user confirmation per the no-live-tenant rule.

