# Dynamic Navigation — Module Enablement + Permission-Based Nav

**Date:** 2026-07-14
**Status:** Phases 1–4 implemented 2026-07-14 (migration `db/006_app_modules.sql` applied to dev; helpers, `nav_global.php`/`nav_admin.php`, `fn/routes.php` registry + `index.php` gate, admin UI module lists updated; `requireModuleAccess()` applied to 33 wayleave/opportunity/strategy/project endpoints, auth added to the 3 previously-unauthenticated export endpoints, enablement checks retrofitted to the 6 entity load/save endpoints). Phase 5 outstanding.

Phase 4 notes:
- `distance_analysis_process.php` is a CLI worker spawned via `exec()` by the manager — deliberately NOT gated (no session).
- `wayleave_map_load.php`, `strategy_stocklist_distance_analysis.php`, `map_boundary_update.php` have no callers (dead) — left untouched, candidates for `docs/unusedfiles.md`.
- Shared map data loaders (`map_get*.php`, `data_get.php`, `global_search.php`, `dashboard_load.php`, file upload/serve endpoints) are used across modules and stay login-only.
- `project_export_pdf.php`, `project_export_spatial.php`, `opportunity_export_pdf.php` previously had **no login check at all**; they now require login + module read. They still send `Access-Control-Allow-Origin: *` — harmless now auth is required, but could be removed.
- Mode-dependent gates: `opportunity_manage.php` (add/remove/map modes = write, search/summary = read), `distance_analysis_manager.php` (start = write, status/fetch/recent = read).

## Goal

Navigation is currently static and duplicated across pages. Make it dynamic, driven by:

1. **Priority 1 — Module enablement:** a `public` schema table flags each module (Accounts, Projects, Stocklists, Opportunities, Wayleave, Strategy) as enabled/disabled. No admin UI — future scope is control via an API from a "Hub" server.
2. **Priority 2 — User permissions:** within an enabled module, nav links only show if the user has at least `read` permission. Page access (`?do=` routes) must be gated the same way, not just the nav — hiding a link is cosmetic; the route gate is the enforcement.

## Current State (as surveyed)

### Navigation markup

- The global horizontal nav (`<nav class="horizontal-nav">` with Dashboard / Opportunity Builder / Accounts / Stocklists / Projects ▾ / Wayleave / Strategy & Insights ▾ / Admin) is **copy-pasted inline** into each list/dashboard/admin body template. Files carrying a copy of the global nav:
  - `www/html/html_body_dashboard.php`
  - `www/html/html_body_projectlist.php`
  - `www/html/html_body_account_list.php`
  - `www/html/html_body_stocklist_list.php`
  - `www/html/html_body_wayleave_list.php`
  - `www/html/html_body_opportunity_list.php`
  - `www/html/html_body_map_v3.php`
  - `www/html/html_body_landregistry.php`
  - `www/html/html_body_distanceanalysis.php`
- Admin pages (`html_body_admin_home.php`, `_admin_users.php`, `_admin_fields.php`, `_admin_map_layers.php`, `_admin_dist_analysis.php`) carry a **separate admin sub-nav** (Overview / Users & Roles / Map Layers / Distance Analysis / Field Management ▾). This is a different nav and stays separate, but its links should also be permission-filtered.
- Editor pages (`html_body_projectedit.php`, `_account_edit_v2.php`, `_stocklistedit_v2.php`, `_opportunity_edit.php`, `_wayleaveedit.php`) reuse the same `horizontal-nav` / `nav-tab` CSS classes for **internal section tabs** (Overview / Fibre / Journal / …). These are *not* the global nav — do not touch them, but be careful: they share the `#nav-button-container` id and `.nav-tab` class, so any JS written for the global nav must not collide.
- `www/html/nav_main_html.php` (vertical sidebar) is legacy, used only by v1 routes (`network`, `reports`, `wayleavefreehold`, `wayleavetitle`). Out of scope — leave as-is (see `docs/unusedfiles.md` culture of not editing legacy files).
- `www/html/html_header_nav_v2.php` is the top header (brand + global search) — unaffected, except the global search results should eventually respect module access (Phase 5).

