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
isDoctorcheck in(tabs)/_layout.tsxhid the History tab for doctors dashboard.tsxwas a ~350-line file with anif (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 samegetAuthHeaders()+fetch()wrapper
The migration provides:
- Route-level access control — doctor routes reject patients (and vice versa) before any screen renders
- Clean component boundaries — patient dashboard and doctor dashboard are separate files, not interleaved branches
- Compile-time import enforcement — ESLint prevents
app/(patient)/**from importingcomponents/doctor/* - 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 | null — null 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 screentruebut 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
redirectToprop
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:
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 fromgetTenantOrigin()- 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 usehttpClient.getAuthHeaders(). They remain as rawfetch(). - Video upload in
videoUploadService.ts— usesXMLHttpRequestfor upload progress tracking (not possible withfetch()). 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:
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¶
-
Forgetting group prefixes in router calls — Expo Router typed routes require the full path.
tsc --noEmitwill catch these. -
RoleGuard null check —
isAuthenticatedstarts asnull. Without the explicit null check, the guard redirects during app startup before auth state loads. -
Moving shared styles out of route directories — If a styles file in
app/is imported bycomponents/, move it tocomponents/during migration. Route directories should only contain route files. -
Updating translations — When adding role-aware empty states (e.g., "No patients connected" vs "No doctors connected"), ensure both
locales/en.jsonandlocales/ru.jsonhave the new keys.