Skip to main content

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
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock getReadDb to return controllable rows
const mockSelect = vi.fn();
vi.mock('../../lib/db', () => ({
  getReadDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit: (_n: number) => Promise.resolve(mockSelect()) }) }) }) }),
}));

import app from '../clinic-modules';

describe('GET /clinic-modules/active', () => {
  beforeEach(() => mockSelect.mockReset());

  it('removes finance for receptionist when role template disables it', async () => {
    // 1st call: clinicModules rows
    mockSelect
      .mockResolvedValueOnce([{ moduleKey: 'finance', isEnabled: true }])
      // 2nd call: userClinicAssignments (no user override)
      .mockResolvedValueOnce([])
      // 3rd call: clinic_permission_templates row with finance=false for receptionist
      .mockResolvedValueOnce([{ permissions: { finance: false } }]);

    const res = await app.request('/active', {
      headers: { /* mocked auth headers — fill from test helpers */ },
    });

    const body: { modules: string[] } = await res.json();
    expect(body.modules).not.toContain('finance');
  });

  it('keeps finance for admin even when receptionist template disables it', async () => {
    mockSelect
      .mockResolvedValueOnce([{ moduleKey: 'finance', isEnabled: true }])
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([]); // no template row for admin

    const res = await app.request('/active', { headers: { /* admin auth */ } });
    const body: { modules: string[] } = await res.json();
    expect(body.modules).toContain('finance');
  });
});
  • Step 2: Run test to verify it fails
Run: 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
git add server/src/routes/__tests__/clinic-modules.test.ts
git commit -m "test(clinic-modules): role-template merge fails (red)"

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 (export clinicPermissionTemplates if not already)
  • Step 1: Read the existing role-template schema to confirm column names