### Route gating (`www/index.php`)

`$routePermissions` maps route → permission module, but only covers `projects`, `projectedit`, `accounts`, `accountedit`, `stocklists`, `stocklistedit` and the admin routes. **Ungated today:** `opportunity`, `opportunityedit`, `map`, `wayleave`, `wayleaveedit`, `distanceanalysistool`, `landregistry`, `companalysis`.

### Permission system

- `users.role_permissions (role_id, module varchar(50), permission_level 'read'|'write')` — module is a free string, no CHECK constraint, so new module names need no DDL change (see `sql/admin_area.sql`).
- Helpers in `www/fn/global_functions.php`: `getModulePermission()`, `getItemPermission()`, `permissionSatisfies()`.
- Known module strings (from `www/js/admin_users.js` `MODULES` array): `projects`, `accounts`, `stocklists`, `admin_fields`, `admin_users`, `admin_companies`, `admin_map_layers`, `admin_dist_analysis`.
- Only 9 endpoint files in `www/fn/` currently call the permission helpers (`project_load/save`, `account_load/save`, `stocklist_load/save`, `admin_load/save`). Opportunity, wayleave, map, and strategy endpoints have **no permission checks**.
- The Admin nav link on the dashboard is rendered unconditionally.

## Design Decisions

### Module keys

One canonical set of module keys used by BOTH the enablement table and `role_permissions.module`:

| Module key | Nav items covered | Routes covered | Permission module |
|---|---|---|---|
| `dashboard` | Dashboard | `dashboard` | none — always enabled, always visible |
| `projects` | Projects ▾ (Project Map, Project List) | `map`, `projects`, `projectedit` | `projects` (existing) |
| `accounts` | Accounts | `accounts`, `accountedit` | `accounts` (existing) |
| `stocklists` | Stocklists | `stocklists`, `stocklistedit` | `stocklists` (existing) |
| `opportunities` | Opportunity Builder | `opportunity`, `opportunityedit` | `opportunities` (**new**) |
| `wayleave` | Wayleave | `wayleave`, `wayleaveedit` | `wayleave` (**new**) |
| `strategy` | Strategy & Insights ▾ (Distance Analysis Tool, Land Registry Search) | `distanceanalysistool`, `landregistry` | `strategy` (**new**) |
| `companalysis` | (not currently in nav) | `companalysis` | `strategy` (reuse) |
| `admin` | Admin (right-aligned) | `admin`, `admin_users`, `admin_fields`, `admin_map_layers`, `admin_dist_analysis` | existing `admin_*` modules, unchanged |

