# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**GeoLynx** is an enterprise PHP web application for telecommunications fibre network planning and wayleave management. It combines interactive GIS mapping, project lifecycle management, wayleave agreement tracking, and strategic distance analysis.

## Tech Stack

- **Backend:** PHP 7+ with PDO (PostgreSQL), no Composer/autoloading
- **Database:** PostgreSQL 13+ with PostGIS and pgRouting extensions
- **Frontend:** Vanilla JS + jQuery 3.6, Bootstrap 5, OpenLayers for all maps (local copy in `/www/lib/ol/`) — no other mapping library is in use
- **Key libraries:** Tabulator.js (data tables), Cytoscape.js (network topology), Chart.js (analytics)

## Development Setup

No build system — all JS/CSS is hand-edited and served directly. No package.json, webpack, or test framework.

**No local runtime.** The development machine is an editing environment only — it has no PHP, no web server and no database, and none are wanted. The IDE uploads to the dev server on save, and all running, testing and UAT happen there. So `php -l`, `php db/migrate.php`, and anything that needs the app or the database to be running cannot be executed locally — don't probe for them. Verify PHP by reading, and ask for the error text and server log when something breaks.

One consequence worth knowing: brace/paren balance checks are the usual stand-in for a lint, and they cannot see cross-file symbol resolution. When adding a function called from more than one endpoint, confirm it lives in a file all of its callers include, and that it is defined exactly once — a helper defined in one endpoint and called from another is a fatal that only appears in the browser.

**Database connection** is configured in `www/fn/db.php` (the `localhost` there is the dev server, not the editing machine)

**Schema restore (fresh install):**
```bash
psql -U postgres -d netplanner -f sql/netplanner_schema_20260706.sql   # baseline
php db/migrate.php --apply                                             # migrations since baseline
```

**Schema changes** are numbered migrations in `db/NNN_*.sql`, applied by `php db/migrate.php` and tracked per-database in `public.schema_migrations` — see `docs/database-migrations.md`. Migrations are immutable once applied; `db/archive/` holds ones already folded into the baseline.

Server requirements: PHP 7+ with `pdo_pgsql`, PostgreSQL with PostGIS + pgRouting, Apache/Nginx pointing to `/www`.

## Architecture

### Request Routing

`www/index.php` receives all requests via `$_GET['do']` parameter and delegates to `www/fn/global_functions.php::load_file()`, which maps route names to their HTML template, JS file, CSS file, and nav component. Example routes:

| Route (`?do=`) | Purpose |
|---|---|
| `dashboard` | Home dashboard |
| `projects` | Project list |
| `projectedit` | Project editor (v2) |
| `accounts` | Account list |
| `accountedit` | Account/wayleave editor (v2) |
| `stocklists` | Stocklist list |
| `stocklistedit` | Premises stocklist editor (v2) |
| `wayleaves` | Wayleave list (renamed from `wayleave` 2026-08-11 with the module key; no alias kept) |
| `wayleaveedit` | Wayleave editor |
| `opportunities` | Opportunity list (renamed from `opportunity` 2026-08-05; no alias kept) |
| `opportunityedit` | Opportunity editor — Main Details + the prospecting tools |
| `map` | Interactive OpenLayers map (loads `map_v5.js`) |
| `distanceanalysistool` | Strategic distance analysis |
| `landregistry` | UK Land Registry search |
| `admin` | Admin home — module × area configuration matrix |
| `admin_users` | User management |
| `admin_fields` | **Module Management** — one module's sections, categories, fields, statuses and dashboard settings (`&module=`, `&area=`) |
| `admin_dashboard` | Legacy standalone dashboard config; now an area of `admin_fields`, kept for existing links |
| `admin_map_layers` | Map layer management |
| `admin_dist_analysis` | Distance analysis configuration |
| `companalysis` | Competitor analysis |

### Directory Structure

