Skip to content

Mobile App — Patient/Doctor Role Separation

Overview

The mobile app separates patient and doctor experiences using Expo Router's file-based route groups, a RoleGuard component, ESLint import boundaries, and a shared httpClient singleton. This ensures patients cannot navigate to doctor-only screens (and vice versa), prevents cross-role component imports at lint time, and eliminates duplicated HTTP/auth boilerplate across services.

Why This Architecture

Before this migration, the app had minimal role separation:

  • A single isDoctor check in (tabs)/_layout.tsx hid the History tab for doctors
  • dashboard.tsx was a ~350-line file with an if (userType === "DOCTOR") branch returning entirely different UI
  • There were no route-level guards — any authenticated user could navigate to any screen via deep link or router.push()
  • Every service file (api.ts, clinicalAssessmentService.ts, messagingService.ts) duplicated the same getAuthHeaders() + fetch() wrapper

The migration provides:

  1. Route-level access control — doctor routes reject patients (and vice versa) before any screen renders
  2. Clean component boundaries — patient dashboard and doctor dashboard are separate files, not interleaved branches
  3. Compile-time import enforcement — ESLint prevents app/(patient)/** from importing components/doctor/*
  4. Single HTTP client — one place for auth headers, error handling, and future 401 interception

Route Structure

app/
  _layout.tsx                              # Root: providers + Stack
  index.tsx                                # Auth gate (unchanged)

  (tabs)/                                  # Shared tabs (both roles)
    _layout.tsx                            # Role-aware tab config
    dashboard.tsx                          # Thin dispatcher -> PatientDashboard | DoctorDashboard
    messages.tsx
    history.tsx                            # Patient-only (href: null for doctors)
    manage.tsx                             # Doctor-only (href: null for patients) — recent assessments
    profile.tsx

  (patient)/                               # Patient-only routes
    _layout.tsx                            # RoleGuard: redirects if not PATIENT
    (workout)/[id].tsx                     # Workout execution
    (workout)/video/[videoUrl].tsx         # Fullscreen video player
    plan-management/index.tsx              # AI plan chat
    plan-management/view-plan.tsx          # Current plan viewer
    plan-management/new-plan-preview.tsx   # Preview generated plan
    plan-management/follow-up-questions.tsx # AI follow-up questions

  (doctor)/                                # Doctor-only routes
    _layout.tsx                            # RoleGuard: redirects if not DOCTOR
    (clinical-assessment)/index.tsx        # Start new assessment
    (clinical-assessment)/record.tsx       # Camera recording
    (clinical-assessment)/processing.tsx   # Processing status polling
    (clinical-assessment)/results.tsx      # Assessment results
    (clinical-assessment)/recent-tests.tsx # Assessment history
    (personalized-video)/index.tsx         # Select patient for video recording
    (personalized-video)/exercise-search.tsx # Select exercise
    (personalized-video)/record.tsx        # Camera recording (max 2 min)
    (personalized-video)/success.tsx       # Upload success confirmation

  conversation/...                         # Shared authenticated (both roles)
  ai-workout-builder/...                   # Onboarding (unchanged)
  welcome.tsx / login.tsx / etc.           # Public screens (unchanged)

Key design decision: URL transparency

Expo Router (group) names do not affect URLs. Moving (workout)/[id].tsx into (patient)/(workout)/[id].tsx keeps the runtime URL as /(workout)/123. Deep links remain unchanged.

However, Expo Router's typed routes require the full path including group prefixes in router.push() and router.replace() calls. For example:

// Correct — includes all group prefixes
router.push("/(doctor)/(clinical-assessment)/record");

// Wrong — TypeScript will reject this
router.push("/(clinical-assessment)/record");

This is a common gotcha. When moving routes into groups, search the entire codebase for any router.push/router.replace/href references to the old paths and update them.

Key design decision: Keep (tabs) unified

Both roles share dashboard/messages/profile tabs, so (tabs) stays as one group. Role divergence within tabs is handled by component dispatching — dashboard.tsx delegates to PatientDashboard vs DoctorDashboard based on isDoctor. Expo Router cannot have two route groups defining the same URL, so splitting tabs by role would require duplicating shared screens.

Key design decision: No (public) or (shared) groups

Public screens (welcome, login) work fine at root level. index.tsx already handles unauthenticated redirects. Shared authenticated routes (conversation, messaging) just need "any authenticated user" which the root layout provides. Extra groups with no behavioral difference would be unnecessary churn.

RoleGuard Component

File: components/auth/RoleGuard.tsx

export function RoleGuard({ allow, redirectTo = "/(tabs)/dashboard", children }: RoleGuardProps) {
  const { isAuthenticated, user } = useAuth();

  if (isAuthenticated === null) return null;           // Loading — render nothing
  if (!isAuthenticated) return <Redirect href="/" />;  // Not authenticated — go to welcome
  if (user?.userType !== allow) return <Redirect href={redirectTo} />;  // Wrong role — go to dashboard

  return <>{children}</>;
}

Three-state auth check

isAuthenticated is boolean | nullnull means "still checking AsyncStorage for a token". The guard must handle this explicitly:

  • null → return nothing (prevents flash-redirect during app startup)
  • false → redirect to welcome screen
  • true but wrong role → redirect to dashboard

If you skip the null check, the guard will redirect to / during the initial auth check, causing a visible flash before the real auth state loads.

Usage in layouts

Each role group's _layout.tsx wraps its Stack in a RoleGuard:

// app/(patient)/_layout.tsx
export default function PatientLayout() {
  return (
    <RoleGuard allow="PATIENT">
      <Stack screenOptions={{ headerShown: false }}>
        <Stack.Screen name="(workout)" />
        <Stack.Screen name="plan-management" options={{ presentation: "modal" }} />
      </Stack>
    </RoleGuard>
  );
}

Tests

File: __tests__/roleGuard.test.tsx — 7 tests covering:

  • Renders children for matching PATIENT/DOCTOR role
  • Redirects wrong role to dashboard
  • Renders nothing while auth is loading (isAuthenticated === null)
  • Redirects unauthenticated user to /
  • Supports custom redirectTo prop

Auth Context Types

File: context/AuthContext.tsx

export type UserType = "PATIENT" | "DOCTOR";

interface User {
  id: number;
  email: string;
  permissionForProcessingPersonalData: boolean;
  userType: UserType;
}

The context exposes convenience booleans:

const isDoctor = user?.userType === "DOCTOR";
const isPatient = !isDoctor;

These are derived (not stored) and included in the context value so consumers don't need to repeat the check.

Key design decision: id: number not string

The API returns user IDs as numbers. Messaging types (ConversationParticipant.userId, ConversationListItem.currentUserId) all expect number. Typing User.id as string caused type errors in every comparison. The field is typed as number to match the API contract.

Shared httpClient

File: lib/httpClient.ts

A singleton class wrapping fetch() with:

  • getAuthHeaders() — reads token from AsyncStorage, origin from getTenantOrigin()
  • Typed get<T>, post<T>, put<T>, patch<T>, delete<T> methods
  • Error handling that parses JSON error bodies when available

Before (duplicated in every service)

// services/api.ts — had its own getAuthHeaders + request
// services/clinicalAssessmentService.ts — had its own getAuthHeaders + request
// services/messagingService.ts — had its own getAuthHeaders + request
// context/AuthContext.tsx — had raw fetch() with manual headers
// dashboard.tsx — had raw fetch() with manual headers

After (all delegate to httpClient)

// services/api.ts
async getWorkouts() {
  return httpClient.get<ApiResponse<Workout[]>>("/v2/patient/workouts-today");
}

// services/clinicalAssessmentService.ts
async createAssessment(description: string) {
  return httpClient.post<Assessment>("/v2/clinical-assessment", { description });
}

What still uses raw fetch()

  • Unauthenticated calls in AuthContext.tsx (login, signup, forgot-password) — these run before any token exists, so they can't use httpClient.getAuthHeaders(). They remain as raw fetch().
  • Video upload in videoUploadService.ts — uses XMLHttpRequest for upload progress tracking (not possible with fetch()). The pre-upload API call (/v2/clinical-assessment/.../upload-url) does use httpClient.

Future improvement: 401 interception

The httpClient currently throws generic errors for all non-OK responses. A future improvement would be intercepting 401 responses to clear the token and trigger a logout, rather than having each caller handle expired tokens individually.

Dashboard Split

File: app/(tabs)/dashboard.tsx

export default function Dashboard() {
  const { isDoctor } = useAuth();
  return isDoctor ? <DoctorDashboard /> : <PatientDashboard />;
}
Component File Contains
PatientDashboard components/patient/PatientDashboard.tsx Workout cards, empty state, plan management card, streak, timezone update
DoctorDashboard components/doctor/DoctorDashboard.tsx In-clinic patient cards with Start Test + Record Exercise actions, patient picker, onboard new patient modal

The dashboard file is a 9-line dispatcher. All role-specific logic, state, and API calls live in the respective component files.

ESLint Import Boundaries

File: .eslintrc.js

overrides: [
  {
    files: ["app/(patient)/**/*"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{ group: ["**/components/doctor/*"], message: "..." }]
      }]
    }
  },
  {
    files: ["app/(doctor)/**/*"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{ group: ["**/components/patient/*"], message: "..." }]
      }]
    }
  }
]

This prevents patient routes from importing doctor components and vice versa at lint time. Shared components stay in components/ root or components/navigation/, components/messaging/, etc.

Workout Styles Location

File: components/workout/styles.ts

The shared workoutStyles StyleSheet lives in components/workout/ alongside the 10 component files that import it. The workout route (app/(patient)/(workout)/[id].tsx) imports it via the @/ alias:

import { workoutStyles as styles } from "@/components/workout/styles";

This was moved from app/(workout)/styles.ts during the migration. The style file belongs with its consumers (the components), not with the route.

Key Files Reference

File Role
context/AuthContext.tsx Typed UserType, isDoctor/isPatient booleans, auth state
components/auth/RoleGuard.tsx Route guard component (3-state auth check)
app/(patient)/_layout.tsx Patient route group layout with RoleGuard
app/(doctor)/_layout.tsx Doctor route group layout with RoleGuard
app/_layout.tsx Root layout — registers (patient) and (doctor) groups, wraps stack in TenantFeaturesProvider
context/TenantFeaturesContext.tsx Fetches per-tenant feature flags from API, exposes hasFeature()
services/tenantFeatureService.ts fetchTenantFeatures() API call
hooks/useInClinicPatients.ts Session-persistent in-clinic patient list (AsyncStorage)
app/(tabs)/manage.tsx Doctor-only Manage tab (recent assessments)
app/(tabs)/_layout.tsx Tab layout — hides History for doctors
app/(tabs)/dashboard.tsx Thin dispatcher to role-specific dashboards
components/patient/PatientDashboard.tsx Patient dashboard (workouts, plan management)
components/doctor/DoctorDashboard.tsx Doctor dashboard (clinical assessment)
lib/httpClient.ts Shared HTTP client singleton
components/workout/styles.ts Shared workout StyleSheet (used by 10+ components)
components/doctor/onboarding/OnboardPatientModal.tsx Doctor patient onboarding modal (invite form, QR code)
components/doctor/onboarding/DOBFieldInput.tsx Mobile DOB input with three numeric fields
services/doctorOnboardingService.ts API service for patient invites and QR generation
.eslintrc.js Import boundary enforcement
__tests__/roleGuard.test.tsx RoleGuard unit tests (7 tests)

Verification Checklist

After making changes to role-separated routes:

  • [ ] npx tsc --noEmit — zero errors (typed routes catch stale path references)
  • [ ] npx jest — all RoleGuard tests pass
  • [ ] npx expo lint — no cross-role import violations
  • [ ] Login as PATIENT — workout/plan-management routes work, clinical-assessment redirects to dashboard
  • [ ] Login as DOCTOR — clinical-assessment and personalized-video routes work, workout/plan-management redirects to dashboard
  • [ ] Check router.push()/router.replace() calls include group prefixes (e.g., /(patient)/plan-management/... not /plan-management/...)

Common Pitfalls

  1. Forgetting group prefixes in router calls — Expo Router typed routes require the full path. tsc --noEmit will catch these.

  2. RoleGuard null checkisAuthenticated starts as null. Without the explicit null check, the guard redirects during app startup before auth state loads.

  3. Moving shared styles out of route directories — If a styles file in app/ is imported by components/, move it to components/ during migration. Route directories should only contain route files.

  4. Updating translations — When adding role-aware empty states (e.g., "No patients connected" vs "No doctors connected"), ensure both locales/en.json and locales/ru.json have the new keys.