Decisions baked in:
- **Project Map lives under `projects`** — the map exists to create/edit projects.
- **`dashboard` and `admin` are not disableable modules.** Dashboard is the fallback landing page for every redirect; disabling it would create redirect loops. Admin visibility is purely permission-driven (any `admin_*` read).
- **Enablement default when a row is missing: enabled.** Fail-open for enablement (it's an ops/licensing flag — a missing row must never lock a client out of a module they use), fail-closed for permissions (as now: no permission row ⇒ no access).

### Access rule (evaluated in this order)

```
page/link visible+accessible ⇔
    module_enabled(module_key)                       -- priority 1, from public table
AND permissionSatisfies(getModulePermission(...), 'read')  -- priority 2, per user
```

Disabled module → link hidden AND route redirects to `index.php?do=dashboard&access_denied=1` (same UX as the existing permission redirect; the dashboard already renders the access-denied alert).

---

## Phase 1 — Database: module enablement table + helpers

### 1a. New table — migration `db/006_app_modules.sql` (created; see `docs/database-migrations.md` for the runner/convention)

```sql
CREATE TABLE IF NOT EXISTS public.app_modules (
    module_key   varchar(50) PRIMARY KEY,
    display_name varchar(100) NOT NULL,
    enabled      boolean NOT NULL DEFAULT true,
    updated_at   timestamptz NOT NULL DEFAULT now()
);

INSERT INTO public.app_modules (module_key, display_name, enabled) VALUES
    ('projects',      'Projects',            true),
    ('accounts',      'Accounts',            true),
    ('stocklists',    'Stocklists',          true),
    ('opportunities', 'Opportunity Builder', true),
    ('wayleave',      'Wayleave',            true),
    ('strategy',      'Strategy & Insights', true),
    ('companalysis',  'Competitor Analysis', true)
ON CONFLICT (module_key) DO NOTHING;
```

Deliberately minimal — no admin UI. Toggling is a manual `UPDATE` for now; the future Hub API will own this table. `updated_at` gives the Hub sync something to compare against.

### 1b. Seed new permission modules (same migration)

Adding `opportunities`, `wayleave`, `strategy` to the permission system silently removes access for every existing role (no row ⇒ no permission). To preserve current behaviour, seed **write** for all existing roles, then admins can dial back:

```sql
INSERT INTO users.role_permissions (role_id, module, permission_level)
SELECT r.role_id, m.module, 'write'
FROM users.roles r
CROSS JOIN (VALUES ('opportunities'), ('wayleave'), ('strategy')) AS m(module)
ON CONFLICT (role_id, module) DO NOTHING;
```

### 1c. PHP helpers (`www/fn/global_functions.php`, next to the existing permission helpers)

```php
/**
 * Returns ['projects' => true, 'wayleave' => false, ...] from public.app_modules.
 * Cached per-request via static. Missing key ⇒ treated as enabled (fail-open).
 */
function getEnabledModules($dbh) { ... static $cache ... }

function isModuleEnabled($dbh, $moduleKey) { ... }

/**
 * Combined check: module enabled AND user has read on its permission module.
 * $permModule defaults to $moduleKey; pass explicitly where they differ
 * (e.g. companalysis → strategy).
 */
function canAccessModule($dbh, $userID, $moduleKey, $permModule = null) { ... }
```

`dashboard` / `admin` are simply never looked up against `app_modules`, so they can't be disabled.

---

## Phase 2 — Centralise the global nav into one partial

**This is the enabling refactor and is worth doing even standalone** — 9 hand-synced copies of the same nav is the root problem.

### 2a. New file `www/html/nav_global.php`

Renders the horizontal nav from a config array so future changes happen in one place:

```php
$navModules = [
    ['key' => 'dashboard',     'label' => 'Dashboard',           'icon' => 'fas fa-home',       'route' => 'dashboard'],
    ['key' => 'opportunities', 'label' => 'Opportunity Builder', 'icon' => 'fas fa-binoculars', 'route' => 'opportunity'],
    ['key' => 'accounts',      'label' => 'Accounts',            'icon' => 'fas fa-city',       'route' => 'accounts'],
    ['key' => 'stocklists',    'label' => 'Stocklists',          'icon' => 'fas fa-list-ul',    'route' => 'stocklists'],
    ['key' => 'projects',      'label' => 'Projects',            'icon' => 'fas fa-project-diagram', 'children' => [
        ['label' => 'Project Map',  'icon' => 'far fa-map',             'route' => 'map'],
        ['label' => 'Project List', 'icon' => 'fas fa-project-diagram', 'route' => 'projects'],
    ]],
    ['key' => 'wayleave',      'label' => 'Wayleave',            'icon' => 'fas fa-file-signature', 'route' => 'wayleave'],
    ['key' => 'strategy',      'label' => 'Strategy & Insights', 'icon' => 'far fa-lightbulb',  'children' => [
        ['label' => 'Distance Analysis Tool',           'icon' => 'fas fa-ruler',    'route' => 'distanceanalysistool'],
        ['label' => 'Land Registry Freeholder Search',  'icon' => 'fas fa-landmark', 'route' => 'landregistry'],
    ]],
];
```

Behaviour:
- Skip a module when `canAccessModule()` fails (dashboard exempt).
- Admin link (right-aligned, `ms-auto`) rendered only when the user has read on any of `admin_fields` / `admin_users` / `admin_companies` — the same check `index.php` already does for the `admin` route.
- Active state derived from `$_GET['do']` (map each route back to its module key so e.g. `projectedit` still highlights Projects). Removes the current per-file hardcoded `active` class.
- Uses `$pdo` and `$_SESSION['id']`, both in scope since body templates are included from `index.php` → `load_file()`.
- Keep the emitted markup **identical** to today's (`nav-tab`, `nav-dropdown`, `nav-dropdown-menu` classes, inline styles) so no CSS/JS changes are needed.

### 2b. Replace the inline copies

In each of the 9 body files listed above, replace the global `<nav class="horizontal-nav" id="nav-button-container">…</nav>` block with:

```php
<?php include __DIR__ . '/nav_global.php'; ?>
```

Per-file quirks to preserve while swapping:
- Dashboard: the `access_denied` alert sits after the nav — untouched.
- Some files put the nav before `<main>`, projectlist has a stray `</main>` before it — replicate current placement exactly; this refactor must be markup-neutral.
- Verify no page's JS binds to the *global* nav via `#nav-button-container` (that id is reused by editor section tabs; the list pages' global nav appears to have no JS behaviour beyond CSS hover dropdowns — confirm during implementation).