grep -n "clinicPermissionTemplates\|clinic_permission_templates" server/src/schema/*.ts
  • Step 2: Update clinic-modules.ts to merge role template
Replace lines 38-62 (after the ALWAYS_ON declaration) with:
const clinicModuleKeys = activeModules.map(m => m.moduleKey);
const merged = new Set([...ALWAYS_ON, ...clinicModuleKeys]);
let finalModules = [...merged];

// Receptionist hardcoded blocklist (kept for backward compat)
const RECEPTIONIST_BLOCKED = new Set(['expenses', 'analytics', 'dental_chart']);
if (user.role === 'receptionist') {
    finalModules = finalModules.filter(key => !RECEPTIONIST_BLOCKED.has(key));
}

// Per-clinic per-role template (e.g., "Dental Square reception: no finance")
const [roleTemplate] = await db.select({ permissions: clinicPermissionTemplates.permissions })
    .from(clinicPermissionTemplates)
    .where(and(
        eq(clinicPermissionTemplates.clinicId, currentClinicId),
        eq(clinicPermissionTemplates.role, user.role as any),
    ))
    .limit(1);

if (roleTemplate?.permissions) {
    const rolePerms = roleTemplate.permissions as Record<string, boolean>;
    finalModules = finalModules.filter(key => rolePerms[key] !== false);
    Object.keys(rolePerms).forEach(key => {
        if (rolePerms[key] === true && !finalModules.includes(key)) {
            finalModules.push(key);
        }
    });
}

// Per-user override (highest precedence — superadmin grants/revokes per user)
if (assignment?.permissions) {
    const userPerms = assignment.permissions as Record<string, boolean>;
    finalModules = finalModules.filter(key => userPerms[key] !== false);
    Object.keys(userPerms).forEach(key => {
        if (userPerms[key] === true && !finalModules.includes(key)) {
            finalModules.push(key);
        }
    });
}
Also add clinicPermissionTemplates to the import at line 3:
import { clinicModules, userClinicAssignments, clinicPermissionTemplates } from '../schema';
  • Step 3: Run tests to verify they pass
Run: cd server && pnpm test src/routes/__tests__/clinic-modules.test.ts Expected: both tests PASS.
  • Step 4: Commit
git add server/src/routes/clinic-modules.ts
git commit -m "feat(clinic-modules): merge clinic_permission_templates into /active"

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
grep -rn "finance\|Finance" /Users/ssh/Documents/Beta-App/odontoX/ui/src --include="*.tsx" | \
  grep -iE "navigate|onclick|to=|href|module|card|nav" | head -30
  • Step 2: For each entry point, confirm it routes through useModules().hasModule('finance')
Open each file flagged and check the conditional. If any hardcoded entry has no module gate, wrap it like:
const { hasModule } = useModules();
if (!hasModule('finance')) return null;
  • Step 3: Commit any gating fixes (skip if no gaps)
git add ui/src/...
git commit -m "fix(finance): gate stray Finance entry points on hasModule('finance')"

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
-- Dental Square: hide finance module for receptionist role.
-- Per the no-live-tenant-execution rule, this script is committed but NOT executed automatically.
-- Run only after explicit per-action user confirmation against the production connection string.

-- 1. Resolve the clinic id for "Dental Square" — verify before running
-- SELECT id, name FROM app.clinics WHERE name ILIKE '%dental square%';

-- 2. Upsert the role template
INSERT INTO app.clinic_permission_templates (id, clinic_id, role, permissions, updated_at)
VALUES (
  gen_random_uuid()::text,
  :'clinic_id',
  'receptionist',
  jsonb_build_object('finance', false),
  NOW()
)
ON CONFLICT (clinic_id, role) DO UPDATE
SET permissions = COALESCE(app.clinic_permission_templates.permissions, '{}'::jsonb) || jsonb_build_object('finance', false),
    updated_at = NOW();

-- 3. Verify
SELECT clinic_id, role, permissions FROM app.clinic_permission_templates
WHERE clinic_id = :'clinic_id' AND role = 'receptionist';
(Adjust column names to match the actual clinic_permission_templates schema after Step 1 of A2 confirms them.)
  • Step 2: Commit the script
git add server/scripts/dental-square-hide-finance.sql
git commit -m "feat(scripts): SQL to disable finance for Dental Square reception"
  • Step 3: Stop. Surface to user for execution approval.
Report to user:
“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
Append under the existing /clinic-modules/active section:
### Merge order for `modules` response

The endpoint computes the user's visible module list in this order, each later layer overriding the previous:

1. `ALWAYS_ON` core modules.
2. `clinic_modules.is_enabled = true` rows for the current clinic.
3. Receptionist hardcoded blocklist (`expenses`, `analytics`, `dental_chart`).
4. `clinic_permission_templates.permissions` for `(clinic_id, user.role)``false` removes, `true` grants.
5. `user_clinic_assignments.permissions` for `(clinic_id, user.id)` — same semantics, highest precedence.
  • Step 2: Commit
git add docs/api-reference.md
git commit -m "docs(api): document clinic-modules merge order"

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)
sed -n '450,475p' ui/src/components/receptionist/ReceptionistOverview.tsx
  • Step 2: Delete the entire Card block
Use Edit tool to remove the Card block at line 455 (the one with 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
Run: cd ui && pnpm dev (in another terminal) — open reception dashboard, confirm no Messages card.
  • Step 4: Commit
git add ui/src/components/receptionist/ReceptionistOverview.tsx
git commit -m "fix(reception): remove stale Messages card (chat globally deprecated)"

Task B2: Delete the patient-detail Message button

Files:
  • Modify: ui/src/components/patients/PatientDetails.tsx
  • Step 1: Remove handleMessage handler at lines 124-128
Delete the entire handleMessage function (the one that calls navigate('/dashboard?chat=1&patientId=...')).
  • Step 2: Remove the Message button + helper text
Delete the <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 Message icon import if now unused
grep -n "import.*Message\|MessageSquare\|MessageCircle" ui/src/components/patients/PatientDetails.tsx
Remove any of these imports that are no longer referenced anywhere else in the file.
  • Step 4: Verify TypeScript clean
Run: cd ui && pnpm tsc --noEmit Expected: no errors involving PatientDetails.tsx.
  • Step 5: Commit
git add ui/src/components/patients/PatientDetails.tsx
git commit -m "fix(patients): remove stale Message button (chat globally deprecated)"

Task B3: Remove the stale toast action in NotificationProvider

Files:
  • Modify: ui/src/components/providers/NotificationProvider.tsx:455-468
  • Step 1: Remove the toast action field
The toast at line 456 has an action button labeled “View Inbox” that pushes to ?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).
toast("You have unread updates", {
    description: `There are ${unread} notifications waiting for you.`,
    duration: 8000,
    className: "border-primary bg-primary/10 text-primary-foreground",
    // action removed — chat globally deprecated
});
  • Step 2: Commit
git add ui/src/components/providers/NotificationProvider.tsx
git commit -m "fix(notifications): drop stale 'View Inbox' chat action"

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
grep -rn "chat=1\|navigate.*chat" ui/src --include="*.tsx" | grep -v "components/chat" | grep -v "components/chat-v2"
Expected (after B1-B3): zero hits.
  • Step 2: If clean, delete the URL-param-strip useEffect
Remove the entire 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
Run: cd ui && pnpm tsc --noEmit Expected: clean.
  • Step 4: Commit
git add ui/src/components/layout/AppLayout.tsx
git commit -m "chore(layout): drop dead chat URL-strip handler"

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
grep -rn "chat" server/src/schema/mobile_role_permissions.ts server/src/lib/seeds/ 2>/dev/null | head
  • Step 2: Remove 'chat' from the receptionist default permission list
Edit to drop the 'chat' entry from any defaultModules / defaultPermissions arrays keyed to receptionist (and any other role that has it).
  • Step 3: Commit
git add server/src/schema/mobile_role_permissions.ts
git commit -m "chore(mobile-perms): drop chat from receptionist defaults"

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
import { describe, it, expect } from 'vitest';
import { detectConflicts } from '../detectConflicts';

describe('detectConflicts — unassigned appointments', () => {
  it('allows two NULL-doctor appointments at the same time', () => {
    const existing = [{
      id: 'a1', doctorId: null, operatory: null,
      appointmentTime: '09:00', durationMinutes: 30,
    }];
    const result = detectConflicts(existing, {
      clinicId: 'c1', appointmentDate: '2026-05-23',
      appointmentTime: '09:00', durationMinutes: 30,
      doctorId: null, operatory: null,
    } as any);
    expect(result.allowed).toBe(true);
  });

  it('blocks same-doctor overlap', () => {
    const existing = [{
      id: 'a1', doctorId: 'd1', operatory: null,
      appointmentTime: '09:00', durationMinutes: 30,
    }];
    const result = detectConflicts(existing, {
      clinicId: 'c1', appointmentDate: '2026-05-23',
      appointmentTime: '09:15', durationMinutes: 30,
      doctorId: 'd1', operatory: null,
    } as any);
    expect(result.allowed).toBe(false);
    expect(result.code).toBe('CONFLICT');
  });
});
  • Step 2: Run — first test should already PASS, second should already PASS
Run: 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
git add server/src/lib/rules/scheduling/__tests__/unassigned-booking.test.ts
git commit -m "test(scheduling): codify unassigned-booking-stacking behavior"

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
grep -n "in_progress\|status.*update\|PATCH" server/src/routes/appointments.ts | head -20
  • Step 2: Write a failing integration test
Create or extend server/src/routes/__tests__/appointments.test.ts:
it('rejects status=in_progress when doctor_id is null', async () => {
  // Seed an appointment with doctorId=null
  const apptId = await seedAppointment({ doctorId: null, status: 'scheduled' });
  const res = await app.request(`/appointments/${apptId}`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json', /* auth */ },
    body: JSON.stringify({ status: 'in_progress' }),
  });
  expect(res.status).toBe(400);
  const body = await res.json() as { code: string };
  expect(body.code).toBe('UNASSIGNED_APPOINTMENT');
});
If a test helper for seeding doesn’t exist yet, mirror the pattern from the nearest sibling test file.
  • Step 3: Run test — should FAIL