- **`www/fn/`** — Backend PHP: `db.php` (DB config), `login_check.php` (auth), `global_functions.php` (router + utilities), plus ~50 AJAX endpoint files (`*_load.php`, `*_save.php`, `map_*.php`, etc.)
- **`www/html/`** — HTML templates (`html_body_*.php`) that render the page shell; include inline `<script>` bootstrapping
- **`www/js/`** — Page-specific JS modules. Large files: `project_edit_v2.js` (439KB), `stocklist_edit.js` (206KB), `opportunity_edit.js` (194KB), `map_v5.js` (141KB)
- **`www/css/`** — Page-specific CSS. LESS/SCSS sources exist in `www/less/` and `www/scss/` but are not compiled by any toolchain — edit the `.css` files directly
- **`www/lib/ol/`** — Local OpenLayers copy

### Data Flow

Frontend JS makes AJAX POST/GET calls to `www/fn/*.php` endpoints, which execute PDO queries against PostgreSQL and return JSON. No REST framework — each endpoint is a standalone PHP file.

### Database Schemas (PostgreSQL)

14+ schemas: `projects`, `accounts`, `stocklists`, `opportunity`, `wayleave`, `landregistry`, `openreach`, `ordnancesurvey`, `strategy`, `inventory`, `competitor_data`, `reports`, `users`, `tasks`, `public`. The `opportunity` schema was split out of `prospector` by migrations 024–030 (`docs/2026-08-04-opportunity-module-alignment.md`); what remains in `prospector` is the network planner's spatial data, which belongs to project planning, and the four `prospector.*_migrated_20260804` tables kept as a restore point. The `wayleave` schema was rebuilt by migrations 031–041 (`docs/2026-08-10-wayleave-module-realignment.md`): the entity noun became `wayleave` throughout (`wayleave.wayleaves`, not `agreements`), the module key went plural, and the status, entity, coverage, meta and EAV tables were recreated to the common shape. Migrations 042–055 then replaced the coverage model itself (`docs/2026-08-13-wayleave-coverage-approvals.md`) — see **Wayleave coverage** below. Full DDL is in `geolynx_ddl.sql`. Every entity has a corresponding history/journal table for audit trail.

### Field Meta System (static + dynamic)

Projects, accounts, stocklists, opportunities and wayleave agreements render **all** editor form fields — static and dynamic — from their `*_fields` meta table, served by `fn/get_update_form.php` (`?type=project|account|stocklist|opportunity|wayleave`) and rendered by the editors' shared field loop plus the autocomplete builders in `main.js` (`buildAutocompleteField` / `initAutocompleteFields`). Opportunities were built to the common naming from the start (024–030); wayleave was rebuilt to it by 031–041.

**The opportunity editor uses `renderMetaForm()` / `populateMetaForm()` in `main.js`** — the sections → categories → fields → options → populate pipeline written once, rather than the private copy the other three editors each carry. New work on that pipeline belongs there; those three are the ones still to be collapsed into it (see the shared-renderer item in `docs/improvement-opportunities.md`). The per-module save-path helpers have the same split: `metaFieldLookup()` / `metaFieldValueChanged()` / `metaStaticValueChanged()` are config-driven from `fieldMetaModuleConfig()`, and `checkOpportunityEdit*()` are one-line wrappers over them, while `check{Project,Account,Stocklist}Edit*()` remain hand-copied.

**Adding a module to `fieldMetaModuleConfig()` or `dashboardModuleMap()` switches consumers on** — it is not an inert registration. `dashboard_load.php` immediately starts walking the new module through five small per-module maps, and `admin_load.php`'s `moduleOverview()` through a label map; a module present in the config but missing from those emits a PHP warning into the JSON body, which breaks the whole response. Land them in the same change.

Two field types (`field_type`):

- **`static` (system fields):** presentation (section, category, order, label, autocomplete wiring) lives in the meta row; **storage stays code-defined** — a real column on the entity table, whitelisted in `$staticFields` in `*_save.php` and returned by the load views. Defined by migrations only (`db/012`–`015` seeded them); the admin fields page lists them read-only ("System" badge) and `admin_save.php` rejects edit/toggle/delete. Templates keep only the entity-id hidden input — the editor pages' Main Details markup is gone.
- **`dynamic`:** admin-created, stored via EAV in five per-type value tables: `*_field_values_text`, `*_field_values_int`, `*_field_values_numeric`, `*_field_values_date`, `*_field_values_boolean`.