### 2c. Admin sub-nav (smaller, parallel change)

Create `www/html/nav_admin.php` from the block in `html_body_admin_home.php`, include it in the 5 admin body files, and hide individual tabs the user lacks permission for (`admin_users` tab needs `admin_users` read; Map Layers + Distance Analysis + Field Management need `admin_fields` read — mirroring `$routePermissions`).

---

## Phase 3 — Single route registry + gating in `index.php`

**Decision (2026-07-14):** rather than a separate `$routeModules` table in `index.php`, consolidate everything about a route into ONE array. Extract `$pageFiles` out of `load_file()` into a new `fn/routes.php` (returning the array) and add two keys per entry:

```php
// fn/routes.php
return [
    'wayleave' => [
        'module'     => 'wayleave',   // enablement key in public.app_modules; null = not disableable
        'permission' => 'wayleave',   // users.role_permissions module; null = ungated
        'head_nav'   => ['html/html_header_nav_v2.php'],
        'nav_html'   => [''],
        'html'       => ['html/html_body_wayleave_list.php'],
        'css'        => ['css/wayleave_list.css'],
        'js'         => ['js/wayleave_list.js'],
        'version'    => ['2'],
    ],
    // ... every route gets exactly one entry: files + gating together
];
```

Route → module/permission assignments (per the module key table above):

| Routes | `module` | `permission` |
|---|---|---|
| `map`, `projects`, `projectedit` | `projects` | `projects` |
| `accounts`, `accountedit` | `accounts` | `accounts` |
| `stocklists`, `stocklistedit` | `stocklists` | `stocklists` |
| `opportunity`, `opportunityedit` | `opportunities` | `opportunities` |
| `wayleave`, `wayleaveedit` | `wayleave` | `wayleave` |
| `distanceanalysistool`, `landregistry` | `strategy` | `strategy` |
| `companalysis` | `companalysis` | `strategy` |
| `admin_users` | null | `admin_users` |
| `admin_fields`, `admin_map_layers`, `admin_dist_analysis` | null | `admin_fields` |
| `dashboard`, login/logout/password routes | null | null |

Both consumers read the same array:
- `index.php` gate (replaces the current `$routePermissions` block, keeping the special-cased `admin` home check):
  1. `module` set and disabled → `Location: index.php?do=dashboard&access_denied=1`, exit.
  2. `permission` set and user fails `read` → same redirect, exit.
- `load_file()` keeps its existing signature but looks the route up in the shared array.