Run: cd server && pnpm test src/routes/__tests__/appointments.test.ts Expected: FAIL (currently allows the transition).
  • Step 4: Add the guard
In the appointment update handler, before persisting the status change:
if (body.status === 'in_progress') {
  const [current] = await db.select({ doctorId: appointments.doctorId })
    .from(appointments).where(eq(appointments.id, appointmentId)).limit(1);
  if (!current || !current.doctorId) {
    return c.json({
      code: 'UNASSIGNED_APPOINTMENT',
      message: 'Assign a doctor before starting this appointment.',
    }, 400);
  }
}
  • Step 5: Run test — should PASS
Run: cd server && pnpm test src/routes/__tests__/appointments.test.ts Expected: PASS.
  • Step 6: Commit
git add server/src/routes/appointments.ts server/src/routes/__tests__/appointments.test.ts
git commit -m "feat(appointments): block in_progress transition without doctor"

Task C3: New PATCH /appointments/:id/assign-doctor endpoint

Files:
  • Modify: server/src/routes/appointments.ts
  • Step 1: Write a failing test
it('PATCH /:id/assign-doctor assigns the doctor and audits', async () => {
  const apptId = await seedAppointment({ doctorId: null, status: 'scheduled' });
  const doctorId = await seedDoctor({ accountType: 'login' });
  const res = await app.request(`/appointments/${apptId}/assign-doctor`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json', /* receptionist auth */ },
    body: JSON.stringify({ doctorId }),
  });
  expect(res.status).toBe(200);
  const [row] = await db.select().from(appointments).where(eq(appointments.id, apptId)).limit(1);
  expect(row.doctorId).toBe(doctorId);
});

it('returns 409 when target doctor has a hard conflict', async () => {
  const doctorId = await seedDoctor({ accountType: 'login' });
  await seedAppointment({ doctorId, status: 'scheduled', appointmentTime: '09:00', durationMinutes: 30 });
  const unassignedId = await seedAppointment({ doctorId: null, status: 'scheduled', appointmentTime: '09:00', durationMinutes: 30 });
  const res = await app.request(`/appointments/${unassignedId}/assign-doctor`, {
    method: 'PATCH', headers: { /* auth */ },
    body: JSON.stringify({ doctorId }),
  });
  expect(res.status).toBe(409);
});
  • Step 2: Run — both FAIL
Run: cd server && pnpm test src/routes/__tests__/appointments.test.ts -t "assign-doctor" Expected: 404 (route doesn’t exist).
  • Step 3: Implement the endpoint
Add to server/src/routes/appointments.ts:
import { canCreateAppointment } from '../lib/rules/scheduling/canCreateAppointment';

const assignDoctorSchema = z.object({ doctorId: z.string().uuid() });