Autocomplete metadata (set at field creation, never changed — stored values are bare IDs):
- `field_autocomplete_type` — one of the `data-autocomplete` lookup types (`usernames`, `companyname`, `accountname`, `projectname`, `stocklistname`, `wayleavename`, `landregtitle`); `field_autocomplete_helper_id` — form id of the visible label input (the field's own `field_form_id` is the hidden ID input).
- On load, `resolveAutocompleteLabel()` / `getAutocompleteLabelMap()` (`global_functions.php`) turn stored IDs back into labels, returned as `data_autocomplete_labels`; labels already served by a load-view alias are skipped.
- `*_save.php` skips helper-id POST keys outright; `admin_save.php` enforces form-id/helper-id uniqueness in both directions (plus unique indexes on `field_form_id`, migration 009).

Dropdown options come from either `*_field_dropdown_options` rows (admin-defined lists) or `field_options_source` — a key into a PHP whitelist in `get_update_form.php` mapping to canonical tables (`project_status`, `account_status`, `stocklist_status`, `opportunity_status`, `wayleave_status`); used where the option values are consumed outside the form (list views, audit queries) so there is a single source of truth.

`global_functions.php` save-side helpers:
- `check*EditField($dbh, $fieldName)` — looks up a field's type/ID by its POST key
- `check*EditCurrentValue($dbh, $fieldID, $entityID, $dataType, $value)` — returns bool (did the value change?)
- `check*EditCurrentValueStatic($dbh, $field, $entityID, $value)` — same check for columns in the entity's main table

Save endpoints iterate POST data, route each key through these helpers (static whitelist first), then write to the entity column or typed value table and call the corresponding `*_journal_save.php`.

### Admin Structure

Admin has two axes — the configuration **area** and the **module** — and module comes first.
Everything module-scoped lives on one page, `?do=admin_fields&module=<m>`, titled *Module
Management*: a module segmented control across the top and the areas down a left rail, in
build order — **Sections, Categories, Fields, Statuses, Dashboard**. Fields is listed third
but selected on load, since that is what people come for. `&area=<key>` deep-links to an
area, and the module links carry it so switching module keeps your place.

The rail is Bootstrap **pills** (`role="tablist"` on the rail, `role="tab"` on each button),
so `shown.bs.tab` drives the lazy loading and the add-button visibility.

Not module-scoped, so still top-level: Users & Roles, Map Layers, Distance Analysis.
`?do=admin` is a module × area matrix showing what each module has configured, fed by
`admin_load.php`'s `module_overview` mode.

**Areas can be absent, and that is normal.** Statuses and Dashboard exist only for modules in
`dashboardModuleMap()`. All five modules are in it since migration 031 gave wayleave's
status table the canonical shape, so no module currently lacks an area — but the
unavailable state remains, because a new module can reach Module Management before its
status table exists. It explains why in the pane rather than sitting greyed with no reason.

**Statuses** (`docs/2026-08-03-admin-status-management.md`) are admin-managed per module:
add, rename, reorder, retire, and assign a **style token** from `statusStyleRegistry()` in
`global_functions.php` — a key, never a colour, with each entry declaring its own text ink.
Styles flow to the editor header badges and the dashboard stage bar, always keyed on **status
id**, never on the label. A status in use cannot be deleted, only deactivated; the status new
records default into (migration 023's `Created`) can be neither deleted nor deactivated, a
rule derived from the column default via `statusDefaultId()`.

**Sub-categories are hidden, not removed** — absent from the rail and the field modal, with
the column, tables and `admin_save.php` modes left in place.

### Wayleave Coverage

What a wayleave covers is `wayleave.wayleave_coverage` — **one row per premise per claiming
source**, and a premise is covered if *any* of its rows is `approved`. Read it through
`wayleave.vw_wayleave_coverage`; a premise claimed twice appears twice, so anything wanting a
count needs `count(DISTINCT uprn)`.

Four sources feed it — a drawn polygon, a directly added UPRN, an attached stocklist, a
linked Land Registry title — and **all four go through approval**. Nothing becomes coverage
because it was attached.

**Pending is derived, not stored.** Only a direct UPRN writes a `pending` row; a polygon that
grew or a stocklist that gained premises writes nothing at all. `vw_wayleave_pending` diffs
each source's live resolution against the approved rows, which is why coverage can never go
stale and never changes without a decision. Counting `wayleave_coverage` for outstanding work
gives the wrong answer.

Row states, and the two flags that are not states:

- `pending` proposed · `approved` covered · `rejected` addition refused · `removed` removal
  approved. Nothing is deleted — a tombstone stops the diff re-raising a decided change, and
  gives a reversal something to restore.
- `withdrawn` is a **request** to remove a direct claim; `retained` is the **answer** that a
  refused removal keeps the premise. A retained premise is simply covered, on its source's
  layer — it is not a third category. Asking again clears the previous answer.

**Decisions are immutable and record their own membership** (`uprn_added_list` /
`uprn_removed_list`, migration 054). `wayleave_coverage.approval_id` says which decision
*currently owns* a row, so it is overwritten by the next one and cleared by a reversal —
never read it as history. `uprn_added`/`uprn_removed` mean *left covered* and *left not
covered*, for every action.

**The source views must stay filterable.** `WHERE wayleave_id = ?` has to reach the base
tables, so they are `UNION ALL` of *what the source produces now* and *decided rows it no
longer produces*, each taking `wayleave_id` from a real column. A `FULL JOIN` with a
`COALESCE`d key cannot be pushed into (048): it computed every wayleave in the database and
filtered afterwards, and the resulting cost estimate was high enough to trigger JIT for 598ms
of a 675ms query.

Endpoints: `wayleave_approvals_load.php` (the tab), `wayleave_approval_decide.php`
(approve/reject/reverse, gated on the `wayleaves_approve` pseudo-module),
`wayleave_approval_detail.php` (premise lists), `wayleave_badge_counts.php` (the two counts
that cost a spatial query, fired after render so editor load stays table-reads-only).

### Project Premises

What a project contains is `projects.project_premises` — **one row per premise per project
per source**, materialised from the boundary rather than resolved live. It replaced the
`ST_Intersects(projects.geom, abp.geom)` join that nine queries ran, one of them for every
project at once on every project-list load (`docs/2026-08-17-project-premises-uprn-table.md`,
migrations 056–059).

`state = 'approved'` is the live set; `removed` is a tombstone, so a premise that leaves a
boundary and comes back keeps the `proposed_datetime` it first arrived with. The refresh is
a trigger on `projects.projects` firing on **`geom` or `parent_project_id`** — re-parenting
changes a lineage's validity with no geometry change — and all three callers share
`projects.refresh_project_premises()`. **A refresh re-flags the UPRNs held by the project
*and every descendant*, not just its own** (migration 059): a project's parent decides the
lineage of everything beneath it, so scoping to the project alone left a grandchild's
premises carrying flags from before they were in that lineage, which read as a phantom
overlap. Setting a parent for the first time is enough to hit it — it is not only about
moving a project. `sql/project_premises_resync.sql` is the backstop for
boundaries written by manual SQL, and is **step 4 of `sql/abp_sync.sql`**, because an address
refresh puts new-builds inside existing boundaries with no boundary change.

**What to count.** `is_valid` is the only stored flag: this project is the lowest level in
its own lineage containing the UPRN. Several projects can hold it for one UPRN, and that is
not a bug — it means unrelated boundaries overlap. `claimant_count` and `hierarchy_role` are
derived in `projects.vw_project_premises` / `vw_premise_claimants`. **Count `is_valid` per
project; no column sums cleanly estate-wide, and `contested_premises` is the size of the
discrepancy. Counting the raw table double counts every hierarchy.** Unrelated overlap is
counted in both projects and flagged, never tie-broken — picking a winner would hide a
boundary error behind an arbitrary answer.

The UI names these by where a premise is counted, not by what the viewing project is:
`is_valid` reads as **This Project**, its inverse as **Sub-projects**, and
`contested_premises` as **Overlap** (Dave, 2026-08-28). Two faults were found and fixed
during that build, both worth not repeating. The chips were first labelled Child/Parent
after the viewing project's role, which in a three-deep chain put *Parent* on the middle
project and read as though its own parent served those premises. They were then keyed on
`hierarchy_role`, which only carries a value where an **ancestor** also holds the premise
— so the top project of a chain could never be chipped for premises it counts while the
one below it was, the same fact labelled two ways depending on where you stood. **The
premises table therefore keys its chip on `is_valid`, not `hierarchy_role`**, and drops the
column entirely for a project with no parent and no children, where nothing could be
counted anywhere else. `hierarchy_role` stays in the view and is not currently read by
anything.

**Nothing here reads a project status**, deliberately, so the status-semantics work is
independent of it. The approval flow is not built: every row lands `approved`, and `state`'s
CHECK already allows `pending` and `rejected` so switching it on needs no constraint change.

**The read views must stay filterable**, the same rule as wayleave's source views.
`vw_premise_claimants` aggregates every UPRN in the database, so it is right for estate-wide
questions and wrong behind a `WHERE project_id = ?` — Postgres plans a subquery once and
never builds a parameterised path for it. Filtered consumers ask the same question
correlated or via `LATERAL`: `vw_project_premises`, `stocklist_load.php` and
`stocklist_premises_geom` all do. `vw_projects_list` is the exception in the other
direction — its `prems` CTE sits behind a `LEFT JOIN`, so no filter can reach it and it is
always computed in full; set-based is correct there.

**The map layer is `projects.vw_project_premises_map`** (migration 063,
`docs/2026-08-31-project-premises-map-layer.md`), registered as *Project Premises* in the
project editor's Project Layers group and **off by default**. It shows the viewing
project's whole **lineage** — itself, its ancestors and its descendants — one row per
premise per viewing project, so opening any project in a 1-2-3 chain shows all three
projects' premises. Siblings and cousins are not included; seeing an adjacent project's
premises is a separate want, logged in `docs/improvement-opportunities.md`.
`premise_level` is the style key and says **where the premise is counted**, not what the
viewing project is: `this_project` (held here and `is_valid`), `sub_project` (counted
below — which includes the viewing project holding it with `is_valid` false, since that
is the same fact), `parent_project` (only a project above holds it). The matching
`projectPremiseStyles` must exist in `pointStyleManager`; a missing key throws and takes
the editor map down, and an unresolved value renders the points invisibly. It is the most
correlated of these views — driven off `projects.projects` with a `LATERAL` — so it is
right behind a `project_id` filter and **wrong unfiltered**; use `vw_project_premises` or
`vw_premise_claimants` for estate-wide questions.

### Project Delete & Restore

Deleting a project sets `projects.projects.is_deleted` (migration 061). Nothing is ever
erased: the row, its journal, history, attachments and premises tombstones all stay, so
every audit trail and label lookup still resolves. `docs/2026-08-28-project-delete-restore.md`
is the full design.

**A project must be hierarchy-free to be deleted** — no parent, and no other project
naming it as parent. The user removes those links first. That single rule is what keeps
the rest simple: a deleted project can never be a parent or a child, so there is no
dangling `parent_project_id`, no restore ordering, no orphan case, and no lineage query
that has to reason about visibility. `vw_parent_project_links`, `vw_project_ancestors` and
`vw_projects_list`'s `in_hierarchy` therefore need no `is_deleted` filter at all — a
deleted project cannot appear in any of them. Live `opportunity.opportunity_project_link`
rows block a delete too, and `opportunityAddOne()` refuses to link a deleted project, which
is what stops that state being manufactured after the fact.

**`projects_approve` ("Project Approver") is a pseudo-module, not a module.** Enablement is
checked against the real `projects` module and only the permission against the pseudo-key:
`requireModuleAccess($pdo, 'projects', 'projects_approve', 'write')`, the same shape
`wayleaves_approve` uses. **write** deletes and restores; **read** sees the archive with
Restore disabled. It is additive — deleting also needs ordinary write on the project. Like
`wayleaves_approve` it gets no `public.app_modules` row, no `routes.php` entry, and no
entry in `fieldMetaModuleConfig()` or `dashboardModuleMap()`.

**Deletion tombstones the project's premises through the existing trigger.** A deleted
project reports a NULL boundary inside `refresh_project_premises()`, so it takes the
no-boundary branch and every `source = 'boundary'` row goes to `state = 'removed'`, with
the `is_valid` re-flag running as it does for any other change. Restore rescans and revives
them, `proposed_datetime` untouched. This is why **anything reading projects through
`project_premises` filtered `state = 'approved'` needs no `is_deleted` filter of its own** —
all four project joins in `stocklist_load.php`, and the premise/validity/rival-claimant
queries in `project_load.php`. That holds because `project_premises.source` has
`CHECK (source IN ('boundary'))` and the refresh function is its only writer; if a second
source is ever added, revisit those.

**Two read surfaces must keep resolving deleted projects, and must not be "fixed":**
`admin_load.php`'s item-permission label lookup, and `resolveAutocompleteLabel()` /
`getAutocompleteLabelMap()` for `projectname`. Soft delete exists so a stored id always
renders a name.

**The map is configured in data, not code.** `public.map_layers` rows carry
`database_schema` / `database_table` and a `url` naming a geotable, so grepping `www/`
cannot tell you what the map serves — query that table. Four layers touch projects:
`vw_projects_geom` and `project_nearest_neighbours` are filtered inside the views by
migration 062 (both sides of the neighbours view, since a deleted project can be the
subject *or* somebody's neighbour); `projects.projects` served raw through
`map_get_v2.php` is filtered by a relation-keyed predicate there; and the GeoServer WMS
layer over `projects.projects` is filtered in GeoServer. Filtering in a view rather than
the endpoint is preferred — it is then true for GeoServer and every other reader.

**Adding `is_deleted` to a table can break a query that never mentions it.**
`opportunity_manage.php`'s nearby-projects CTE selected `projects.*` alongside
`o.is_deleted`, and the new column made the later bare `is_deleted` references ambiguous.
After adding a column to an entity table, grep for star-selects of it.

**Rolling this out to another module** — `docs/module-delete-restore-pattern.md` is the
reference: the settled decisions, a per-module derivation checklist (what blocks a delete,
which map layers, which reads must keep resolving deleted records), and the traps. Work
through it rather than copying the projects plan, whose substance is projects-specific
answers.

Endpoints: `project_archive.php` (`check` / `delete` / `restore` / `list`), and
`isItemDeleted()` / `requireNotDeleted()` in `global_functions.php` — the latter refuses
with the same generic `'No Access'` every other gate emits, deliberately: saying a record
was *deleted* would confirm to an unauthorised caller that it exists.

### Permission System

Two-tier RBAC in `global_functions.php`:
1. **Module-level:** `getModulePermission($dbh, $userID, $module)` — resolves highest permission (`write` > `read`) via `users.user_roles` → `users.role_permissions`
2. **Record-level:** `getItemPermission($dbh, $userID, $itemType, $itemID)` — per-record override in `users.user_item_permissions`
3. `permissionSatisfies($permission, $requiredLevel)` — combines both tiers for an access decision

**Pseudo-modules** are permission keys with no module behind them, used for capabilities that
sit on top of a module rather than beside it: `wayleaves_approve` (approve coverage) and
`projects_approve` (delete/restore a project). They are listed in `admin_save.php`'s
`$allowed`, in `admin_users.js`'s `MODULES` **and its roles-list label map**, and as a row in
`html_body_admin_users.php`. They get no `public.app_modules` row, no `routes.php` entry and
no place in `fieldMetaModuleConfig()` / `dashboardModuleMap()` — enablement is always checked
against the real module: `requireModuleAccess($pdo, '<module>', '<module>_approve', 'write')`.

### Key Patterns

- **Versioned files:** Some modules have v1/v2/v3/v5 variants. The highest version is current; lower versions are legacy (see `docs/unusedfiles.md`).
- **Journal logging:** All save operations call a corresponding `*_journal_save.php` to record changes.
- **Shared file-upload JS:** `js/project_edit_v2_fileuploads.js` is loaded alongside `projectedit`, `accountedit`, `stocklistedit` and `opportunityedit` routes — not just project editing. It builds `new bootstrap.Modal(document.getElementById('uploadModal'))` at `DOMContentLoaded`, so **any page that loads it must carry the `#uploadModal` markup** or it throws on `backdrop` and the whole attachments panel is dead.
- **Cover images are entity-aware:** `image_upload.php` takes an `entity` POST key (`project` default, plus `opportunity`) and writes `cover_image_url` on the matching table. Files land in `/var/www/netplanner-files/<entity-path>/<id>/`; `serve_image.php` validates path shape rather than an entity allowlist, so it needs no change per module.
- **Spatial operations:** PostGIS used for geometry storage and spatial queries; pgRouting used in `project_auto_route.php` for automated cable path calculation.
- **PDF export:** `project_export_pdf.php` delegates to QGIS for cartographic rendering.
- **Server-side JS vars:** `www/fn/global_vars.php` injects PHP-side configuration into the page for JS consumption.

### Database Connection

`www/fn/db.php` — local dev connects to `netplanner` on `localhost:5432` as `postgres`. A prod AWS RDS connection (`gistest.crgoyftp3cxo.eu-west-2.rds.amazonaws.com`, db `gis`) is defined but commented out. The `$pdo` variable is the active handle used by all AJAX endpoints.

## Documentation Files

All in `docs/`:
- `plan-template.md` — **Template for all new plan docs** (`docs/YYYY-MM-DD-*.md`): header status block, Plan Phases list, Build sections, testing checklist. Keep `Status` / `Phases Complete` updated as work progresses; mark finished phases with ✅.
- `module-delete-restore-pattern.md` — **Reference for rolling delete/restore out to a new module.** Settled decisions, a per-module derivation checklist, the traps, and the UI copy rules. Not a plan — each module gets its own short plan doc from `plan-template.md`.
- `2026-08-28-project-delete-restore.md` — the projects delete/restore build (migrations 061–062); the worked example behind the pattern doc
- `2026-08-13-wayleave-coverage-approvals.md` — the coverage/approval model above (migrations 042–055)
- `improvement-opportunities.md` — Running checklist backlog of improvements spotted during other work; tick items off when done, promote big items to their own plan doc.
- `features.md` — Feature overview
- `unusedfiles.md` — Legacy/unused files to avoid editing
- `admin_area.md`, `task_system_plan.md`, `benefit_analysis.md`, `login_sharing_protection.md`, `plan_os_usage_counting.md` — Feature design docs
- `archive/superpowers/` — **Superseded, historical record only.** Plans/specs from the original wayleave build, whose design diverged from the projects module. Never use these as a pattern source. That divergence cost three rewrites; the realignment is `docs/2026-08-10-wayleave-module-realignment.md`, and the module now follows the projects/opportunity patterns.

`sql/netplanner_schema_20260706.sql` — Baseline PostgreSQL schema (fresh-install restore point; includes migrations 000–005). `sql/geolynx_ddl.sql` is an older dump, superseded — do not use for new installs.

`sql/util_delete_dynamic_field.sql` — **dev/test utility, not a migration.** Hard-deletes one dynamic field and everything stored against it (history, values, dropdown options, meta row) in the order the foreign keys require. Defaults to a dry run; refuses static fields. Deliberately in `sql/` and not `db/`, because `migrate.php` globs `db/*.sql` and would apply it as a migration. On production, deactivate the field in admin instead — that keeps the audit trail.