Benefits: a route added without gating is visible at a glance (`'permission' => null`) instead of being a silent omission in a second table. The nav config (`$navModules`) stays separate — it is presentation (labels, icons, grouping, order) covering only ~8 of ~30 routes, and does not belong in the route registry.

Notes:
- The gate must stay **before any HTML output** (it already is).
- New routes being gated for the first time (`map`, `opportunity*`, `wayleave*`, strategy routes) is a behaviour change — safe only because Phase 1b seeded write for all existing roles.
- Considered and rejected: moving the route registry itself into the database. Routes reference code files, so they change in lockstep with deploys; a DB copy breaks atomic deploy/rollback, loses version control, invites environment drift, and turns `include` paths into a DB-sourced LFI surface — for flexibility nothing needs. **Code owns structure, DB owns state** (`app_modules.enabled` is the only genuinely dynamic bit).

---

## Phase 4 — Backend endpoint gating (`www/fn/*.php`)

Hiding nav links and gating pages still leaves the AJAX endpoints open. Extend the existing endpoint-level pattern (as in `project_load.php`) to the uncovered modules:

1. Add a small helper in `global_functions.php`:
   ```php
   function requireModuleAccess($dbh, $moduleKey, $permModule, $requiredLevel) {
       // module disabled or permission unsatisfied → 403 JSON + exit
   }
   ```
2. Apply to the opportunity, wayleave, map, distance-analysis, and land-registry endpoints (`opportunity_*.php`, `wayleave_*.php`, `map_*.php`, `distance_*` / `landregistry_*` files — enumerate with grep during implementation). Load endpoints require `read`; save/delete endpoints require `write`.
3. Retrofit the enablement check into the 9 endpoints that already do permission checks (one extra call each).

This phase is larger and mechanical; it can trail Phases 1–3 without blocking them, but it is the actual security boundary — schedule it, don't drop it.

---

## Company Scoping Hardening (added 2026-07-14, follow-up audit)

Decision: per-item permissions are deprecated in favour of the company-match
model — `(module permission) AND (company 1 OR user.company = record.company_id)`,
NULL `company_id` visible to company 1 only. The admin Item Permissions tab was
removed; backend modes and existing `|| itemPermission` clauses in the six
entity endpoints remain until a later cleanup.

Implemented (migration `db/007_company_scoping.sql` + code):
- `prospector.opportunity` gained `company_id` (backfilled from creator);
  `wayleave.agreements.company_id` backfilled from creator. `opportunity_create`
  and `wayleave_save` now stamp `company_id` on insert.
- The three list views (`vw_accounts_list`, `vw_stocklists_list`,
  `vw_opportunity_list`) now expose `company_id`.
- `data_get.php`: source allowlist (was: ANY existing table readable by any
  logged-in user), `fields` validated per column (was: raw SQL concatenation —
  injection), invalid where-column now exits, company predicate applied to all
  sources with `company_id` (was: projects only).
- `global_search.php`: rewritten — term parameterised (was: injection),
  per-type module enablement + permission filtering (the Phase 5 search item),
  company predicates, deleted wayleave agreements excluded.
- All agreement-scoped wayleave endpoints call `requireCompanyMatch()`
  (new helper in `global_functions.php`); `wayleave_list_load` filters by company.

Added 2026-07-15:
- Record-level company gate in `index.php` for the five edit routes
  (`projectedit&project=`, `accountedit&account=`, `stocklistedit&stocklist=`,
  `opportunityedit&opportunity=`, `wayleaveedit&agreement_id=`): a stale link
  or dashboard recent-item to another company's record redirects to the
  dashboard access-denied alert instead of rendering an editor whose data
  loads are denied. This intentionally does NOT honour item permissions
  (deprecated). `getItemCompanyId()` gained an `opportunity` mapping.
- `db/008_backfill_entity_company.sql`: backfills NULL `company_id` on
  projects/accounts/stocklists from the creator's company; `stocklist_create`
  now stamps `company_id` (project_create_boundary already did; no
  account-creation endpoint exists in the codebase — account rows come from
  imports).