appointmentsRoute.patch('/:id/assign-doctor', async (c) => {
  const user = c.get('user');
  const clinicId = c.get('clinicContext')?.currentClinicId;
  if (!clinicId) return c.json({ error: 'No clinic context' }, 400);

  // Permission: receptionist, admin, doctor (any) can reassign
  if (!['admin', 'receptionist', 'doctor', 'superadmin'].includes(user.role)) {
    return c.json({ error: 'Forbidden' }, 403);
  }

  const apptId = c.req.param('id');
  const body = assignDoctorSchema.parse(await c.req.json());

  const db = getDb();
  const [appt] = await db.select().from(appointments)
    .where(and(eq(appointments.id, apptId), eq(appointments.clinicId, clinicId)))
    .limit(1);
  if (!appt) return c.json({ error: 'Appointment not found' }, 404);

  // Verify the target doctor exists at this clinic
  const [doctor] = await db.select({ id: users.id, accountType: users.accountType })
    .from(users)
    .innerJoin(userClinicAssignments, eq(userClinicAssignments.userId, users.id))
    .where(and(
      eq(users.id, body.doctorId),
      eq(userClinicAssignments.clinicId, clinicId),
      eq(userClinicAssignments.role, 'doctor'),
    ))
    .limit(1);
  if (!doctor) return c.json({ error: 'Doctor not found at this clinic' }, 404);

  // Run the conflict check against the new doctor
  const ruleResult = await canCreateAppointment(getReadDb(), {
    clinicId,
    doctorId: body.doctorId,
    patientId: appt.patientId,
    appointmentDate: appt.appointmentDate,
    appointmentTime: appt.appointmentTime,
    durationMinutes: appt.durationMinutes ?? 30,
    operatory: appt.operatory,
    existingAppointmentId: appt.id,
  });
  if (!ruleResult.allowed) {
    return c.json({ code: ruleResult.code, message: ruleResult.message }, 409);
  }

  const previousDoctorId = appt.doctorId;
  await db.update(appointments)
    .set({ doctorId: body.doctorId, updatedAt: new Date() })
    .where(eq(appointments.id, apptId));

  // Audit log
  await db.insert(auditLog).values({
    clinicId, actorUserId: user.id, event: 'appointment.assign_doctor',
    payload: { appointmentId: apptId, from: previousDoctorId, to: body.doctorId },
    createdAt: new Date(),
  });

  // SSE broadcast (use the existing helper)
  await broadcastAppointmentChange(clinicId, apptId, 'reassigned');

  return c.json({ ok: true, doctorId: body.doctorId });
});
(Adjust import names: getDb, getReadDb, auditLog, broadcastAppointmentChange, users, userClinicAssignments to match the existing route imports.)
  • Step 4: Run tests — should PASS
Run: cd server && pnpm test src/routes/__tests__/appointments.test.ts -t "assign-doctor" Expected: both PASS.
  • Step 5: Commit
git add server/src/routes/appointments.ts server/src/routes/__tests__/appointments.test.ts
git commit -m "feat(appointments): PATCH /:id/assign-doctor with conflict check + audit"

Task C4: Update doctor-list response to include accountType

Files:
  • Modify: server/src/routes/staff.ts (the staff GET endpoint) or server/src/routes/users.ts (getClinicUsers)
  • Step 1: Locate the doctor-list endpoint used by the calendar/booking flow
grep -rn "getClinicUsers\|clinic.*users\|listClinicDoctors" server/src/routes/ | head
  • Step 2: Add accountType to the select payload
Find the db.select({...}) for users and append:
accountType: users.accountType,
This requires the 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
git add server/src/routes/staff.ts
git commit -m "feat(staff): expose accountType in doctor list response"

Task C5: Shared DoctorPicker component

Files:
  • Create: ui/src/components/appointments/DoctorPicker.tsx
  • Step 1: Implement the picker
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getClinicDoctors } from '../../lib/serverComm';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Badge } from '../ui/badge';
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
import { UserPlus, Search } from 'lucide-react';

interface Doctor {
  id: string;
  firstName: string;
  lastName: string;
  accountType: 'login' | 'visiting';
}

interface DoctorPickerProps {
  value: string | null;
  onChange: (doctorId: string) => void;
  trigger?: React.ReactNode;
  showAddVisiting?: boolean;
  onAddVisiting?: () => void;
}

export function DoctorPicker({ value, onChange, trigger, showAddVisiting, onAddVisiting }: DoctorPickerProps) {
  const [open, setOpen] = useState(false);
  const [search, setSearch] = useState('');
  const { data: doctors = [] } = useQuery({
    queryKey: ['clinic-doctors'],
    queryFn: getClinicDoctors,
  });

  const filtered = doctors.filter((d: Doctor) =>
    `${d.firstName} ${d.lastName}`.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        {trigger ?? <Button variant="outline" size="sm">Pick doctor</Button>}
      </PopoverTrigger>
      <PopoverContent className="w-72 p-2" align="start">
        <div className="flex items-center gap-2 px-2 pb-2">
          <Search className="h-4 w-4 text-muted-foreground" />
          <Input
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder="Search doctors..."
            className="h-8"
          />
        </div>
        <div className="max-h-64 overflow-y-auto">
          {filtered.map((d: Doctor) => (
            <button
              key={d.id}
              className="w-full text-left px-2 py-2 hover:bg-accent rounded flex items-center justify-between"
              onClick={() => { onChange(d.id); setOpen(false); }}
            >
              <span>Dr. {d.firstName} {d.lastName}</span>
              {d.accountType === 'visiting' && (
                <Badge variant="secondary" className="text-xs">Visiting</Badge>
              )}
            </button>
          ))}
          {filtered.length === 0 && (
            <div className="px-2 py-4 text-center text-sm text-muted-foreground">
              No doctors found
            </div>
          )}
        </div>
        {showAddVisiting && (
          <div className="border-t mt-2 pt-2">
            <Button
              variant="ghost" size="sm" className="w-full justify-start gap-2"
              onClick={() => { setOpen(false); onAddVisiting?.(); }}
            >
              <UserPlus className="h-4 w-4" /> Add visiting doctor
            </Button>
          </div>
        )}
      </PopoverContent>
    </Popover>
  );
}
  • Step 2: Add getClinicDoctors to serverComm.ts if missing
