# 0002. Staff attendance locations admin page

**Date**: 2026-10-26
**Status**: Accepted
**Amended**: 2026-10-31 (spec 0006 owns Staff attendance nav group membership: Locations, Settings, and Enrollments)

## Summary

Admins set named school places on a map, each with a center and a radius in meters. Staff later pass if they stand inside any active place. This page lists those places, lets you add and edit them on an OpenStreetMap map (free map tiles, no key), and can copy the same place into other schools on your switch list. The table from spec 0001 is unchanged.

## Requirements

**User stories**:
- As an admin, director, or backend user, I want to add, edit, turn on or off, and remove attendance places for the current school so later check in can test GPS against them.
- As an admin of several schools, I want to save one place into the schools I pick so I do not redraw the same gate once per branch.
- As an admin on a phone, I want a map with search, my location, a draggable pin, and a live radius circle so I can see overlap before I save.

**Acceptance criteria** (the contract, each criterion is IDed and independently checkable):
- **AC-1**: A user whose session `table_name` is `admin`, `director`, or `backend`, or whose `$info['status']` is `admin`, can open `app/admin/staff_attendance_locations.php` from the Locations tile in the Staff attendance nav group. Spec 0006 AC-2 owns that group (Locations, Settings, and Enrollments). Spec 0003 still owns the Settings tile copy. The group is added only for those roles. Any other logged in role that hits the page URL is sent to the app home. The same role check applies to the edit page and to every AJAX action. Access is the shared helper `staff_attendance_can_access` in `admin/includes/staff_attendance_access.php`. `sal_can_access` in this engine calls that helper.
- **AC-2**: The list uses zap-app Pattern C. Hero overline is Staff attendance, title is Locations, stat is the number of cards returned. School name in the hero comes from `$school_config['school_name']` (same as other Pattern C pages). The page shows a skeleton, then loads cards over AJAX. Each card shows name, radius in meters, an active toggle, edit, and delete. Inactive rows stay on the list with the toggle off. Zero rows shows a prompt to add the first place. After toggle or delete the list reloads over AJAX. Cards sort by name, ignoring letter case. At most 50 rows are returned; there is no paging. Hero count equals the length of that list payload (not a separate untruncated COUNT).
- **AC-3**: Add and edit live at `app/admin/staff_attendance_location_edit.php` (optional `id`). Chrome is a back toolbar and title, then name, address search (submit on button or Enter, not while typing), Use my current location, the map, radius slider and number (step 10), and save. Leaflet draws OpenStreetMap tiles at `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. Click drops one draggable marker, or moves it if one already exists. A circle follows the marker and the radius. The UI keeps slider and number in 20 to 2000 meters (default 100 on add) and updates the circle live. A short hint under the slider says a very small radius can fail later check in because school GPS accuracy (settings default 50 m) may be coarser. Every other location in the current school, including inactive, is printed as JSON on the page and drawn as a faded circle.
- **AC-4**: Save is one AJAX action: no `location_id` creates, an id updates. It stores `name`, `latitude`, `longitude`, `radius_m`. Create sets `is_active = 1`. Save never changes `is_active` on an existing row. `created_at` (insert only) and `updated_at` are PHP `Africa/Lagos` now via the app boot timezone. The save button disables until the response returns. After the current school write succeeds, the browser goes to the list. Extra school failures ride in a session flash and show on the list. If the current school write fails, stay on the edit page with `msg` and skip extras.
- **AC-5**: Name is required, trimmed (no inner space collapse), max 100 characters (not bytes), unique in that school among every row including inactive. Compare in PHP with `mb_strtolower(..., 'UTF-8')` after trim. Do not rely on MySQL collation. A clash returns HTTP 200 `{status:error, msg}` and writes nothing in that school.
- **AC-6**: The list toggle is its own AJAX action. It sets `is_active` to the posted 0 or 1 (absolute, not a flip), sets `updated_at`, and refreshes the list. Turning off the last active place is allowed.
- **AC-7**: Delete asks for confirm. Unused (`referenced` false): "This will remove the place." Used and active: "This place has history. It will only be turned off." Used and already inactive: "This place has history. It cannot be removed." Hard delete runs only when no row in `staff_attendance_days`, `staff_attendance_tokens`, or `staff_attendance_attempts` has that `location_id`, checked in the same request before the DELETE (if any reference exists, UPDATE `is_active=0` and `updated_at` instead). The JSON `mode` (`removed`, `deactivated`, or `kept`) is the toast the UI trusts, not the stale list flag.
- **AC-8**: Address search POSTs to the PHP Nominatim proxy only on submit. `q` is trimmed, at least 3 characters, at most 200. The proxy uses cURL, 5 second timeout, `countrycodes=ng` (hard filter, Nigeria only), `format=json`, `limit=5`, and `User-Agent: ZAP-Attendance/1.0 (https://design-changes.zap.ng)`. Picking a match flies the map to zoom 16 and drops or moves the marker. More than 20 geocode POSTs in the same session in 10 minutes return HTTP 429 `{status:error}`. Queries under 3 characters are refused and do not count toward the cap. HTTP 429 or 5xx from Nominatim, or a cURL failure, shows a short message; the map stays usable.
- **AC-9**: Use my current location uses `navigator.geolocation.getCurrentPosition` with `enableHighAccuracy`, timeout 10000 ms, `maximumAge` 0. Deny or fail on that button shows a short message; search and map click still work. First add with no other locations: try geolocation once on load (deny stays silent), else center 9.0820, 8.6753 at zoom 6. A geolocation fix uses zoom 16. Add when two or more others exist: `fitBounds` on their centers, padding 40 px, `maxZoom` 17. Add when exactly one other exists: center on it at zoom 16 (do not `fitBounds` a single point). Edit centers on the row at zoom 16.
- **AC-10**: Create and edit may send extra `school_id` values from `$school_config['schools']` (the same init global insights uses: `school_id`, `school_name`, `db_name`). Being on that list is enough authority. The current school always writes via the session database. If it is not on the switch list, still write it and hide the picker. Skip any `school_id` that equals the current school (already written). Extra schools: on create, match trimmed name ignore letter case; on edit, match the **old** name, then write the new name. One match: update that row (keep `created_at` and `is_active`, overwrite name letter case, lat, lon, radius, `updated_at`). Several matches: update the lowest `location_id` and report that in `msg`. No match: insert with `is_active = 1`. Unchecked schools are left alone. On edit, other school boxes start unchecked. Picker shows only when the switch list has more than one school. A school_id not on the list is refused. If the current write fails, skip extras. Partial extra success is kept. Restore the session database in a `finally` after every extra attempt. Missing 0001 tables on a branch fail that school only, reason `missing_tables`.
- **AC-11**: Leaflet 1.9.4 loads from jsDelivr (`leaflet.js` and `leaflet.css`) with a Subresource Integrity hash taken from jsDelivr at build time. After 3 tile `tileerror` events, or if the script fails, the map area shows retry, which re-inits the map. Save is refused until a marker exists. Server rounds lat/lon to 8 decimal places, refuses non numeric values, and refuses latitude outside -90 to 90 or longitude outside -180 to 180 (no wrap). Server casts `radius_m` to int and refuses (does not clamp) values outside 20 to 2000.
- **AC-12**: Current school reads and writes use the mysqli database already selected by `$_SESSION['db_name']`. Extra branch writes call `select_db` only with a `db_name` from that switch list, then restore in `finally`. Mutations check `$security->check_csrf_token()`. Each JSON response includes a fresh `csrf_token`; page JS replaces the in memory token so overlapping list, save, and geocode calls do not use a stale one. CSRF failure is HTTP 403. Dead session on AJAX is HTTP 401 JSON, not an HTML login page. Wrong role AJAX is HTTP 403. Validation errors are HTTP 200 `{status:error, msg}`. Missing 0001 tables on the current school return HTTP 200 `{status:error, msg}` saying the attendance tables are missing, not a fatal retry screen. Engine functions take `mysqli` plus a `$ctx` array, return arrays, never echo, and never read `$_POST` or `$_GET`. JSON uses `json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)` like `admin/includes/finance_item_ajax.php`.

## Decision

**Chosen option**: Option 1: App list plus map editor on the 0001 table, Leaflet and OpenStreetMap, optional unlinked copies into other school databases.

Two app pages, one engine, one AJAX dispatcher. No new columns. Radius 20 to 2000 is enforced in PHP. Delete removes an unused row and only turns off a used one.

**Implementation skills**: `zap-app` (local, `.cursor/skills/zap-app/`)

## Feature design

**Data model sketch**:

No new table and no `ALTER`. Target is `staff_attendance_locations` in each school database, as in spec 0001:

| Field | Type | Required | Notes |
|---|---|---|---|
| location_id | INT UNSIGNED PK | yes | Auto increment per school database |
| name | VARCHAR(100) | yes | Unique per school in PHP after trim, ignore letter case, including inactive |
| latitude | DECIMAL(10,8) | yes | Marker center |
| longitude | DECIMAL(11,8) | yes | Marker center |
| radius_m | INT | yes | PHP range 20 to 2000 |
| is_active | TINYINT(1) | yes | Default 1 on create |
| created_at | DATETIME | yes | PHP Africa/Lagos |
| updated_at | DATETIME | yes | PHP Africa/Lagos |

Relationships (logical, no MySQL foreign keys): one location to many days, tokens, and attempts. Tenant is the selected school database. Copies in other schools are separate rows with their own `location_id`. They are not linked after write. Extra school match key is the trimmed name ignoring letter case (the old name on edit, then the new name is written).

**State transitions**:
- New row: insert with `is_active = 1`.
- Toggle: POST sets `is_active` to 0 or 1. `updated_at` changes. The edit page has no active control.
- Used Delete: `is_active` becomes 0, `updated_at` changes, the row stays.
- Unused Delete: row removed.
- Edit save: name, center, and radius may change, even when days already exist. `is_active` does not change on save. Day rows keep the old `location_name` snapshot from spec 0001.

**API surface**:

Pages boot from `app/admin/includes/authenticate.php`. AJAX is included from the list or edit page via `zap_main_admin_include('staff_attendance_locations_ajax.php')`. The dispatcher reads `$_POST['action']`. Engines live in `admin/includes/staff_attendance_locations_engine.php`.

| Endpoint | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| app/admin/staff_attendance_locations.php | GET | none | Pattern C shell, skeleton, CSRF in page JS, optional flash of extra school failures | roles in AC-1 | other roles redirected home |
| app/admin/staff_attendance_location_edit.php | GET | id:int (opt) | form, map, others JSON, old name for branch match, branch picker when 2+ schools, CSRF | roles in AC-1 | unknown id redirects to list; other roles redirected home |
| action=list | POST | csrf_token | locations[] (location_id, name, radius_m, is_active, latitude, longitude, referenced), count | roles in AC-1 | 403; 401; missing_tables |
| action=save | POST | csrf_token, location_id:int (opt), name, latitude, longitude, radius_m, school_ids[] (opt) | current location_id, redirect list URL, branches[] of {school_id, school_name, status, msg} | roles in AC-1 | 403 csrf or role; 401; validation 200 error; duplicate name; unknown id; school not on switch list |
| action=toggle | POST | csrf_token, location_id, is_active 0 or 1 | updated row | roles in AC-1 | 403; 401; unknown id |
| action=delete | POST | csrf_token, location_id | mode `removed`, `deactivated`, or `kept`, then list payload | roles in AC-1 | 403; 401; unknown id |
| action=geocode | POST | csrf_token, q | matches[] of {display_name, latitude, longitude}, max 5 | roles in AC-1 | 403; 401; short query 200 error; 429 rate limit; nominatim error 200 |

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| List page | who may open it | `staff_attendance_can_access` (`$_SESSION['table_name']` in admin, director, backend, or `$info['status']` is admin) |
| List page | which school | mysqli already on `$_SESSION['db_name']` |
| List page | CSRF token | `$security->create_csrf_token()` |
| List page | nav tile | `app/admin/partials/zap-nav-menu.php` `staff_attendance` group, Locations tile only for AC-1 roles, icon `icon/attendance.png`, href `staff_attendance_locations.php` (Settings is spec 0003) |
| List page | hero overline, title | constants Staff attendance, Locations |
| List page | hero school name | `$school_config['school_name']` |
| List page | extra school flash | `$_SESSION` flash set by save, shown once then cleared |
| action=list | cards | SELECT all rows from `staff_attendance_locations` ORDER BY name, limit 50 |
| action=list | hero count | `count` field, same length as `locations[]` |
| action=list | radius text | column `radius_m` plus the word meters |
| action=list | toggle state | column `is_active` |
| action=list | referenced flag | engine COUNT of days + tokens + attempts per `location_id` (true if any COUNT > 0) |
| Edit GET | form name, lat, lon, radius | row for `id` in the current school; add uses empty name, no marker, radius 100 |
| Edit GET | old name for branch match | the loaded row’s `name` kept in a hidden field; add has no old name |
| Edit GET | faded circles | every other current school row as JSON, including inactive |
| Edit GET | branch picker rows | `$school_config['schools']` from init (school_id, school_name, db_name); hide picker when count is 1 or current school is not on the list |
| Edit GET | current school checkbox | matched by `$_SESSION['db_name']` onto that list; always checked and not sent as an extra write |
| Save | name stored | POST `name`, trim, max 100 characters, `mb_strtolower` only for compare |
| Save | unique name | PHP SELECT all names in the target school, compare `mb_strtolower(trim())`, exclude the current `location_id` |
| Save | latitude, longitude | POST from the Leaflet marker, round to 8 decimals |
| Save | radius_m | POST, `(int)`, refuse if outside 20 to 2000 |
| Save | is_active on create | constant 1 |
| Save | is_active on update | unchanged (toggle action only) |
| Save | created_at | PHP `date('Y-m-d H:i:s')` on insert only; app boot timezone `Africa/Lagos` |
| Save | updated_at | same PHP clock on insert, update, toggle, and deactivating delete |
| Save extra school | which databases | POST `school_ids[]` intersected with `$school_config['schools']`; drop the current school id; never a raw `db_name` from the client |
| Save extra school | match existing row | create: new trimmed name; edit: old name from hidden field; `mb_strtolower`; several hits → lowest `location_id` |
| Save extra school | insert vs update fields | insert: `is_active=1`, new `created_at`; update: keep `created_at` and `is_active`, write name, lat, lon, radius_m, `updated_at` |
| Save extra school | restore tenant | `select_db($_SESSION['db_name'])` in `finally` after each extra attempt |
| Save extra school | skip extras | current school save returned error |
| Toggle | new is_active | POST 0 or 1, written as given |
| Delete | removed vs deactivated vs kept | if any matching `location_id` in days, tokens, or attempts: if already `is_active=0` then `kept`, else UPDATE `is_active=0` (`deactivated`); else DELETE (`removed`) |
| Confirm copy | wording | AC-7 strings from the list row’s `referenced` and `is_active`; toast uses server `mode` |
| Geocode | matches | Nominatim `search` JSON `display_name`, `lat`, `lon` parsed as floats; proxy `countrycodes=ng`, `format=json`, `limit=5` |
| Geocode | User-Agent | constant `ZAP-Attendance/1.0 (https://design-changes.zap.ng)` |
| Geocode | transport | PHP cURL, 5 second timeout |
| Geocode | rate limit | `$_SESSION` timestamps of geocode POSTs with `q` length >= 3; prune older than 10 minutes; 21st is HTTP 429 |
| Map first add | start center | browser Geolocation coords zoom 16, else 9.0820, 8.6753 zoom 6 |
| Map later add | start view | two or more others: fitBounds centers, padding 40, maxZoom 17; one other: center that point zoom 16 |
| Map edit | start view | the row’s latitude, longitude, zoom 16 |
| Map search pick | view | fly to that point, zoom 16 |
| My location | marker | `getCurrentPosition` enableHighAccuracy, timeout 10000, maximumAge 0 |
| Leaflet assets | script and CSS | jsDelivr `leaflet@1.9.4` dist files plus SRI hash copied from jsDelivr at build |
| Map tiles | raster images | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` plus the default OSM attribution |
| JSON envelope | status, msg, csrf_token | `admin/includes/finance_item_ajax.php` shape; flags `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT` |
| Wrong role page | redirect | `Location` to `/app/admin/` |
| Wrong role AJAX | body | `{status:error, msg:forbidden}` HTTP 403 |
| Dead session AJAX | body | `{status:error, msg:unauthorized}` HTTP 401 |

**Key invariants**:
- Engine never echoes and never reads superglobals.
- Radius 20 to 2000 is PHP only (no MySQL CHECK), as spec 0001 required.
- Name uniqueness is PHP only. Two overlapping saves can still race; that is accepted because a school has few admins.
- A used location is never hard deleted.
- Extra school writes restore the session database in `finally`, including on exception.
- Overlap of circles is allowed. Which overlapping place wins at check in belongs to the staff check in spec.
- Duplicate names are refused even when one row is inactive, so a deactivated "Main Gate" blocks a new "main gate" until you rename or reuse that row.
- Client never sends `db_name`.
- Save disables until the response so a double tap cannot create two rows then hit AC-5.
- Public check in does not use these pages. Geofence matching belongs to the staff check in spec.

**Security model**:
- Personal data: school GPS points and place names. Not student or staff biometrics. Product bar is the same tenant isolation as the rest of the app, not a finished NDPR review.
- Readers and writers: roles in AC-1, current school for list, edit, toggle, delete. Extra creates and name matched updates only for school_ids on that user’s switch list.
- Students, parents, and other staff: page redirect home, AJAX 403.
- CSRF on every POST action, including geocode and list.
- Prepared statements for every SQL call.
- Escape names on the way out to HTML. JSON uses `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT`.
- Nominatim is server side so the browser does not talk to OSM search unauthenticated, and so User-Agent is ours.
- Geocode cap is per PHP session, not per person across devices, and not public.
- No new secrets. No API key.

**Configuration required**:
None. Leaflet is a public CDN. Nominatim and OSM tiles need outbound HTTPS from the server (proxy) and from the browser (tiles). No env var.

**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements):
- Happy path: admin opens the list, adds a place on the map, saves, sees the card with name, radius, toggle on; edit moves the pin and radius; list updates, verifies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-9**, **AC-11**
- Failure case: save with a duplicate name (different letter case) is refused; delete of a location that has a day row deactivates and keeps the card; delete of an unused row removes it; Nominatim down still lets you click the map and save, verifies **AC-5**, **AC-7**, **AC-8**
- Auth/permission: a student hitting the list URL lands on app home; a staff user not in AC-1 posting save gets 403; a school_id not on the switch list is refused; after an extra write, mysqli is back on the session database, verifies **AC-1**, **AC-10**, **AC-12**

## Build plan

Tracer Bullet: first a real save from the map into the current school and back to the list, then thicken rules, search, and branch copies. No schema migration.

1. [x] Access helper, nav tile, list page shell (Pattern C, skeleton), edit page with Leaflet CDN, marker, circle, radius controls, engine list plus save for the current school, AJAX `list` and `save`, JSON envelope and CSRF, satisfies **AC-1**, **AC-3**, **AC-4**, **AC-11**, **AC-12**
2. [x] Render cards from `list` (name, radius, toggle, edit, delete, empty state, sort, referenced flag), toggle action, delete confirm plus hard delete or deactivate, unique name check, zero active allowed, satisfies **AC-2**, **AC-5**, **AC-6**, **AC-7**
3. [x] Nominatim proxy on submit, Nigeria filter, rate cap, Use my current location, map start rules, faded other circles from page JSON, fail messages for geo, search, tiles, and CDN, satisfies **AC-8**, **AC-9**, **AC-11**
4. [x] Branch picker on create and edit, extra school writes with old name match, flash of partial failures, `finally` restore session database, satisfies **AC-10**, **AC-12**

## Consequences

**Positive**:
- First real write path on the attendance tables, on the app stack the team already ships.
- Map plus faded circles makes overlap visible before save.
- Used places keep a stable `location_id` for old day rows.
- Branch copies save redrawing the same gate, without a new link table.

**Negative / tradeoffs**:
- Copies are unlinked. A later edit overwrites another school only when you check it, and then by name. If that school already used the same name for a different gate, this push replaces that gate’s center.
- Inactive names still occupy uniqueness. You cannot add a second "Main Gate" until you rename the old row.
- Leaflet CDN and OSM tiles need network. Offline add is not supported.
- Nominatim can throttle. Search runs only on submit so it is not autocomplete. Click and my location remain the reliable path.
- No admin audit log of who moved a fence.
- Unpaginated list is capped at 50. A school with more gates would need a later change.
- Principal and academic_admin are denied unless their status is `admin`.

**Neutral**:
- Desktop `admin/` is still deferred.
- Check in geofence math is not in this spec.
- Settings is spec 0003. Enrollment and the public kiosk stay later slices.
- Staff attendance hub is not built. Locations, Settings, and Enrollments are tiles in the Staff attendance nav group (spec 0006 AC-2). There is no hub page.

## Follow-up

- [ ] Staff check in spec owns point in circle matching against `is_active = 1` locations, and which place wins when two active circles overlap (nearest center is the suggested tie break).
- [x] Attendance settings is spec 0003. Locations stays a tile in the Staff attendance nav group; Settings is also a tile. There is no hub.
- [x] Enrollment tile shipped in spec 0006.
- [ ] Attendance report and the live board add their own tiles to that group when those specs ship.
- [ ] Desktop admin copy remains deferred.
- [ ] No location change audit in this slice. Add one later if you need who moved a fence.
- [ ] NDPR or other legal review of storing school GPS is not done here.
- [ ] If a school needs more than 50 places, add paging then, not now.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
