Enterprise-grade frontend + service-layer foundation. Auth, org context, RBAC guards, i18n (EN/AR + RTL), and every module route — no business logic (Knowledge/Survey/AI) implemented yet, per scope.
React 18 · TypeScript · Vite · Tailwind CSS · shadcn/ui (Radix) · React Router v6 · TanStack Query · Zustand · Supabase JS
npm install
npm run devNo .env is required to explore the app — with no Supabase project connected, auth automatically falls back to a demo account (see the login screen). To connect a real Supabase project, create .env.local:
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
The app detects this automatically (isSupabaseConfigured in services/supabase/client.ts) and switches from the demo auth service to the real one — no component code changes needed.
src/
app/ — (reserved for app-level composition beyond App.tsx as it grows)
components/
ui/ — shadcn/ui primitives (unmodified Radix wrappers)
shared/ — reusable design-system components (DataTable, StatusBadge, PageHeader...)
layout/ — AppShell, Sidebar, TopNav — presentational shell pieces
theme/ — ThemeProvider (light/dark)
features/ — one folder per business feature; UI + local logic for that feature only
modules/ — domain module *descriptors* (Phase 3 Module Architecture as typed data),
not implementations — a registry future modules attach real logic to
services/ — vendor-specific code lives ONLY here, behind an interface
auth/ — AuthService interface + Supabase/Demo implementations
storage/ — StorageService interface + Supabase/Noop implementations
audit/ — AuditService interface + console placeholder implementation
supabase/ — the one file that imports @supabase/supabase-js directly
store/ — Zustand stores (auth, org context, ui) — client state only
hooks/ — useAuth, useOrgContext, usePermission — the seam between store+service and UI
routes/ — ProtectedRoute, RoleGuard, PermissionGuard
layouts/ — route-level layout wrappers (AppLayout = guard + shell, AuthLayout)
pages/ — thin route-entry barrels, one per route, re-exporting from features/
config/ — nav-config, permissions matrix, i18n dictionary — the "rules," not the UI
types/ — shared TypeScript types (auth, org, nav)
lib/ — cross-cutting utilities (i18n provider, query client, cn helper)
modules/ is the domain boundary — it mirrors Phase 3's Module Architecture table exactly (purpose, inputs, outputs, dependencies, events) as typed data, so the architecture document and the code never drift apart. features/ is the UI boundary — where a module's actual pages/components/hooks live once built. Right now every modules/* descriptor is status: "not_started" or "foundation_only"; wiring one up to real logic later means building out its features/<name>/ folder and flipping its module status, not restructuring anything.
services/auth, services/storage, services/audit each export an interface and swap implementations based on whether Supabase is configured. No component, hook, or store ever imports @supabase/supabase-js directly — only services/supabase/client.ts does. This is what makes "no vendor-specific code outside the service" actually true, not just a comment.
- Auth: login/logout, session persistence via
useAuth, protected routes, demo-mode fallback when no Supabase project is connected. - RBAC:
RoleGuardandPermissionGuardcomponents, a role→permission matrix (config/permissions.ts) mirroring Phase 3's roles table exactly, applied to both sidebar visibility and route content. - Organization Context: Organization → Hospital → Department → Location, global Zustand store (persisted), drives the top-bar switchers and scopes every page.
- i18n: English/Arabic dictionary, RTL layout switching (
dirattribute, logical CSS propertiesms-/me-/ps-/pe-throughout instead ofml-/mr-so the layout mirrors correctly). - Theme: white/gray/blue enterprise palette, light + dark.
- Notification UI, global search UI: presentational only, mock data, no backend calls — matches this phase's scope.
- Routing: every module in the brief has a route and a sidebar entry; unbuilt modules render the shared
PlaceholderPage.
Knowledge, Survey, Playbook business logic, AI — these modules exist only as route placeholders and modules/* descriptors. Audit has a logging interface (services/audit/audit.service.ts) with a console-only placeholder implementation, not a persisted store.
- Role data isn't in Supabase yet.
SupabaseAuthService.loginhas aTODOwhere it should fetchuser_role_assignments(Phase 4) — right now a real Supabase login returns a user with an empty role list, which thePermissionGuards will correctly treat as "no access anywhere." This is expected until the Users/Hospitals module exists to seed real role data. - Bundle size: a single ~650KB JS chunk (see
vite buildwarning). Fine for this stage; worth revisiting with route-based code-splitting once Knowledge/Survey are built out and the app is meaningfully larger. - Demo auth is a real fallback path, not a mock flag — it's a full
AuthServiceimplementation. Worth deciding whether that pattern (auto-fallback when unconfigured) is what you want in production, or whether it should be dev-only (e.g. gated byimport.meta.env.DEV).
Standards, Content Library (Questions/Observation Items), Mappings (approve/reject queue), Gap Analysis, and Documents are now wired to real Supabase queries via services/knowledge/knowledge.service.ts and hooks/use-knowledge.ts — not mock data, when a project is connected. Every page falls back to a clear "connect Supabase" empty state (RequiresSupabaseNotice) when it isn't, rather than erroring. Documents falls back to the earlier mock demo table specifically, since that was already a working showcase.
Still open per the Knowledge Engine spec (Phase 2): the AI ingestion pipeline (OCR → chunking → detection → staging in ai_extractions), semantic search, and Playbook auto-generation from approved mappings. Those depend on the AI Engine, which is out of scope until a provider is chosen.
Start New Survey and the guided session execution screen (/survey/session/:id) are now real, backed by Supabase — not placeholders:
- Playbook generation (
services/playbook/playbook.service.ts): resolves applicable standards for the current department/location (viadepartment_standard_map/location_standard_map), pulls approved Content Library items, and writes a realsurvey_playbooks/survey_playbook_steps/playbook_step_taskstree. - Session execution (
services/survey/survey.service.ts): starts a realsurvey_sessionsrow, and for the Observation activity type specifically, recordssurvey_activities→observations→ optionalevidence→ auto-generatedfindings(Partial/Non-Compliant triggers a Finding, matching the Execution Engine spec's Section 7 logic) — all as one atomic sequence of inserts. - Interview/File Review/Equipment Review/Tracer activity types are resolved and shown in the guided flow (their content pulls correctly) but recording a result for them isn't wired to the database yet — the execution screen shows a clear "not built yet, skip to continue" state for those rather than pretending to save something it doesn't. Observation was chosen as the first complete vertical slice specifically because it's structurally representative of the other four (same SurveyActivity supertype pattern) — extending to the rest is now a repeat of the same pattern, not new design work.
- Finalize Session completes and locks the session — once locked, the database's own trigger (Phase 4) rejects any further writes, not just the UI.
findings.me_id is NOT NULL, but the original content-library tables only linked to a Standard, not a specific MeasurableElement — imprecise, since one Standard can have several MEs. Added migration 20260726090000_content_library_me_linkage.sql (linked_me_id columns + a one-time backfill). Re-validated the full 15-migration chain locally end to end — zero errors.
SupabaseAuthService now actually fetches user_role_assignments (joined to roles) on login/session-restore, instead of returning an empty role list. Every PermissionGuard in the app now reflects real assigned roles once you've added a user_role_assignments row for your account (see step 1 in the setup checklist above).
recordObservation was generalized into recordActivityResult (one function, not four near-duplicates) — the guided execution screen now records real results for Observation, Interview, File Review, and Equipment Review the same way, each writing to its own subtype table (observations/interviews/file_reviews/equipment_reviews) via the shared SurveyActivity supertype pattern. Only Tracer remains unbuilt (it needs its own multi-stop UI, not a single per-item result — Execution Engine spec, Screen 7) and shows a clear skip state rather than a fake one.
Also added the Findings Review screen (Execution Engine spec, Screen 8) between "all steps done" and session finalize: every finding raised in the session is listed with an editable recommendation before the session locks — matching the spec's "surveyor judgment always overrides the system's draft" principle.
services/dashboard/dashboard.service.ts + hooks/use-dashboard.ts query the actual findings and survey_sessions tables for the current hospital: open/critical/major finding counts, findings-by-chapter, active sessions (with a "Resume" link), and a live recent-findings table. Falls back to the mock demo when no Supabase project is connected, same pattern as Documents.
Deliberately not computed: a true "% compliance by chapter." Findings only exist for Partial/Non-Compliant results (per the Execution Engine spec — compliant results never create a Finding row), so the denominator needed for a real percentage (every Measurable Element actually tested this cycle, compliant included) isn't available from findings alone — it would need joining every activity subtype table back through its content-library item to a Measurable Element. What's shown instead is honest: finding counts per chapter, not a fabricated percentage. Flagged in the UI itself, not just here.
Audit: services/audit/audit.service.ts now actually writes to the audit_log table (every auditService.log() call across the app already existed — it was just going to console.info before). New /audit page shows the real, filterable trail. Found and fixed a real RLS gap while building this: the original policy only let a user see their own entries, silently overriding the app's permission model (which grants audit.view to Quality Director/Hospital Admin/Org Admin) — migration 20260729080000_audit_log_rls_broaden.sql fixes it. Re-validated the full 16-migration chain locally, zero errors.
Notifications: services/notifications/notification.service.ts reads/writes the real notifications table. More importantly: recording a Finding that comes out critical (including via the database's own auto-escalation trigger, not just what the surveyor picked) now actually notifies every Hospital Admin/Quality Director/Quality Manager at that hospital — matching Phase 3 Section 8's "critical findings bypass digest batching" rule for real, not just as a UI label. The top-bar bell and /notifications page both read live data now, with mock fallback when no project is connected.
Hospitals: real list + "Add Hospital" wired to Supabase.
Users: real list showing each user's actual resolved roles (joined from user_role_assignments), plus a real Invite User flow — this required a fourth Edge Function (invite-user), because creating an auth user needs the Supabase service role key, same non-negotiable rule as the Anthropic key. Deploy it too:
supabase functions deploy invite-userThe function itself checks the caller's role server-side before letting them invite anyone (hospital_admin/organization_admin/super_admin only) — a real authorization check, not just "logged in."
Not a hypothetical — I actually impersonated an organization_admin against a local Postgres instance and tried to create a hospital the way the app does it. It failed. Twice, for two different reasons:
- The original
hospitalswrite policy checkedhas_hospital_access(id), which resolves a hospital's organization via a fresh lookup — impossible for a row that doesn't exist yet during its own INSERT's check (Postgres command-counter visibility). Fixed with a policy that checks the new row's ownorganization_idcolumn directly. - Supabase's
.select().single()after an insert (whatcreateHospitaluses) triggers an implicit SELECT-policy check viaRETURNING— which hit the exact same lookup problem a second time, on the SELECT policy. Needed its own fix, structurally identical.
Both are in migration 20260729090000_hospitals_insert_policy_fix.sql, and both were verified by actually running the insert as an impersonated organization_admin — once confirming the failure, once confirming the fix, and once confirming cross-organization creation is still correctly blocked. Full 17-migration chain re-validated from scratch, zero errors.
services/supabase/client.ts now checks two sources, env vars first: build-time .env.local (unchanged, still wins if present), then a runtime connection saved via Settings → Organization (stored in localStorage, applied by reloading the app). This matters for anyone testing a build that's already deployed without rebuilding it — paste a project URL + anon key, connect, done. The page also explains, correctly, why the AI provider (Claude) has no configuration UI at all — the API key can never be client-side, full stop, regardless of how convenient a settings field would be.
Real bug, found by actually clicking through the flow in a headless browser rather than assuming a fix worked: useAuth()'s session-fetching useEffect ran on every component that called the hook, not once for the app. After login, ProtectedRoute mounts and calls useAuth() — fine — but TopNav (rendered inside the very children ProtectedRoute is deciding whether to show) also calls useAuth(), and its mount reset the shared loading state back to "loading". That made ProtectedRoute hide its children again — including TopNav — unmounting it mid-fetch, so nothing ever resolved the state back. Genuine infinite oscillation, not a timing fluke.
Fixed by splitting the hook: hooks/auth-bootstrap.tsx now does the one-time session fetch/subscribe, mounted exactly once at the app root (<AuthBootstrap> in App.tsx, above the router). useAuth() itself is now a pure store read + login/logout actions — safe to call from as many components as needed, since it no longer re-triggers the fetch.
Verified by scripting the actual click in a headless browser and checking the result at 1s, 3s, 6s, and 10s after clicking — previously it went blank and stayed blank; now it lands on the Dashboard within ~1s and stays there.
orgService.createDepartment existed since the Users/Hospitals build-out but had no UI calling it — a service function nobody could reach. New page at Settings → Departments & Locations (replacing a duplicate placeholder that overlapped with the real Hospitals page): add departments to the current hospital, expand any department to add/view its locations. Scoped to whichever hospital is selected in the top bar.
services/search/search.service.ts does federated keyword search (ilike) across Standards, Documents, Findings, and Playbooks for the current hospital, in parallel. Wired into both the /search page and the top-bar ⌘K command palette (previously hardcoded shortcuts only) — typing 2+ characters now shows live, debounced, clickable results instead of just static quick actions.
Deliberately not semantic search. document_sections.embedding_vector still exists in the schema unused — this is honest keyword matching, not a fake "AI search" pretending to be smarter than it is. Semantic search is a real follow-up (needs an embeddings call, likely another small Edge Function), not done here.
Guided Tracer is real now: start a patient/system tracer (optional MRN), then add stops one at a time (department, notes) as the surveyor actually walks the path — order isn't forced, since a real tracer often deviates from the expected blueprint sequence. Shows the blueprint's expected stop count as a reference badge, not a rigid constraint.
Found and fixed in the same pass: getResolvedSteps never actually resolved tracer_blueprint tasks — a playbook step referencing one would silently show "Untitled" instead of the blueprint's name/expected stop count. Fixed alongside building the UI that would have exposed it immediately.
Deliberately not built: per-stop Finding generation. A tracer stop doesn't map to one specific Measurable Element the way an Observation does — a tracer inherently touches many standards across its path — so wiring that in would be a real design decision (which ME? picked how?), not a small addition. Flagged rather than guessed at; notes are still recorded per stop either way.
This closes out Survey Execution's four originally-listed gaps (Observation/Interview/File Review/Equipment Review were done earlier, Tracer now) — every activity type in the approved spec has a real, working guided screen.