export async function getClinicDoctors() {
  const res = await fetch('/api/staff?role=doctor', { credentials: 'include' });
  if (!res.ok) throw new Error('Failed to fetch doctors');
  return res.json();
}
  • Step 3: Commit
git add ui/src/components/appointments/DoctorPicker.tsx ui/src/lib/serverComm.ts
git commit -m "feat(appointments): shared DoctorPicker with visiting-doctor support"

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
grep -rn "doctorName\|doctor.*Name\|doctorId" ui/src/components/appointments/ --include="*.tsx" | grep -iE "render|return|td|td>" | head
  • Step 2: Add the badge
Where the row renders {doctorName}, replace with:
{doctorId ? (
  <span>Dr. {doctorName}</span>
) : (
  <Badge variant="warning" className="bg-amber-100 text-amber-900 border-amber-300">
    Assign doctor
  </Badge>
)}
Add 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
Wrap the badge in DoctorPicker as the trigger, with onChange calling the assign API:
<DoctorPicker
  value={doctorId}
  onChange={async (id) => {
    await assignDoctor(appointmentId, id); // new serverComm helper
    queryClient.invalidateQueries({ queryKey: ['appointments'] });
  }}
  trigger={<Badge variant="warning" className="cursor-pointer">Assign doctor</Badge>}
  showAddVisiting={hasPermission('staff.create.visiting')}
  onAddVisiting={() => setAddVisitingOpen(true)}
/>
  • Step 4: Add assignDoctor to serverComm
export async function assignDoctor(appointmentId: string, doctorId: string) {
  const res = await fetch(`/api/appointments/${appointmentId}/assign-doctor`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ doctorId }),
  });
  if (!res.ok) {
    const body = await res.json();
    throw new Error(body.message || 'Failed to assign doctor');
  }
  return res.json();
}
  • Step 5: Commit
git add ui/src/components/appointments/ ui/src/lib/serverComm.ts
git commit -m "feat(appointments): Assign-doctor badge + picker on list rows"

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
grep -n "doctorName\|Doctor:" ui/src/components/appointments/AppointmentRightRail.tsx
  • Step 2: Replace the doctor display with an inline DoctorPicker
<div className="flex items-center justify-between">
  <span className="text-sm text-muted-foreground">Doctor</span>
  {appointment.doctorId ? (
    <div className="flex items-center gap-2">
      <span className="font-medium">Dr. {appointment.doctorName}</span>
      <DoctorPicker
        value={appointment.doctorId}
        onChange={(id) => assignDoctor(appointment.id, id).then(refetch)}
        trigger={<Button variant="ghost" size="sm" className="text-xs">Reassign</Button>}
      />
    </div>
  ) : (
    <DoctorPicker
      value={null}
      onChange={(id) => assignDoctor(appointment.id, id).then(refetch)}
      trigger={
        <Button variant="outline" size="sm" className="text-amber-700 border-amber-400">
          Assign doctor
        </Button>
      }
    />
  )}
</div>
  • 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
Find the button that triggers in_progress. Add:
<Button
  disabled={!appointment.doctorId}
  title={!appointment.doctorId ? 'Assign a doctor first' : undefined}
  onClick={handleStart}
>
  Start appointment
</Button>
  • Step 5: Commit
git add ui/src/components/appointments/AppointmentRightRail.tsx ui/src/components/appointments/AppointmentDetailPage.tsx
git commit -m "feat(appointments): right-rail reassign + disabled start when unassigned"

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
grep -rn "lane\|doctorColumn\|doctorLanes\|groupBy.*doctor" ui/src/components/appointments/ --include="*.tsx" | head
  • Step 2: Add an “Unassigned” lane