Known remaining gaps (deliberate): `dashboard_load.php` unscoped (full rework
planned separately — its recent-items list may still SHOW other companies'
records, but clicking through now bounces safely); `autocomplete.php`
parameterised but company-unscoped; `reports.reports` has no company column.

## Phase 5 — Follow-ups (explicitly out of initial scope)

- **Global search** (`#globalsearch` in `html_header_nav_v2.php`): its backend should exclude entity types from disabled/no-permission modules so results don't link to inaccessible pages.
- **Dashboard widgets**: charts referencing disabled modules (e.g. project premise volumes if Projects were disabled) — decide whether to hide per-widget.
- **Hub sync**: intended model is a standalone PHP script on an hourly cron/scheduled task that calls the Hub API and updates `public.app_modules` (`UPDATE ... SET enabled = ?, updated_at = now() WHERE module_key = ?`). It never touches code or requires a deploy; the app reads the table fresh per request (per-request cache only), so changes apply on the next page load. Failure modes land safely: Hub unreachable → last-known state persists; Hub sends an unknown module key → row is inert; Hub omits a key the app knows → fail-open default covers it. Treat the Hub response as **authoritative** (overwrite full table state) rather than delta-based — simpler and self-healing.
- **`admin_users.js` MODULES array**: add `opportunities`, `wayleave`, `strategy` so admins can manage the new permission modules in the Users & Roles UI. (Done 2026-07-14, together with the modal rows in `html_body_admin_users.php` and the `$allowed` list in `admin_save.php`.)
- **⚠ Adding a permission module in future touches THREE places** that must stay in sync: the radio rows in `html_body_admin_users.php`'s role modal, the `MODULES` array + labels map in `admin_users.js`, and the `$allowed` allowlist in `admin_save.php::rolePermissionSet()`. The save is delete-then-reinsert, so a module missing from any of these is **silently revoked from a role the next time it's saved**. (Bit us on 2026-07-14: saves made through a stale-cached modal stripped seeded rows.) Consider deriving all three from one server-provided list if modules change again.
- Legacy v1 routes using `nav_main_html.php` (`network`, `reports`, `wayleavefreehold`, `wayleavetitle`): leave untouched unless they're revived.

---

## Implementation Order & Effort

| Phase | What | Size | Depends on |
|---|---|---|---|
| 1 | `public.app_modules` + role seeding + PHP helpers | S | — |
| 2 | `nav_global.php` + `nav_admin.php`, de-duplicate 14 templates | M | 1 (for filtering; the de-dupe itself has no dependency) |
| 3 | Extract `fn/routes.php` route registry + `index.php` gate | S–M | 1 |
| 4 | Endpoint gating | M–L | 1 |
| 5 | Search/dashboard/Hub follow-ups | — | 1–4 |

Recommended commits: Phase 2 de-dupe first as a pure no-behaviour-change refactor (easy to verify by diffing rendered HTML), then Phases 1+3 together (the feature), then 4.

## Test Checklist

- [x] All modules enabled + full-permission user: every page's nav renders identical to pre-change markup (diff rendered HTML).
- [x] Disable `wayleave` in `app_modules`: link disappears from nav on all pages; `?do=wayleave` and `?do=wayleaveedit&id=…` redirect to dashboard with the access-denied alert.
- [x] Delete `strategy` row from `app_modules`: Strategy nav still shows (missing = enabled).
- [x] User whose role lacks `opportunities` permission: Opportunity Builder hidden; direct URL redirected; (Phase 4) `opportunity_load.php` returns 403.
- [x] User with no `admin_*` permissions: Admin link hidden; `?do=admin` redirect still works (existing behaviour).
- [x] Editor pages (`projectedit`, `stocklistedit`, etc.): internal section tabs unaffected.
- [x] Active-tab highlighting correct on every route, including edit routes highlighting their parent module.
- [x] Login/logout/password routes unaffected (they bypass the default switch case).
- [x] Existing roles retain access after the migration (seeded `write` rows present).