Prepend an { 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
Amber background (e.g., bg-amber-50), italic label “Unassigned”, to visually flag it.
  • Step 4: Verify in the dev server
Run: 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
git add ui/src/components/appointments/
git commit -m "feat(calendar): Unassigned lane for null-doctor appointments"

Task C9: Update API docs

Files:
  • Modify: docs/api-reference.md
  • Step 1: Add the new endpoint
### PATCH /appointments/:id/assign-doctor

Reassign or first-time-assign a doctor to an appointment.

**Auth:** any clinic user (admin, doctor, receptionist, superadmin).

**Body:** `{ "doctorId": "<uuid>" }`

**Responses:**
- `200 { ok: true, doctorId }` — success.
- `404` — appointment or doctor not found at this clinic.
- `409 { code: 'CONFLICT' | '...', message }` — target doctor has a scheduling conflict.

**Behavior:** Writes an `appointment.assign_doctor` audit log entry (with `from`/`to` doctor ids) and broadcasts an SSE update on the clinic's appointments channel.
Also add a note under the existing appointment status PATCH:
**Status transitions:**
- `in_progress` is rejected with `{ code: 'UNASSIGNED_APPOINTMENT' }` when `doctor_id` is null.
  • Step 2: Commit
git add docs/api-reference.md
git commit -m "docs(api): document assign-doctor + unassigned status guard"

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
ALTER TABLE app.users
  ADD COLUMN account_type TEXT NOT NULL DEFAULT 'login'
    CHECK (account_type IN ('login', 'visiting'));

CREATE INDEX users_account_type_idx ON app.users(account_type);
  • Step 2: Update the Drizzle schema to expose the column
In server/src/schema/users.ts, after the lastSessionId field, add:
accountType: text('account_type').default('login').notNull(), // 'login' | 'visiting'
  • Step 3: Commit migration (do NOT apply yet)
git add server/drizzle/0052_user_account_type.sql server/src/schema/users.ts
git commit -m "feat(db): add users.account_type column (migration 0052)"
  • Step 4: Stop. Apply migration only with user confirmation.
Report to user:
“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
it('rejects sign-in for account_type=visiting with generic error', async () => {
  const email = `visit-${Date.now()}@example.com`;
  await seedUser({ email, accountType: 'visiting', passwordHash: null });
  const res = await app.request('/auth/signin', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email, password: 'whatever' }),
  });
  expect(res.status).toBe(401);
});
  • Step 2: Add the guard right after fetching the user
if (user.accountType === 'visiting') {
  // Generic invalid-credentials response — avoid email enumeration
  return c.json({ error: 'Invalid credentials' }, 401);
}
  • Step 3: Run tests → PASS
cd server && pnpm test src/routes/__tests__/auth.test.ts -t "visiting"
  • Step 4: Commit
git add server/src/routes/auth.ts server/src/routes/__tests__/auth.test.ts
git commit -m "feat(auth): reject visiting-account sign-in"

Task D3: POST /staff/visiting-doctor endpoint

Files:
  • Modify: server/src/routes/staff.ts
  • Step 1: Locate the middleware guard at staff.ts:72
The existing middleware blocks all non-admin requests. We need a separate sub-router for visiting-doctor that allows receptionists too.
  • Step 2: Write a failing test
it('POST /staff/visiting-doctor creates a no-login doctor as receptionist', async () => {
  const res = await app.request('/staff/visiting-doctor', {
    method: 'POST',
    headers: { 'content-type': 'application/json', /* receptionist auth */ },
    body: JSON.stringify({
      firstName: 'Alex', lastName: 'Visit',
      email: `alex-${Date.now()}@example.com`,
      phone: '+1-555-0100', specialty: 'Orthodontics',
    }),
  });
  expect(res.status).toBe(201);
  const body = await res.json() as { id: string; accountType: string };
  expect(body.accountType).toBe('visiting');

  const [row] = await db.select().from(users).where(eq(users.id, body.id));
  expect(row.passwordHash).toBeNull();
  expect(row.onboardingToken).toBeNull();
});
  • Step 3: Implement — add a route that uses a per-permission auth, not the file’s admin-only middleware
Refactor: move the existing staffRoute.use('*', ...) admin middleware to a named middleware applied per-route, then add the visiting-doctor route with its own permission middleware:
import { requirePermission } from '../middleware/permissions';

const visitingDoctorSchema = z.object({
  firstName: z.string().min(1),
  lastName: z.string().min(1),
  email: z.string().email(),
  phone: z.string().optional(),
  specialty: z.string().optional(),
});

staffRoute.post('/visiting-doctor',
  requirePermission('staff.create.visiting'),
  async (c) => {
    const user = c.get('user');
    const clinicId = c.get('clinicContext')?.currentClinicId;
    if (!clinicId) return c.json({ error: 'No clinic context' }, 400);

    const body = visitingDoctorSchema.parse(await c.req.json());
    const db = getDb();

    // Reject duplicate emails (across visiting + login)
    const [existing] = await db.select({ id: users.id }).from(users)
      .where(eq(users.email, body.email)).limit(1);
    if (existing) return c.json({ error: 'Email already in use' }, 409);

    const userId = crypto.randomUUID();
    await db.insert(users).values({
      id: userId,
      email: body.email,
      passwordHash: null,
      firstName: body.firstName,
      lastName: body.lastName,
      role: 'doctor',
      accountType: 'visiting',
      status: 'active',
      isActive: true,
      isOnboarded: true,
      authProvider: 'visiting',
      clinicId,
      primaryClinicId: clinicId,
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    await db.insert(userClinicAssignments).values({
      id: crypto.randomUUID(),
      userId, clinicId,
      role: 'doctor',
      status: 'active',
      createdAt: new Date(),
      updatedAt: new Date(),
    });

    // Audit
    await db.insert(auditLog).values({
      clinicId, actorUserId: user.id, event: 'staff.create_visiting_doctor',
      payload: { userId, email: body.email },
      createdAt: new Date(),
    });

    return c.json({ id: userId, accountType: 'visiting' }, 201);
  }
);
  • Step 4: Ensure requirePermission middleware exists or create it
ls server/src/middleware/permissions* 2>/dev/null
If missing, create server/src/middleware/permissions.ts:
import type { MiddlewareHandler } from 'hono';
import { getReadDb } from '../lib/db';
import { clinicPermissionTemplates, userClinicAssignments } from '../schema';
import { and, eq } from 'drizzle-orm';

export function requirePermission(key: string): MiddlewareHandler {
  return async (c, next) => {
    const user = c.get('user');
    const clinicId = c.get('clinicContext')?.currentClinicId;
    if (!user || !clinicId) return c.json({ error: 'Unauthorized' }, 401);

    // Admin + superadmin implicitly allowed
    if (user.role === 'admin' || user.role === 'superadmin') return next();

    const db = getReadDb();
    // Per-user override wins
    const [assn] = await db.select({ permissions: userClinicAssignments.permissions })
      .from(userClinicAssignments)
      .where(and(eq(userClinicAssignments.userId, user.id), eq(userClinicAssignments.clinicId, clinicId)))
      .limit(1);
    const userPerm = (assn?.permissions as Record<string, boolean> | undefined)?.[key];
    if (userPerm === true) return next();
    if (userPerm === false) return c.json({ error: 'Forbidden' }, 403);

    // Role template
    const [tpl] = await db.select({ permissions: clinicPermissionTemplates.permissions })
      .from(clinicPermissionTemplates)
      .where(and(eq(clinicPermissionTemplates.clinicId, clinicId), eq(clinicPermissionTemplates.role, user.role as any)))
      .limit(1);
    if ((tpl?.permissions as Record<string, boolean> | undefined)?.[key] === true) return next();

    // Defaults: receptionist has staff.create.visiting by default
    const DEFAULTS: Record<string, string[]> = {
      'staff.create.visiting': ['admin', 'receptionist', 'superadmin'],
    };
    if (DEFAULTS[key]?.includes(user.role)) return next();

    return c.json({ error: 'Forbidden' }, 403);
  };
}
  • Step 5: Run tests → PASS
cd server && pnpm test src/routes/__tests__/staff.test.ts -t "visiting-doctor"
  • Step 6: Commit
git add server/src/routes/staff.ts server/src/middleware/permissions.ts server/src/routes/__tests__/staff.test.ts
git commit -m "feat(staff): POST /staff/visiting-doctor (receptionist-allowed)"

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
After the permissions field in the select object (line ~123), add:
accountType: users.accountType,
  • Step 2: Confirm the WHERE clause doesn’t filter visiting out
The existing filter status='active' on userClinicAssignments is fine — visiting doctors are inserted with status='active'. No change.
  • Step 3: Commit
git add server/src/routes/staff.ts
git commit -m "feat(staff): expose accountType in staff list"

Task D5: AddVisitingDoctorSheet UI component

Files:
  • Create: ui/src/components/settings/AddVisitingDoctorSheet.tsx
  • Step 1: Implement the sheet
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../ui/sheet';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { addVisitingDoctor } from '../../lib/serverComm';
import { toast } from 'sonner';

interface AddVisitingDoctorSheetProps {
  trigger: React.ReactNode;
  onCreated?: (id: string) => void;
}

export function AddVisitingDoctorSheet({ trigger, onCreated }: AddVisitingDoctorSheetProps) {
  const [open, setOpen] = useState(false);
  const [form, setForm] = useState({ firstName: '', lastName: '', email: '', phone: '', specialty: '' });
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addVisitingDoctor,
    onSuccess: ({ id }) => {
      toast.success('Visiting doctor added');
      queryClient.invalidateQueries({ queryKey: ['staff'] });
      queryClient.invalidateQueries({ queryKey: ['clinic-doctors'] });
      setOpen(false);
      setForm({ firstName: '', lastName: '', email: '', phone: '', specialty: '' });
      onCreated?.(id);
    },
    onError: (e: Error) => toast.error(e.message),
  });

  return (
    <Sheet open={open} onOpenChange={setOpen}>
      <SheetTrigger asChild>{trigger}</SheetTrigger>
      <SheetContent side="right" className="sm:max-w-md">
        <SheetHeader>
          <SheetTitle>Add visiting doctor</SheetTitle>
          <p className="text-sm text-muted-foreground">
            Visiting doctors don't get login access. Add their contact info so reception can assign appointments to them.
          </p>
        </SheetHeader>
        <form className="space-y-4 mt-6" onSubmit={(e) => { e.preventDefault(); mutation.mutate(form); }}>
          <div className="grid grid-cols-2 gap-3">
            <div>
              <Label htmlFor="firstName">First name</Label>
              <Input id="firstName" required value={form.firstName}
                onChange={(e) => setForm(f => ({ ...f, firstName: e.target.value }))} />
            </div>
            <div>
              <Label htmlFor="lastName">Last name</Label>
              <Input id="lastName" required value={form.lastName}
                onChange={(e) => setForm(f => ({ ...f, lastName: e.target.value }))} />
            </div>
          </div>
          <div>
            <Label htmlFor="email">Email</Label>
            <Input id="email" type="email" required value={form.email}
              onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} />
          </div>
          <div>
            <Label htmlFor="phone">Phone</Label>
            <Input id="phone" value={form.phone}
              onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} />
          </div>
          <div>
            <Label htmlFor="specialty">Specialty (optional)</Label>
            <Input id="specialty" value={form.specialty}
              onChange={(e) => setForm(f => ({ ...f, specialty: e.target.value }))} />
          </div>
          <Button type="submit" className="w-full" disabled={mutation.isPending}>
            {mutation.isPending ? 'Adding…' : 'Add visiting doctor'}
          </Button>
        </form>
      </SheetContent>
    </Sheet>
  );
}
  • Step 2: Add addVisitingDoctor to serverComm
export async function addVisitingDoctor(payload: {
  firstName: string; lastName: string; email: string; phone?: string; specialty?: string;
}) {
  const res = await fetch('/api/staff/visiting-doctor', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    const body = await res.json();
    throw new Error(body.error || 'Failed to add visiting doctor');
  }
  return res.json() as Promise<{ id: string; accountType: 'visiting' }>;
}
  • Step 3: Commit
git add ui/src/components/settings/AddVisitingDoctorSheet.tsx ui/src/lib/serverComm.ts
git commit -m "feat(staff): AddVisitingDoctorSheet UI"

Task D6: Wire the sheet into StaffManagement

Files:
  • Modify: ui/src/components/settings/StaffManagement.tsx
  • Step 1: Add the button next to “Invite staff”
Find the existing Invite button area (top of the staff list) and add:
<AddVisitingDoctorSheet
  trigger={
    <Button variant="outline" className="gap-2">
      <UserPlus className="h-4 w-4" />
      Add visiting doctor
    </Button>
  }
/>
Import AddVisitingDoctorSheet and UserPlus from lucide-react.
  • Step 2: Render the “Visiting” badge on staff rows
In the row rendering, after the staff name:
{member.accountType === 'visiting' && (
  <Badge variant="secondary" className="text-xs ml-2">Visiting</Badge>
)}
  • Step 3: Hide Resend-invite / Reset-password actions for visiting rows
{member.accountType !== 'visiting' && (
  <>
    <Button onClick={resendInvite}>Resend invite</Button>
    <Button onClick={resetPassword}>Reset password</Button>
  </>
)}
  • Step 4: Verify in dev server
Open Settings → Staff. Confirm: “Add visiting doctor” button visible, sheet opens, creates a doctor, row shows “Visiting” pill, no invite/reset actions on that row.
  • Step 5: Commit
git add ui/src/components/settings/StaffManagement.tsx
git commit -m "feat(staff): wire visiting-doctor sheet + badge into StaffManagement"

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
grep -rn "staff\|Staff" ui/src/components/superadmin/tenants/ --include="*.tsx" | head
  • Step 2: Add the visiting-count split
In the Staff tab header summary, render:
<span className="text-sm text-muted-foreground">
  {totalStaff} staff ({loginCount} login, {visitingCount} visiting)
</span>
Compute 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
git add ui/src/components/superadmin/tenants/
git commit -m "feat(superadmin): visiting-doctor count + badge in tenant staff tab"

Task D8: Permission tree update

Files:
  • Modify: wherever the canonical permission key list is defined (likely server/src/lib/permissions.ts or similar — search)
  • Step 1: Find the permission key list
grep -rn "staff.create\|staff.invite\|permission.*tree\|PERMISSION_KEYS" server/src/ ui/src/ | head -10
  • Step 2: Add staff.create.visiting with description
{ key: 'staff.create.visiting', label: 'Add visiting (no-login) doctors',
  defaults: { admin: true, receptionist: true, doctor: false, superadmin: true } },
  • Step 3: Commit
git add server/src/lib/permissions.ts
git commit -m "feat(permissions): add staff.create.visiting key"

Task D9: Update API docs

Files:
  • Modify: docs/api-reference.md
  • Step 1: Document the new endpoint
### POST /staff/visiting-doctor

Create a visiting (no-login) doctor.

**Auth:** requires `staff.create.visiting` permission (default: admin + receptionist).

**Body:**
```json
{
  "firstName": "Alex",
  "lastName": "Visit",
  "email": "[email protected]",
  "phone": "+1-555-0100",
  "specialty": "Orthodontics"
}
Responses:
  • 201 { id, accountType: 'visiting' } — created.
  • 409 — email already in use.
  • 403 — caller lacks staff.create.visiting.
Behavior: No invitation email. Creates a users row with account_type='visiting', password_hash=null, plus a user_clinic_assignments row. Sign-in attempts return generic 401.

Also note in the existing `GET /staff` section: response now includes `accountType: 'login' | 'visiting'` per row.

- [ ] **Step 2: Commit**

```bash
git add docs/api-reference.md
git commit -m "docs(api): document POST /staff/visiting-doctor"

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_VERSION in ui/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/ or ui/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 accountType from D — D should land first in prod, but the picker handles missing accountType gracefully (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.