diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index e258135..1db2847 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -1,3 +1,10 @@
+{"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"hc-m1a","title":"Plugin: start screen — site_key field + recent dropdown + resume","description":"When no active local session exists, the start screen shows:\n\n1. Text input 'Site / engagement key' — defaults to normalised get_site_url() (lowercase, strip scheme + leading www., trim trailing slash). Editable. Help text: 'Used to group runs that span dev + live for the same engagement. Pick from recent engagements below if continuing one.'\n2. Dropdown 'Recent engagements' populated from GET /sites?limit=20. Selecting one fills the input.\n3. After site_key is set, GET /healthchecks?site_key=...\u0026include=steps. If any UNFINISHED sessions exist, list them with started_at and progress, each with a [Resume] button. Plus a separate [Start fresh] button.\n\nResume: load the chosen session into the local option (no new POST). Start fresh: new POST /healthchecks.","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:05Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:36:09Z","started_at":"2026-06-29T11:33:33Z","closed_at":"2026-06-29T11:36:09Z","close_reason":"Start screen rewritten:\n- Editable 'Site / engagement key' input, defaults to ATT_HC_Session::normalise_site_url(get_site_url()).\n- datalist-backed autocomplete from GET /sites?limit=20 (recent engagements). Picking one fills the input via native browser UX.\n- 'Look up engagement' submits as GET with site_key in the URL, so re-rendering picks it up and re-queries incompletes.\n- Table of in-progress sessions for the selected site_key with started_at, last-activity, reporting_url, and a [Resume] button per row. Each Resume button posts to a new att_hc_handle_resume handler which calls ATT_HC_Session::resume($id).\n- Separate Start button below labelled differently depending on whether incompletes exist.\n\nDouble-click guard: att_hc_handle_start (and att_hc_handle_resume) early-return to the main page if ATT_HC_Session::current() is non-null, so a stray repeat click can't 409 against the (site_key, started_at) unique constraint with a confusing duplicate error.\n\nEnd-to-end verified: /sites populates the datalist, incomplete filter shows 2 alpha + 1 beta + 0 unknown correctly, resume() repopulates cache and restores step notes, finish() removes from incomplete list. Lookup-err and recent-sites-err paths both surface as inline warnings without blocking the Start button.","dependencies":[{"issue_id":"hc-m1a","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:23Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-m1a","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"hc-9jl","title":"Plugin: refactor ATT_HC_Session to write-through","description":"Session is now server-of-truth. Local WP option becomes a thin cache of the *active* session for fast page loads.\n\nChanges:\n- start(): generates id locally, POSTs to /healthchecks, stores response in option. site_key + reporting_url are new fields.\n- update_step(): updates option AND PUTs to /healthchecks/{id}/steps/{step_id}. If server call fails, the option is NOT updated and the caller sees the error (no drift).\n- finish(): PUT /healthchecks/{id} with finished_at, then update option.\n- set_autocheck(): same write-through pattern.\n- previous(): replace local 'att_hc_previous_session' option with a call to GET /healthchecks?site_key=...\u0026include=steps\u0026limit=2, return the most recent FINISHED one before the current session.\n- discard(): delete option only. Server keeps the record (intentional; history is the point).\n\nRemove the att_hc_previous_session option entirely (data is on the server now).","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:55Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:30:29Z","started_at":"2026-06-29T11:25:28Z","closed_at":"2026-06-29T11:30:29Z","close_reason":"ATT_HC_Session refactored to write-through. Every mutating method (start / update_step / finish / set_autocheck) POSTs or PUTs to the central server first; only on success does the local WP option cache get updated. ATT_HC_Api_Exception bubbles to callers — admin handlers (start / save_step / finish / refresh_checks / step_action) now wrap calls in try/catch and surface via att_hc_api_error transient → admin notice. Added: site_key field (defaults to normalise_site_url(get_site_url())), reporting_url field, ATT_HC_Session::resume(id) for hc-m1a, ATT_HC_Session::normalise_site_url(). previous() now fetches from server (list_healthchecks ?include=steps) and reconstructs into the local session shape; degrades silently on server unreachable since diff is a nice-to-have. Removed att_hc_previous_session option entirely. Also added a config-error gate at the top of the admin page that blocks the UI with a clear notice when wp-config.php constants are missing or wrong. Added server/dev-router.php to work around PHP -S 405-ing dotted uniqid paths (production Apache/nginx unaffected). End-to-end verified against live server: 13 assertions covering happy path, autocheck preservation across upserts, multi-session-per-site, previous()/resume() reconstruction, discard-preserves-server-record, and error bubbling.","dependencies":[{"issue_id":"hc-9jl","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:23Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-9jl","depends_on_id":"hc-0rr","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
+{"_type":"issue","id":"hc-0rr","title":"Plugin: HTTP client + config constants","description":"New class ATT_HC_Api in includes/class-att-hc-api.php.\n\nReads ATT_HC_API_URL and ATT_HC_API_KEY from wp-config.php constants. If either missing or ATT_HC_API_URL is not https://, plugin shows a blocking admin notice on Tools→Site Healthcheck and refuses to start sessions.\n\nMethods mirror server endpoints. wp_remote_post/get with timeout=15, Authorization header injected. On non-2xx, throws ATT_HC_Api_Exception with the server's error.code and human message. Caller (admin page) catches and surfaces.\n\nAcceptance:\n- Missing constants: clear notice, no PHP fatals\n- Wrong key (401): notice says 'server rejected our credentials'\n- Server down: notice says 'central history server unreachable — retry'","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:45Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:23:23Z","started_at":"2026-06-29T11:21:00Z","closed_at":"2026-06-29T11:23:23Z","close_reason":"ATT_HC_Api + ATT_HC_Api_Exception added and wired into the plugin bootstrap. Reads ATT_HC_API_URL / ATT_HC_API_KEY constants from wp-config.php. Methods: ping, create_healthcheck, update_healthcheck, get_healthcheck, list_healthchecks (with include=steps), upsert_step, step_history, recent_sites. Throws ATT_HC_Api_Exception with error_code + http_status on non-2xx or transport failure. Refuses non-HTTPS endpoints (loopback exception for dev). Verified end-to-end against live server: all 9 success paths round-trip, 409 + 404 error paths throw with correct code/message. No existing plugin behaviour changed yet — wiring in happens in hc-9jl.","dependencies":[{"issue_id":"hc-0rr","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-0rr","depends_on_id":"hc-rdo","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
+{"_type":"issue","id":"hc-rdo","title":"Server: HTTP endpoints","description":"All routes require Authorization: Bearer \u003cATT_HC_API_KEY\u003e. JSON in/out. UTC unix timestamps.\n\nPOST /healthchecks Register new session. Body: {id, site_key, started_at, reporting_url, technician_id?, wp_version, php_version}. 201 on success, 409 if (site_key, started_at) collides.\nPUT /healthchecks/{id} Update session metadata. Body: {finished_at?}. 200 on success.\nPUT /healthchecks/{id}/steps/{step_id} Upsert step state. Body: {status, notes, autocheck?, reporting_url}. 200 on success.\nGET /healthchecks?site_key=...\u0026include=steps List sessions for a site, newest first. include=steps inlines step_updates.\nGET /healthchecks/{id} Full session including steps. For Resume flow.\nGET /healthchecks/steps/{step_id}?site_key=...\u0026limit=5 Notes for one step across last N sessions for this site. Newest first.\nGET /sites?limit=20 Recent distinct site_keys for the start-screen dropdown.\n\nErrors: {error: 'human message', code: 'machine_code'}. 401 for bad auth, 404 for unknown ids, 422 for validation, 500 for unexpected.","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:35Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:20:54Z","started_at":"2026-06-29T11:17:58Z","closed_at":"2026-06-29T11:20:54Z","close_reason":"All 7 endpoints implemented and smoke-tested end-to-end with curl: POST /healthchecks (201/409/422), GET /healthchecks/{id} (200/404), PUT /healthchecks/{id} (finish), PUT step (upsert with autocheck, 404/422), GET list (with optional include=steps, ordered DESC by started_at), GET step history across sessions, GET /sites recent keys. Multi-session per site_key works (different started_at). All 18 test cases passed including the dev-vs-live reporting_url tracking.","dependencies":[{"issue_id":"hc-rdo","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-rdo","depends_on_id":"hc-7qm","type":"blocks","created_at":"2026-06-29T12:10:35Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
+{"_type":"issue","id":"hc-7qm","title":"Server: schema for healthchecks + step_updates + audit","description":"Tables:\n\nhealthchecks\n id TEXT PRIMARY KEY -- client-generated (uniqid from plugin)\n site_key TEXT NOT NULL -- composite key with started_at\n started_at INTEGER NOT NULL -- unix seconds\n finished_at INTEGER NULL\n technician_id INTEGER NULL -- WP user id, informational\n reporting_url TEXT NOT NULL -- get_site_url() at start\n wp_version TEXT\n php_version TEXT\n created_at INTEGER NOT NULL\n updated_at INTEGER NOT NULL\n UNIQUE (site_key, started_at)\n\nstep_updates\n healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE\n step_id TEXT NOT NULL\n status TEXT NOT NULL -- not_started|done|skipped|blocked|n_a\n notes TEXT NOT NULL DEFAULT ''\n autocheck_json TEXT NULL -- JSON blob if step ran autocheck\n updated_at INTEGER NOT NULL\n reporting_url TEXT NOT NULL\n PRIMARY KEY (healthcheck_id, step_id)\n\nrequest_log (audit, simple)\n id INTEGER PRIMARY KEY AUTOINCREMENT\n ts INTEGER NOT NULL\n method TEXT\n path TEXT\n status INTEGER\n ip TEXT\n bytes_in INTEGER\n bytes_out INTEGER\n\nIndexes:\n- healthchecks(site_key, started_at DESC)\n- step_updates(step_id, updated_at DESC) -- for cross-session step history","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:17:57Z","started_at":"2026-06-29T11:17:02Z","closed_at":"2026-06-29T11:17:57Z","close_reason":"Schema + auto-migration runner in place. 0001_initial.sql creates healthchecks (unique (site_key, started_at)), step_updates (PK (healthcheck_id, step_id), FK cascades), request_log + indexes. Migrations table tracks applied files. Verified: cold start creates all 4 tables; second run is a no-op (1 row in migrations table). SQLite WAL + foreign_keys enabled in Db.php.","dependencies":[{"issue_id":"hc-7qm","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:22Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-7qm","depends_on_id":"hc-r5r","type":"blocks","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
+{"_type":"issue","id":"hc-r5r","title":"Server: scaffold PHP/SQLite app in server/","description":"Single-file router (index.php) + PDO + SQLite. No framework.\n\nLayout:\n- server/public/index.php — front controller, routing\n- server/src/ — Router, Auth, Store, controllers\n- server/migrations/ — SQL files run on boot if schema missing\n- server/config.php.example — copy to config.php on deploy, holds API key + DSN\n- server/.htaccess — route all to public/index.php\n\nAcceptance:\n- 'php -S localhost:8000 -t server/public' starts a working dev server\n- GET / returns {ok:true, version:...}\n- Missing/invalid Authorization header returns 401","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:13:25Z","started_at":"2026-06-29T11:10:44Z","closed_at":"2026-06-29T11:13:25Z","close_reason":"Scaffold complete: front controller + autoloader + Config + Http + Auth + Router + GET /. Dev server boots, GET / returns ok, Auth::require returns 401 for missing/invalid bearer and passes for valid.","dependencies":[{"issue_id":"hc-r5r","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:21Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"hc-5ix.26","title":"Decision: distribution model — internal-only, no WP.org listing","description":"This is an internal/agency tool, not a public plugin. Distributed as a built ZIP (GitHub release artefact). Technicians install via wp-admin → Plugins → Upload, or via wp-cli (`wp plugin install \u003curl\u003e --activate`).\n\n**Why:** lets us release fast, keeps client-specific text/checks private, no review process.\n\n**How to apply:**\n- Don't add WP.org boilerplate (readme.txt, screenshots, banner.png).\n- Plugin header version = source of truth for what's installed.\n- Phase 3 considers a self-hosted update channel (hc-5ix.NEW).","status":"open","priority":1,"issue_type":"decision","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:29Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.26","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:28Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.6","title":"Session lifecycle: Start/Resume/Finish actions","description":"Start: creates new session, snapshots site URL + WP/PHP versions + technician. Resume: continues the current in-progress session. Finish: stamps finished_at, locks notes, opens report view. If a session is already in-progress, Start asks to discard or resume.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.6","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:12Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.7","title":"Report generation: Markdown export + copy to clipboard + save to private CPT","description":"Render the finished session as Markdown (technician, site URL, started/finished, then per-step status + notes + sub-items). 'Copy' button and 'Save as report post' (private CPT 'wph_report') so reports are retrievable per site. Plain text fallback. No PDF in phase 1.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:29Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.7","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:13Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -6,6 +13,8 @@
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:03:02Z","closed_at":"2026-06-11T15:03:02Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"hc-3y0","title":"Server: deploy notes for VPS","description":"Document deployment to a PHP VPS:\n- PHP version requirements\n- Apache/nginx rewrite to public/index.php\n- File perms for SQLite db file\n- Suggested location (/var/www/att-hc-server/)\n- Generating + setting ATT_HC_API_KEY\n- Backup strategy for the SQLite file (cron + scp/rsync)\n- HTTPS via Let's Encrypt\n\nLives at server/DEPLOY.md.","status":"closed","priority":2,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:27Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:23:22Z","started_at":"2026-06-29T11:21:00Z","closed_at":"2026-06-29T11:23:22Z","close_reason":"DEPLOY.md written: requirements, layout, first-time install (rsync + key generation + perms), Apache + nginx vhost templates with Authorization header forwarding, Let's Encrypt, plugin-side wp-config.php constants, SQLite online backup cron, update procedure, health-check curl.","dependencies":[{"issue_id":"hc-3y0","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-3y0","depends_on_id":"hc-rdo","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"hc-0p1","title":"[epic] Central history server + plugin sync","description":"Replace the local-only session model with a required central server that stores healthcheck history across engagements.\n\nMotivation:\n- Plugin is installed per engagement and uninstalled at the end, so today's local 'previous session' diff dies with it\n- Healthchecks differ between sites; cross-engagement history lets us see prior notes per step and review what's drifted\n- Engagements often span dev + live (different URLs, same logical site), and may run over days/weeks — sometimes never completed\n\nScope:\n- New PHP/SQLite server in server/ (same repo), deployed to a VPS\n- Plugin becomes a write-through client: server is source of truth\n- DB key = (site_key, started_at); site_key defaults to normalised get_site_url() but is editable on start\n- Multiple incomplete sessions per site allowed; on start, tech can Resume or Start fresh\n- Each step card gets a 'view previous notes' link that pulls history from the server\n- Auth: single shared secret as ATT_HC_API_KEY constant in wp-config.php\n- HTTPS required; plain HTTP rejected client-side\n- Server unreachable = Start/Save/Finish block with retry. No offline queue v1.","status":"open","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:09:02Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:09:02Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.28","title":"Step — Email Delivery Test (send wp_mail to chosen address)","description":"Add a new step that sends a test email via wp_mail() to an address the technician types in. Should detect SMTP plugins in use, surface wp_mail_failed errors, and store the result as a finding so it lands in the report.\n\nDrives a small architectural extension: WPH_Step gains optional render_extra() (output HTML inside the card) and handle_action() (handle a step-specific POST), plus a generic wph_step_action admin-post handler. Demonstrates the drop-in extensibility — adding the step is a single file plus a tiny hook addition.","notes":"Added in commit (next). New file includes/steps/115-email.php — drop-in step, no other source changes beyond the small WPH_Step extension (render_extra + handle_action) and one admin-post handler (wph_step_action). Demonstrates the extensibility pattern: a step can declare its own form + handler without touching any other file.\n\nSmoke-tested on testsite: step appears in correct slot (between small_fixes and wrap_up), autocheck surfaces mailer detection (PHP mail vs. 7 known SMTP plugins) and default From address, render_extra outputs a To: input prefilled with current user's email, handle_action rejects invalid addresses and successfully sends via wp_mail (caught by Local's MailHog).","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T17:43:34Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T17:45:47Z","closed_at":"2026-06-11T17:45:47Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.28","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T18:43:33Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
diff --git a/att-site-healthcheck.php b/att-site-healthcheck.php
index cb93990..a90e38b 100644
--- a/att-site-healthcheck.php
+++ b/att-site-healthcheck.php
@@ -22,6 +22,8 @@ define('ATT_HC_OPT_SESSION', 'att_hc_session');
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-step.php';
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-steps.php';
+require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-api-exception.php';
+require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-api.php';
require_once ATT_HC_PLUGIN_DIR . 'includes/class-att-hc-session.php';
require_once ATT_HC_PLUGIN_DIR . 'includes/recovery-bootstrap.php';
require_once ATT_HC_PLUGIN_DIR . 'includes/recovery-installer.php';
diff --git a/includes/admin-page.php b/includes/admin-page.php
index d6bd1c5..e95c335 100644
--- a/includes/admin-page.php
+++ b/includes/admin-page.php
@@ -3,6 +3,8 @@ if (!defined('ABSPATH')) exit;
add_action('admin_menu', 'att_hc_register_menu');
add_action('admin_post_att_hc_start', 'att_hc_handle_start');
+add_action('admin_post_att_hc_resume', 'att_hc_handle_resume');
+add_action('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step');
add_action('admin_post_att_hc_finish', 'att_hc_handle_finish');
add_action('admin_post_att_hc_discard', 'att_hc_handle_discard');
@@ -111,11 +113,24 @@ function att_hc_render_admin_page(): void {
echo '
';
echo '
Site Healthcheck
';
+ if ($cfg_err = ATT_HC_Api::config_error()) {
+ echo '
Central history server not usable: ' . esc_html($cfg_err) . '
';
+ echo '
Add the following to wp-config.php and reload:
';
+ echo '
define(\'ATT_HC_API_URL\', \'https://your-history-server.example.com\');' . "\n" . 'define(\'ATT_HC_API_KEY\', \'<shared secret>\');
';
+ echo '
';
+ return;
+ }
+
if ($msg = get_transient('att_hc_install_message')) {
delete_transient('att_hc_install_message');
echo '';
}
+ if ($err = get_transient('att_hc_api_error')) {
+ delete_transient('att_hc_api_error');
+ echo 'Central history server: ' . esc_html($err) . '
';
+ }
+
if (!$session) {
att_hc_render_start_panel();
echo '';
@@ -133,14 +148,93 @@ function att_hc_render_admin_page(): void {
}
function att_hc_render_start_panel(): void {
+ // Site key for the lookup is either user-specified (?site_key=...) or
+ // the normalised current site URL. The technician can override on submit.
+ $default_key = ATT_HC_Session::normalise_site_url(get_site_url());
+ $site_key = isset($_GET['site_key']) && is_string($_GET['site_key']) && $_GET['site_key'] !== ''
+ ? sanitize_text_field(wp_unslash((string) $_GET['site_key']))
+ : $default_key;
+
+ // Best-effort fetches — surface a notice on failure but still let the
+ // tech start a fresh session. (Resume needs a successful list call to
+ // know which session to resume.)
+ $incomplete = [];
+ $recent = [];
+ $lookup_err = null;
+ try {
+ $resp = ATT_HC_Api::list_healthchecks($site_key, include_steps: false, limit: 20);
+ foreach ($resp['healthchecks'] ?? [] as $hc) {
+ if (empty($hc['finished_at'])) $incomplete[] = $hc;
+ }
+ } catch (ATT_HC_Api_Exception $e) {
+ $lookup_err = $e->getMessage();
+ }
+ try {
+ $sites = ATT_HC_Api::recent_sites(20);
+ $recent = $sites['sites'] ?? [];
+ } catch (ATT_HC_Api_Exception $e) {
+ // No-op — the recent dropdown is just a convenience.
+ }
?>
Start a healthcheck
-
This will create a new in-progress session for . One session per site at a time.
+
+
Could not look up history for this engagement: . You can still start a new session.
+
+
+
+
+
+
In-progress healthchecks for
+
Pick up where a previous session left off, or start a fresh one below.
+
+ | Started | Last activity | Reporting URL | Action |
+
+
+
+ |
+ ago |
+ |
+
+
+ |
+
+
+
+
+
+
site_key(), $session->id());
+ $step_counts = $resp['counts'] ?? [];
+ } catch (ATT_HC_Api_Exception $e) {
+ // History badges are a nice-to-have — don't block the page.
+ }
+
+ att_hc_print_step_history_assets($session);
+
echo '';
att_hc_render_sidebar($session);
echo '
';
foreach (ATT_HC_Steps::instance()->all() as $step) {
- att_hc_render_step_card($session, $step);
+ att_hc_render_step_card($session, $step, (int) ($step_counts[$step->id()] ?? 0));
}
echo '
';
}
+function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
+ $cfg = [
+ 'ajaxUrl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('att_hc_step_history'),
+ 'reportingUrl' => $session->reporting_url(),
+ ];
+ ?>
+
+
+ Steps
';
foreach (ATT_HC_Steps::instance()->all() as $step) {
@@ -267,7 +460,7 @@ function att_hc_render_diff_summary(ATT_HC_Session $session): void {
echo '
';
}
-function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step): void {
+function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int $history_count = 0): void {
$state = $session->step_state($step->id());
$status = $state['status'];
$notes = $state['notes'];
@@ -277,6 +470,12 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step): vo
title()); ?>
+ 0): ?>
+
+ Previous notes for this step ()
+
+
+
blurb()): ?>
@@ -382,7 +581,55 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
function att_hc_handle_start(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_start');
- ATT_HC_Session::start(get_current_user_id());
+ // Double-submit / double-click guard: if an active session already exists,
+ // ignore the second start and just send the user to it. Otherwise we'd POST
+ // again, hit the (site_key, started_at) unique constraint, and surface a
+ // confusing "duplicate" error.
+ if (ATT_HC_Session::current()) {
+ wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
+ exit;
+ }
+ $site_key = isset($_POST['site_key']) ? sanitize_text_field(wp_unslash((string) $_POST['site_key'])) : null;
+ try {
+ ATT_HC_Session::start(get_current_user_id(), $site_key);
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Could not register the session with the central server: ' . $e->getMessage(), 60);
+ }
+ wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
+ exit;
+}
+
+function att_hc_handle_step_history_ajax(): void {
+ if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
+ if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) {
+ wp_send_json_error('Bad nonce', 403);
+ }
+ $step_id = isset($_POST['step_id']) ? sanitize_key((string) $_POST['step_id']) : '';
+ if ($step_id === '') wp_send_json_error('Missing step_id', 400);
+ $session = ATT_HC_Session::current();
+ if (!$session) wp_send_json_error('No active session', 400);
+ try {
+ $resp = ATT_HC_Api::step_history($step_id, $session->site_key(), 5, $session->id());
+ wp_send_json_success($resp);
+ } catch (ATT_HC_Api_Exception $e) {
+ wp_send_json_error($e->getMessage(), 502);
+ }
+}
+
+function att_hc_handle_resume(): void {
+ if (!current_user_can('manage_options')) wp_die('Forbidden');
+ $id = isset($_POST['id']) ? sanitize_text_field(wp_unslash((string) $_POST['id'])) : '';
+ check_admin_referer('att_hc_resume_' . $id);
+ if (ATT_HC_Session::current()) {
+ // Same guard as start — don't clobber an active session on a stray click.
+ wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
+ exit;
+ }
+ try {
+ ATT_HC_Session::resume($id);
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Could not resume that session: ' . $e->getMessage(), 60);
+ }
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
@@ -396,7 +643,11 @@ function att_hc_handle_save_step(): void {
if (!ATT_HC_Steps::instance()->get($step_id)) wp_die('Unknown step.');
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
- $session->update_step($step_id, $status, $notes);
+ try {
+ $session->update_step($step_id, $status, $notes);
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Step not saved (central server rejected the write): ' . $e->getMessage(), 60);
+ }
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
@@ -406,7 +657,11 @@ function att_hc_handle_finish(): void {
check_admin_referer('att_hc_finish');
$session = ATT_HC_Session::current();
if (!$session) wp_die('No active session.');
- $session->finish();
+ try {
+ $session->finish();
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Could not mark session finished on the central server: ' . $e->getMessage(), 60);
+ }
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck'));
exit;
}
@@ -579,7 +834,11 @@ function att_hc_handle_step_action(): void {
}
}
if (!$replaced) $findings[] = $finding;
- $session->set_autocheck($step_id, $findings);
+ try {
+ $session->set_autocheck($step_id, $findings);
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Step action ran but the result could not be saved to the central server: ' . $e->getMessage(), 60);
+ }
}
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
@@ -596,7 +855,11 @@ function att_hc_handle_refresh_checks(): void {
if (!$step) wp_die('Unknown step.');
@set_time_limit(60);
$findings = $step->autocheck($session->data());
- $session->set_autocheck($step_id, $findings);
+ try {
+ $session->set_autocheck($step_id, $findings);
+ } catch (ATT_HC_Api_Exception $e) {
+ set_transient('att_hc_api_error', 'Autocheck ran but the result could not be saved to the central server: ' . $e->getMessage(), 60);
+ }
wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
diff --git a/includes/class-att-hc-api-exception.php b/includes/class-att-hc-api-exception.php
new file mode 100644
index 0000000..bb15d86
--- /dev/null
+++ b/includes/class-att-hc-api-exception.php
@@ -0,0 +1,17 @@
+error_code = $error_code;
+ $this->http_status = $http_status;
+ }
+}
diff --git a/includes/class-att-hc-api.php b/includes/class-att-hc-api.php
new file mode 100644
index 0000000..17dc9e5
--- /dev/null
+++ b/includes/class-att-hc-api.php
@@ -0,0 +1,155 @@
+');
+ *
+ * Constants (not options) on purpose: the plugin gets uninstalled per engagement,
+ * but the constants survive in wp-config.php so the next install on the same site
+ * still talks to the same server.
+ *
+ * Every method throws ATT_HC_Api_Exception on failure. Callers must catch and surface.
+ */
+final class ATT_HC_Api {
+
+ private const TIMEOUT_SECONDS = 15;
+
+ /** Returns true if the plugin is configured to talk to a server. */
+ public static function is_configured(): bool {
+ return defined('ATT_HC_API_URL') && defined('ATT_HC_API_KEY')
+ && is_string(ATT_HC_API_URL) && is_string(ATT_HC_API_KEY)
+ && ATT_HC_API_URL !== '' && ATT_HC_API_KEY !== '';
+ }
+
+ /**
+ * If config is missing or broken, returns a human message; otherwise null.
+ * Used by the admin page to block the UI with a clear error.
+ */
+ public static function config_error(): ?string {
+ if (!defined('ATT_HC_API_URL') || !defined('ATT_HC_API_KEY')) {
+ return 'Central history server is not configured. Add ATT_HC_API_URL and ATT_HC_API_KEY constants to wp-config.php.';
+ }
+ if (!is_string(ATT_HC_API_URL) || !is_string(ATT_HC_API_KEY) || ATT_HC_API_URL === '' || ATT_HC_API_KEY === '') {
+ return 'ATT_HC_API_URL or ATT_HC_API_KEY in wp-config.php is empty.';
+ }
+ if (stripos(ATT_HC_API_URL, 'https://') !== 0 && !self::is_loopback(ATT_HC_API_URL)) {
+ return 'ATT_HC_API_URL must start with https:// — refusing to send credentials over plain HTTP.';
+ }
+ return null;
+ }
+
+ private static function is_loopback(string $url): bool {
+ $host = parse_url($url, PHP_URL_HOST) ?: '';
+ return in_array($host, ['localhost', '127.0.0.1', '::1'], true);
+ }
+
+ /** GET / — verifies reachability + auth-free heartbeat. */
+ public static function ping(): array {
+ return self::request('GET', '/', null, requires_auth: false);
+ }
+
+ public static function create_healthcheck(array $payload): array {
+ return self::request('POST', '/healthchecks', $payload);
+ }
+
+ public static function update_healthcheck(string $id, array $payload): array {
+ return self::request('PUT', '/healthchecks/' . rawurlencode($id), $payload);
+ }
+
+ public static function get_healthcheck(string $id): array {
+ return self::request('GET', '/healthchecks/' . rawurlencode($id));
+ }
+
+ public static function list_healthchecks(string $site_key, bool $include_steps = false, int $limit = 50): array {
+ $query = ['site_key' => $site_key, 'limit' => $limit];
+ if ($include_steps) $query['include'] = 'steps';
+ return self::request('GET', '/healthchecks?' . http_build_query($query));
+ }
+
+ public static function upsert_step(string $healthcheck_id, string $step_id, array $payload): array {
+ return self::request(
+ 'PUT',
+ '/healthchecks/' . rawurlencode($healthcheck_id) . '/steps/' . rawurlencode($step_id),
+ $payload
+ );
+ }
+
+ public static function step_history(string $step_id, string $site_key, int $limit = 5, ?string $exclude_id = null): array {
+ $query = ['site_key' => $site_key, 'limit' => $limit];
+ if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
+ return self::request(
+ 'GET',
+ '/healthchecks/steps/' . rawurlencode($step_id) . '?' . http_build_query($query)
+ );
+ }
+
+ public static function recent_sites(int $limit = 20): array {
+ return self::request('GET', '/sites?' . http_build_query(['limit' => $limit]));
+ }
+
+ /** Returns ['counts' => ['step_id' => int, ...]] — used to render the "(N)" badge. */
+ public static function step_counts(string $site_key, ?string $exclude_id = null): array {
+ $query = ['site_key' => $site_key];
+ if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
+ return self::request('GET', '/step-counts?' . http_build_query($query));
+ }
+
+ /**
+ * @throws ATT_HC_Api_Exception
+ */
+ private static function request(string $method, string $path, ?array $body = null, bool $requires_auth = true): array {
+ $err = self::config_error();
+ if ($err !== null) {
+ throw new ATT_HC_Api_Exception($err, 'no_config');
+ }
+
+ $url = rtrim(ATT_HC_API_URL, '/') . $path;
+
+ $args = [
+ 'method' => $method,
+ 'timeout' => self::TIMEOUT_SECONDS,
+ 'redirection' => 0,
+ 'headers' => [
+ 'Accept' => 'application/json',
+ ],
+ ];
+ if ($requires_auth) {
+ $args['headers']['Authorization'] = 'Bearer ' . ATT_HC_API_KEY;
+ }
+ if ($body !== null) {
+ $args['headers']['Content-Type'] = 'application/json';
+ $args['body'] = wp_json_encode($body);
+ }
+
+ $response = wp_remote_request($url, $args);
+
+ if (is_wp_error($response)) {
+ throw new ATT_HC_Api_Exception(
+ 'Central history server unreachable: ' . $response->get_error_message(),
+ 'unreachable'
+ );
+ }
+
+ $status = (int) wp_remote_retrieve_response_code($response);
+ $raw = (string) wp_remote_retrieve_body($response);
+ $data = $raw !== '' ? json_decode($raw, true) : [];
+ if (!is_array($data)) $data = [];
+
+ if ($status < 200 || $status >= 300) {
+ $code = isset($data['code']) && is_string($data['code']) ? $data['code'] : 'http_' . $status;
+ $message = isset($data['error']) && is_string($data['error'])
+ ? $data['error']
+ : 'central server returned HTTP ' . $status;
+ if ($status === 401) {
+ $message = 'Central server rejected our credentials. Check ATT_HC_API_KEY in wp-config.php matches the server config.';
+ }
+ throw new ATT_HC_Api_Exception($message, $code, $status);
+ }
+
+ return $data;
+ }
+}
diff --git a/includes/class-att-hc-session.php b/includes/class-att-hc-session.php
index e0e1450..4ca147e 100644
--- a/includes/class-att-hc-session.php
+++ b/includes/class-att-hc-session.php
@@ -2,11 +2,19 @@
if (!defined('ABSPATH')) exit;
/**
- * Healthcheck session. Option-backed (single in-progress session per site).
+ * Healthcheck session — server is the source of truth.
*
- * Per beads decision hc-5ix.4: option, not CPT. The plugin is installed per
- * engagement, so per-site history living in the DB would die on uninstall.
- * Reports are exported as Markdown instead — see report.php.
+ * The local WP option is a thin cache of the *active* session so page renders
+ * are fast and don't fetch on every request. Every write goes to the central
+ * history server first; only on a successful server response does the local
+ * cache get updated. If the server is unreachable or rejects the write, the
+ * caller sees an ATT_HC_Api_Exception and surfaces it via an admin notice —
+ * we never let the cache drift from the server.
+ *
+ * Cross-engagement history (previous-session diff, per-step prior notes) is
+ * fetched live from the server. The old local `att_hc_previous_session`
+ * option is gone — the server has that data, and the plugin gets uninstalled
+ * per engagement anyway.
*/
final class ATT_HC_Session {
@@ -36,38 +44,106 @@ final class ATT_HC_Session {
return new self($raw);
}
- public static function start(int $technician_id): self {
+ /**
+ * Start a new session. POSTs to the central server first; on success
+ * caches the session locally so subsequent renders don't refetch.
+ *
+ * @param int $technician_id WP user id
+ * @param string|null $site_key logical engagement identifier. Falls back to
+ * normalised get_site_url() when null — the
+ * start-screen UI (hc-m1a) lets the tech edit it.
+ * @throws ATT_HC_Api_Exception when the server rejects or is unreachable.
+ */
+ public static function start(int $technician_id, ?string $site_key = null): self {
+ $reporting_url = get_site_url();
+ $site_key = $site_key !== null && $site_key !== '' ? $site_key : self::normalise_site_url($reporting_url);
+
$data = [
- 'id' => uniqid('att_hc_', true),
- 'started_at' => time(),
- 'finished_at' => null,
- 'technician_id'=> $technician_id,
- 'site_url' => get_site_url(),
- 'wp_version' => get_bloginfo('version'),
- 'php_version' => PHP_VERSION,
- 'steps' => [], // keyed by step id → ['status' => ..., 'notes' => ..., 'updated_at' => ...]
+ 'id' => uniqid('att_hc_', true),
+ 'started_at' => time(),
+ 'finished_at' => null,
+ 'technician_id' => $technician_id,
+ 'site_key' => $site_key,
+ 'reporting_url' => $reporting_url,
+ // Kept as a display-only echo of where this install reports from.
+ // Pre-existing report templates still read `site_url` — keep populated.
+ 'site_url' => $reporting_url,
+ 'wp_version' => get_bloginfo('version'),
+ 'php_version' => PHP_VERSION,
+ 'steps' => [],
];
+
+ ATT_HC_Api::create_healthcheck([
+ 'id' => $data['id'],
+ 'site_key' => $data['site_key'],
+ 'started_at' => $data['started_at'],
+ 'reporting_url' => $data['reporting_url'],
+ 'technician_id' => $data['technician_id'],
+ 'wp_version' => $data['wp_version'],
+ 'php_version' => $data['php_version'],
+ ]);
+
update_option(ATT_HC_OPT_SESSION, $data, false);
return new self($data);
}
+ /**
+ * Resume an existing server-side session by id. Pulls the full state down
+ * (including step history) and writes it into the local cache as the active
+ * session. Used by the start-screen "Resume" button (hc-m1a).
+ *
+ * @throws ATT_HC_Api_Exception
+ */
+ public static function resume(string $id): self {
+ $remote = ATT_HC_Api::get_healthcheck($id);
+ $data = self::hydrate_from_remote($remote);
+ update_option(ATT_HC_OPT_SESSION, $data, false);
+ return new self($data);
+ }
+
+ /** Drop the local cache. The server keeps the record — history is the point. */
public static function discard(): void {
delete_option(ATT_HC_OPT_SESSION);
}
- /** The previous finished session (for diffing). Stored on finish(). */
- public static function previous(): ?self {
- $raw = get_option('att_hc_previous_session');
- if (!is_array($raw) || empty($raw['id'])) return null;
- return new self($raw);
+ /**
+ * The most recent FINISHED server-side session for this site, excluding the
+ * active one. Used for the existing diff/review-items panel.
+ *
+ * Returns null when there's no prior history (or when the server is
+ * unreachable — we degrade silently here because the diff is a nice-to-have,
+ * not a blocker for completing a healthcheck).
+ */
+ public static function previous(?string $site_key = null, ?string $exclude_id = null): ?self {
+ if ($site_key === null) {
+ $current = self::current();
+ if (!$current) return null;
+ $site_key = $current->site_key();
+ $exclude_id = $exclude_id ?? $current->id();
+ }
+
+ try {
+ $response = ATT_HC_Api::list_healthchecks($site_key, include_steps: true, limit: 10);
+ } catch (ATT_HC_Api_Exception $e) {
+ return null;
+ }
+
+ foreach ($response['healthchecks'] ?? [] as $hc) {
+ if (!empty($hc['finished_at']) && ($exclude_id === null || $hc['id'] !== $exclude_id)) {
+ return new self(self::hydrate_from_remote($hc));
+ }
+ }
+ return null;
}
public function id(): string { return (string) $this->data['id']; }
public function started_at(): int { return (int) $this->data['started_at']; }
public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; }
public function is_finished(): bool { return $this->finished_at() !== null; }
- public function technician_id(): int { return (int) $this->data['technician_id']; }
- public function site_url(): string { return (string) ($this->data['site_url'] ?? get_site_url()); }
+ public function technician_id(): int { return (int) ($this->data['technician_id'] ?? 0); }
+ public function site_url(): string { return (string) ($this->data['site_url'] ?? $this->data['reporting_url'] ?? get_site_url()); }
+ public function site_key(): string { return (string) ($this->data['site_key'] ?? self::normalise_site_url(get_site_url())); }
+ public function reporting_url(): string { return (string) ($this->data['reporting_url'] ?? get_site_url()); }
public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
public function data(): array { return $this->data; }
@@ -80,10 +156,24 @@ final class ATT_HC_Session {
];
}
+ /**
+ * Write-through step update. Sends the full step state (status + notes +
+ * any prior autocheck) to the server first, then updates the local cache.
+ *
+ * @throws ATT_HC_Api_Exception
+ */
public function update_step(string $step_id, string $status, string $notes): void {
if (!in_array($status, self::VALID_STATUSES, true)) {
$status = self::STATUS_NOT_STARTED;
}
+ $autocheck = $this->data['autocheck'][$step_id] ?? null;
+ ATT_HC_Api::upsert_step($this->id(), $step_id, [
+ 'status' => $status,
+ 'notes' => $notes,
+ 'reporting_url' => $this->reporting_url(),
+ 'autocheck' => $autocheck,
+ ]);
+
$this->data['steps'][$step_id] = [
'status' => $status,
'notes' => $notes,
@@ -92,19 +182,37 @@ final class ATT_HC_Session {
update_option(ATT_HC_OPT_SESSION, $this->data, false);
}
+ /**
+ * Mark the session finished. Server PUT first, then cache update.
+ *
+ * @throws ATT_HC_Api_Exception
+ */
public function finish(): void {
- $this->data['finished_at'] = time();
+ $finished_at = time();
+ ATT_HC_Api::update_healthcheck($this->id(), ['finished_at' => $finished_at]);
+ $this->data['finished_at'] = $finished_at;
update_option(ATT_HC_OPT_SESSION, $this->data, false);
- // Snapshot for next-session diff. One slot, overwritten each finish.
- update_option('att_hc_previous_session', $this->data, false);
}
- /** Store the result of running autocheck() on a step. */
+ /**
+ * Store the result of running autocheck() on a step. Write-through:
+ * we PUT the step row (preserving any existing status + notes) with the
+ * new autocheck blob attached.
+ *
+ * @throws ATT_HC_Api_Exception
+ */
public function set_autocheck(string $step_id, array $findings): void {
- $this->data['autocheck'][$step_id] = [
- 'checked_at' => time(),
- 'findings' => $findings,
- ];
+ $blob = ['checked_at' => time(), 'findings' => $findings];
+ $state = $this->step_state($step_id);
+
+ ATT_HC_Api::upsert_step($this->id(), $step_id, [
+ 'status' => $state['status'],
+ 'notes' => (string) $state['notes'],
+ 'reporting_url' => $this->reporting_url(),
+ 'autocheck' => $blob,
+ ]);
+
+ $this->data['autocheck'][$step_id] = $blob;
update_option(ATT_HC_OPT_SESSION, $this->data, false);
}
@@ -125,4 +233,49 @@ final class ATT_HC_Session {
}
return ['done' => $done, 'total' => $total];
}
+
+ /**
+ * lowercase, strip scheme + leading www., trim trailing slash. The default
+ * site_key — the tech can override at start time (hc-m1a).
+ */
+ public static function normalise_site_url(string $url): string {
+ $host = parse_url($url, PHP_URL_HOST) ?: $url;
+ $host = strtolower($host);
+ if (str_starts_with($host, 'www.')) $host = substr($host, 4);
+ $path = parse_url($url, PHP_URL_PATH) ?: '';
+ $path = rtrim($path, '/');
+ return $host . $path;
+ }
+
+ /**
+ * Turn a server `GET /healthchecks/{id}` (or a `?include=steps` list row)
+ * into the local cache shape.
+ */
+ private static function hydrate_from_remote(array $remote): array {
+ $data = [
+ 'id' => (string) $remote['id'],
+ 'started_at' => (int) $remote['started_at'],
+ 'finished_at' => isset($remote['finished_at']) && $remote['finished_at'] !== null ? (int) $remote['finished_at'] : null,
+ 'technician_id' => isset($remote['technician_id']) ? (int) $remote['technician_id'] : 0,
+ 'site_key' => (string) $remote['site_key'],
+ 'reporting_url' => (string) $remote['reporting_url'],
+ 'site_url' => (string) $remote['reporting_url'],
+ 'wp_version' => (string) ($remote['wp_version'] ?? ''),
+ 'php_version' => (string) ($remote['php_version'] ?? ''),
+ 'steps' => [],
+ 'autocheck' => [],
+ ];
+ foreach ($remote['steps'] ?? [] as $step_row) {
+ $sid = (string) $step_row['step_id'];
+ $data['steps'][$sid] = [
+ 'status' => (string) $step_row['status'],
+ 'notes' => (string) ($step_row['notes'] ?? ''),
+ 'updated_at' => (int) $step_row['updated_at'],
+ ];
+ if (!empty($step_row['autocheck']) && is_array($step_row['autocheck'])) {
+ $data['autocheck'][$sid] = $step_row['autocheck'];
+ }
+ }
+ return $data;
+ }
}
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..3d77da5
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,9 @@
+# Anchored to server/ root — the unanchored pattern was matching server/src/Config.php
+# on case-insensitive filesystems (macOS, Windows).
+/config.php
+/data/
+*.sqlite
+*.sqlite-journal
+*.sqlite-wal
+*.sqlite-shm
+
diff --git a/server/DEPLOY.md b/server/DEPLOY.md
new file mode 100644
index 0000000..4e753a4
--- /dev/null
+++ b/server/DEPLOY.md
@@ -0,0 +1,149 @@
+# Deploying the ATT Healthcheck Server
+
+Single-tenant PHP/SQLite app. Tiny — runs comfortably on the smallest VPS tier.
+
+## Requirements
+
+- PHP **8.1+** with `pdo`, `pdo_sqlite`, `json`, `mbstring` (all standard).
+- Apache + `mod_rewrite`, or nginx with a `try_files` fallback.
+- HTTPS terminating in front of PHP. Plain HTTP is technically accepted, but the plugin client will refuse to talk to non-`https://` endpoints.
+
+## Layout on the server
+
+Anywhere works — every path inside the app is resolved relative to its own files.
+Example using `/home/www/healthcheck`:
+
+```
+/home/www/healthcheck/
+├── public/ ← docroot
+│ ├── .htaccess
+│ └── index.php
+├── src/
+├── migrations/
+├── data/ ← SQLite file lives here (writable by web user)
+├── config.php ← deployment-specific, NOT in git
+└── config.php.example
+```
+
+> **Perm gotcha for `/home/...` locations:** home directories often default to
+> `750` or `700`, which blocks the web user from traversing into the app.
+> `sudo chmod 755 /home/www` once is usually enough. `/var/www` doesn't have
+> this problem (always world-readable by default).
+
+## First-time install
+
+```sh
+# As you, on the server:
+sudo mkdir -p /home/www/healthcheck
+sudo chown $USER:www-data /home/www/healthcheck
+sudo chmod 755 /home/www # see perm gotcha above
+
+# From your dev box:
+rsync -avz --exclude='data/' --exclude='config.php' \
+ ~/dev/att-site-healthcheck/server/ \
+ user@vps:/home/www/healthcheck/
+
+# Back on the server:
+cd /home/www/healthcheck
+cp config.php.example config.php
+php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate api_key
+$EDITOR config.php # paste it in
+
+mkdir -p data
+sudo chown -R www-data:www-data data/ config.php
+sudo chmod 640 config.php
+sudo chmod 770 data/
+```
+
+## Apache vhost (example)
+
+```apache
+
+ ServerName healthcheck-history.example.com
+ DocumentRoot /home/www/healthcheck/public
+
+
+ AllowOverride All
+ Require all granted
+
+
+ # Pass Authorization header through to PHP — Apache strips it by default with FPM.
+ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem
+
+```
+
+## nginx (example)
+
+```nginx
+server {
+ listen 443 ssl http2;
+ server_name healthcheck-history.example.com;
+ root /home/www/healthcheck/public;
+ index index.php;
+
+ location / {
+ try_files $uri /index.php?$query_string;
+ }
+
+ location ~ \.php$ {
+ include fastcgi_params;
+ fastcgi_pass unix:/run/php/php8.2-fpm.sock;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_param HTTP_AUTHORIZATION $http_authorization;
+ }
+
+ ssl_certificate /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem;
+}
+```
+
+## TLS
+
+Use Let's Encrypt + certbot. Example:
+
+```sh
+sudo certbot --apache -d healthcheck-history.example.com
+# or for nginx:
+sudo certbot --nginx -d healthcheck-history.example.com
+```
+
+## Configure the plugin to talk to it
+
+In `wp-config.php` on each WordPress install:
+
+```php
+define('ATT_HC_API_URL', 'https://healthcheck-history.example.com');
+define('ATT_HC_API_KEY', '');
+```
+
+## Backups
+
+The SQLite DB is a single file. Snapshot it on a cron:
+
+```cron
+# Daily at 03:15, rotate 14 days. SQLite-safe online backup.
+15 3 * * * /usr/bin/sqlite3 /home/www/healthcheck/data/att_hc.sqlite ".backup '/var/backups/att-hc/att_hc-$(date +\%F).sqlite'" && find /var/backups/att-hc -name 'att_hc-*.sqlite' -mtime +14 -delete
+```
+
+Pull those backups offsite with `rsync` / `rclone` to whatever you already use.
+
+## Updating
+
+Just rsync the source again — migrations run on first request after deploy.
+
+```sh
+rsync -avz --delete --exclude='data/' --exclude='config.php' \
+ ~/dev/att-site-healthcheck/server/ \
+ user@vps:/home/www/healthcheck/
+```
+
+## Quick health check
+
+```sh
+curl https://healthcheck-history.example.com/
+# → {"ok":true,"service":"att-site-healthcheck-server","version":"..."}
+```
diff --git a/server/config.php.example b/server/config.php.example
new file mode 100644
index 0000000..7b45316
--- /dev/null
+++ b/server/config.php.example
@@ -0,0 +1,19 @@
+`.
+ 'api_key' => 'REPLACE_WITH_A_LONG_RANDOM_STRING',
+
+ // PDO DSN. SQLite default — file path is resolved relative to server/.
+ // For MySQL: 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4'
+ 'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite',
+ 'db_user' => null,
+ 'db_pass' => null,
+
+ // Server version, surfaced on GET /. Bump on deploy.
+ 'version' => '0.1.0',
+];
diff --git a/server/dev-router.php b/server/dev-router.php
new file mode 100644
index 0000000..7b444e8
--- /dev/null
+++ b/server/dev-router.php
@@ -0,0 +1,15 @@
+
+ RewriteEngine On
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule ^ index.php [QSA,L]
+
diff --git a/server/public/index.php b/server/public/index.php
new file mode 100644
index 0000000..c28503e
--- /dev/null
+++ b/server/public/index.php
@@ -0,0 +1,22 @@
+dispatch(
+ $_SERVER['REQUEST_METHOD'] ?? 'GET',
+ parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/'
+);
diff --git a/server/src/Auth.php b/server/src/Auth.php
new file mode 100644
index 0000000..3e38fb9
--- /dev/null
+++ b/server/src/Auth.php
@@ -0,0 +1,38 @@
+ $value) {
+ if (strcasecmp($name, 'Authorization') === 0) return (string) $value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/server/src/Config.php b/server/src/Config.php
new file mode 100644
index 0000000..0f9836f
--- /dev/null
+++ b/server/src/Config.php
@@ -0,0 +1,16 @@
+ Validate::requireString($body, 'id', 64),
+ 'site_key' => Validate::requireString($body, 'site_key', 255),
+ 'started_at' => Validate::requireInt($body, 'started_at'),
+ 'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
+ 'technician_id' => Validate::optionalInt($body, 'technician_id'),
+ 'wp_version' => Validate::optionalString($body, 'wp_version', 32),
+ 'php_version' => Validate::optionalString($body, 'php_version', 32),
+ ];
+
+ try {
+ Store::insertHealthcheck($row);
+ } catch (\PDOException $e) {
+ if (self::isUniqueViolation($e)) {
+ Http::error(409, 'duplicate', 'a healthcheck with this id or (site_key, started_at) already exists');
+ return;
+ }
+ throw $e;
+ }
+
+ Http::json(201, ['ok' => true, 'id' => $row['id']]);
+ }
+
+ public static function update(array $params): void {
+ $id = $params['id'];
+ if (Store::getHealthcheck($id) === null) {
+ Http::error(404, 'not_found', 'no healthcheck with that id');
+ return;
+ }
+ $body = Http::readJsonBody();
+ $finishedAt = Validate::optionalInt($body, 'finished_at');
+ Store::updateHealthcheck($id, $finishedAt);
+ Http::json(200, ['ok' => true]);
+ }
+
+ public static function get(array $params): void {
+ $hc = Store::getHealthcheck($params['id']);
+ if ($hc === null) {
+ Http::error(404, 'not_found', 'no healthcheck with that id');
+ return;
+ }
+ $hc['steps'] = Store::stepsFor($hc['id']);
+ Http::json(200, $hc);
+ }
+
+ public static function list(): void {
+ $siteKey = $_GET['site_key'] ?? '';
+ if (!is_string($siteKey) || $siteKey === '') {
+ Http::error(422, 'invalid', 'site_key query param is required');
+ return;
+ }
+ $limit = max(1, min(200, (int) ($_GET['limit'] ?? 50)));
+ $rows = Store::listHealthchecksForSite($siteKey, $limit);
+
+ $includeSteps = (($_GET['include'] ?? '') === 'steps');
+ if ($includeSteps) {
+ foreach ($rows as &$r) $r['steps'] = Store::stepsFor($r['id']);
+ }
+
+ Http::json(200, ['healthchecks' => $rows]);
+ }
+
+ private static function isUniqueViolation(\PDOException $e): bool {
+ // SQLite: 'UNIQUE constraint failed' / SQLSTATE 23000. MySQL: 1062 / 23000.
+ $msg = $e->getMessage();
+ return str_contains($msg, 'UNIQUE constraint failed')
+ || str_contains($msg, 'Duplicate entry')
+ || ($e->getCode() === '23000');
+ }
+}
diff --git a/server/src/Controllers/Sites.php b/server/src/Controllers/Sites.php
new file mode 100644
index 0000000..2696aee
--- /dev/null
+++ b/server/src/Controllers/Sites.php
@@ -0,0 +1,14 @@
+ Store::recentSiteKeys($limit)]);
+ }
+}
diff --git a/server/src/Controllers/Steps.php b/server/src/Controllers/Steps.php
new file mode 100644
index 0000000..1cd576a
--- /dev/null
+++ b/server/src/Controllers/Steps.php
@@ -0,0 +1,54 @@
+ Validate::status(Validate::requireString($body, 'status', 32)),
+ 'notes' => $body['notes'] ?? '',
+ 'reporting_url' => Validate::requireString($body, 'reporting_url', 512),
+ 'autocheck' => isset($body['autocheck']) && is_array($body['autocheck']) ? $body['autocheck'] : null,
+ ];
+ if (!is_string($row['notes'])) Validate::fail('notes must be a string');
+
+ Store::upsertStep($healthcheckId, $stepId, $row);
+ Http::json(200, ['ok' => true]);
+ }
+
+ public static function history(array $params): void {
+ $stepId = $params['step_id'];
+ $siteKey = $_GET['site_key'] ?? '';
+ if (!is_string($siteKey) || $siteKey === '') {
+ Http::error(422, 'invalid', 'site_key query param is required');
+ return;
+ }
+ $limit = max(1, min(50, (int) ($_GET['limit'] ?? 5)));
+ $excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
+ Http::json(200, ['history' => Store::stepHistory($stepId, $siteKey, $limit, $excludeId)]);
+ }
+
+ public static function counts(): void {
+ $siteKey = $_GET['site_key'] ?? '';
+ if (!is_string($siteKey) || $siteKey === '') {
+ Http::error(422, 'invalid', 'site_key query param is required');
+ return;
+ }
+ $excludeId = isset($_GET['exclude_id']) && is_string($_GET['exclude_id']) ? $_GET['exclude_id'] : null;
+ Http::json(200, ['counts' => Store::stepCountsForSite($siteKey, $excludeId)]);
+ }
+}
diff --git a/server/src/Db.php b/server/src/Db.php
new file mode 100644
index 0000000..1094cd2
--- /dev/null
+++ b/server/src/Db.php
@@ -0,0 +1,47 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+
+ if (str_starts_with($dsn, 'sqlite:')) {
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ $pdo->exec('PRAGMA journal_mode = WAL');
+ $pdo->exec('PRAGMA synchronous = NORMAL');
+ }
+
+ self::$pdo = $pdo;
+ Migrations::ensureRan($pdo);
+ return $pdo;
+ }
+}
diff --git a/server/src/Http.php b/server/src/Http.php
new file mode 100644
index 0000000..99c31bd
--- /dev/null
+++ b/server/src/Http.php
@@ -0,0 +1,28 @@
+ $message, 'code' => $code]);
+ }
+
+ public static function readJsonBody(): array {
+ $raw = file_get_contents('php://input') ?: '';
+ if ($raw === '') return [];
+ try {
+ $decoded = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ self::error(400, 'bad_json', 'request body is not valid JSON');
+ exit;
+ }
+ return is_array($decoded) ? $decoded : [];
+ }
+}
diff --git a/server/src/Migrations.php b/server/src/Migrations.php
new file mode 100644
index 0000000..7593353
--- /dev/null
+++ b/server/src/Migrations.php
@@ -0,0 +1,51 @@
+exec('CREATE TABLE IF NOT EXISTS migrations (
+ filename TEXT PRIMARY KEY,
+ applied_at INTEGER NOT NULL
+ )');
+
+ $dir = __DIR__ . '/../migrations';
+ if (!is_dir($dir)) return;
+
+ $files = glob($dir . '/*.sql') ?: [];
+ sort($files);
+
+ $applied = [];
+ foreach ($pdo->query('SELECT filename FROM migrations')->fetchAll(PDO::FETCH_COLUMN) as $f) {
+ $applied[$f] = true;
+ }
+
+ foreach ($files as $path) {
+ $name = basename($path);
+ if (isset($applied[$name])) continue;
+
+ $sql = file_get_contents($path);
+ if ($sql === false) {
+ throw new \RuntimeException('failed to read migration: ' . $name);
+ }
+
+ $pdo->beginTransaction();
+ try {
+ $pdo->exec($sql);
+ $stmt = $pdo->prepare('INSERT INTO migrations (filename, applied_at) VALUES (?, ?)');
+ $stmt->execute([$name, time()]);
+ $pdo->commit();
+ } catch (\Throwable $e) {
+ $pdo->rollBack();
+ throw new \RuntimeException('migration failed: ' . $name . ' — ' . $e->getMessage(), 0, $e);
+ }
+ }
+ }
+}
diff --git a/server/src/Router.php b/server/src/Router.php
new file mode 100644
index 0000000..87421df
--- /dev/null
+++ b/server/src/Router.php
@@ -0,0 +1,40 @@
+ [method, pattern, handler, requires_auth] */
+ private array $routes = [];
+
+ public function add(string $method, string $pattern, callable $handler, bool $requiresAuth = true): void {
+ $this->routes[] = [strtoupper($method), $pattern, $handler, $requiresAuth];
+ }
+
+ public function dispatch(string $method, string $path): void {
+ $method = strtoupper($method);
+ foreach ($this->routes as [$m, $pattern, $handler, $requiresAuth]) {
+ if ($m !== $method) continue;
+ $params = $this->match($pattern, $path);
+ if ($params === null) continue;
+ if ($requiresAuth) Auth::require();
+ $handler($params);
+ return;
+ }
+ Http::error(404, 'not_found', 'no route matches ' . $method . ' ' . $path);
+ }
+
+ /** Returns captured params on match, null otherwise. Patterns use {name} placeholders. */
+ private function match(string $pattern, string $path): ?array {
+ $regex = preg_replace_callback('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', static function ($m): string {
+ return '(?P<' . $m[1] . '>[^/]+)';
+ }, $pattern);
+ $regex = '#^' . $regex . '$#';
+ if (!preg_match($regex, $path, $matches)) return null;
+ $params = [];
+ foreach ($matches as $k => $v) {
+ if (is_string($k)) $params[$k] = $v;
+ }
+ return $params;
+ }
+}
diff --git a/server/src/Store.php b/server/src/Store.php
new file mode 100644
index 0000000..1a6b44e
--- /dev/null
+++ b/server/src/Store.php
@@ -0,0 +1,158 @@
+prepare('INSERT INTO healthchecks
+ (id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at)
+ VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)');
+ $stmt->execute([
+ ':id' => $row['id'],
+ ':site_key' => $row['site_key'],
+ ':started_at' => $row['started_at'],
+ ':technician_id' => $row['technician_id'] ?? null,
+ ':reporting_url' => $row['reporting_url'],
+ ':wp_version' => $row['wp_version'] ?? null,
+ ':php_version' => $row['php_version'] ?? null,
+ ':created_at' => $now,
+ ':updated_at' => $now,
+ ]);
+ }
+
+ public static function getHealthcheck(string $id): ?array {
+ $stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE id = ?');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function updateHealthcheck(string $id, ?int $finishedAt): bool {
+ $stmt = Db::pdo()->prepare('UPDATE healthchecks SET finished_at = :finished_at, updated_at = :updated_at WHERE id = :id');
+ $stmt->execute([
+ ':id' => $id,
+ ':finished_at' => $finishedAt,
+ ':updated_at' => time(),
+ ]);
+ return $stmt->rowCount() > 0;
+ }
+
+ public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
+ $stmt = Db::pdo()->prepare('INSERT INTO step_updates
+ (healthcheck_id, step_id, status, notes, autocheck_json, reporting_url, updated_at)
+ VALUES (:hc, :sid, :status, :notes, :autocheck, :reporting_url, :updated_at)
+ ON CONFLICT (healthcheck_id, step_id) DO UPDATE SET
+ status = excluded.status,
+ notes = excluded.notes,
+ autocheck_json = excluded.autocheck_json,
+ reporting_url = excluded.reporting_url,
+ updated_at = excluded.updated_at');
+ $stmt->execute([
+ ':hc' => $healthcheckId,
+ ':sid' => $stepId,
+ ':status' => $row['status'],
+ ':notes' => $row['notes'] ?? '',
+ ':autocheck' => isset($row['autocheck']) ? json_encode($row['autocheck']) : null,
+ ':reporting_url' => $row['reporting_url'],
+ ':updated_at' => time(),
+ ]);
+ // Bump parent's updated_at so list views sort sensibly.
+ Db::pdo()->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
+ ->execute([time(), $healthcheckId]);
+ }
+
+ /** @return array */
+ public static function stepsFor(string $healthcheckId): array {
+ $stmt = Db::pdo()->prepare('SELECT step_id, status, notes, autocheck_json, reporting_url, updated_at
+ FROM step_updates WHERE healthcheck_id = ? ORDER BY updated_at ASC');
+ $stmt->execute([$healthcheckId]);
+ $rows = $stmt->fetchAll();
+ foreach ($rows as &$r) {
+ $r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
+ unset($r['autocheck_json']);
+ }
+ return $rows;
+ }
+
+ /** @return array */
+ public static function listHealthchecksForSite(string $siteKey, int $limit = 50): array {
+ $stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE site_key = ? ORDER BY started_at DESC LIMIT ?');
+ $stmt->bindValue(1, $siteKey);
+ $stmt->bindValue(2, $limit, \PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+ }
+
+ /**
+ * Notes for one step across the last N sessions for a site.
+ * @return array
+ */
+ public static function stepHistory(string $stepId, string $siteKey, int $limit = 5, ?string $excludeId = null): array {
+ $sql = 'SELECT h.id AS healthcheck_id, h.started_at, h.finished_at, h.reporting_url AS session_reporting_url,
+ s.status, s.notes, s.autocheck_json, s.reporting_url AS step_reporting_url, s.updated_at
+ FROM step_updates s
+ JOIN healthchecks h ON h.id = s.healthcheck_id
+ WHERE s.step_id = ? AND h.site_key = ?';
+ $params = [$stepId, $siteKey];
+ if ($excludeId !== null && $excludeId !== '') {
+ $sql .= ' AND h.id != ?';
+ $params[] = $excludeId;
+ }
+ $sql .= ' ORDER BY h.started_at DESC LIMIT ?';
+
+ $stmt = Db::pdo()->prepare($sql);
+ $i = 1;
+ foreach ($params as $p) {
+ $stmt->bindValue($i++, $p);
+ }
+ $stmt->bindValue($i, $limit, \PDO::PARAM_INT);
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+ foreach ($rows as &$r) {
+ $r['autocheck'] = $r['autocheck_json'] !== null ? json_decode($r['autocheck_json'], true) : null;
+ unset($r['autocheck_json']);
+ }
+ return $rows;
+ }
+
+ /**
+ * Per-step note count for every step that has at least one row for the site.
+ * Drives the "Previous notes (N)" disclosure on each step card.
+ * @return array step_id => count
+ */
+ public static function stepCountsForSite(string $siteKey, ?string $excludeId = null): array {
+ $sql = 'SELECT s.step_id, COUNT(*) AS c
+ FROM step_updates s
+ JOIN healthchecks h ON h.id = s.healthcheck_id
+ WHERE h.site_key = ?';
+ $params = [$siteKey];
+ if ($excludeId !== null && $excludeId !== '') {
+ $sql .= ' AND h.id != ?';
+ $params[] = $excludeId;
+ }
+ $sql .= ' GROUP BY s.step_id';
+ $stmt = Db::pdo()->prepare($sql);
+ $stmt->execute($params);
+ $out = [];
+ foreach ($stmt->fetchAll() as $row) {
+ $out[(string) $row['step_id']] = (int) $row['c'];
+ }
+ return $out;
+ }
+
+ /** @return array */
+ public static function recentSiteKeys(int $limit = 20): array {
+ $stmt = Db::pdo()->prepare('SELECT site_key, MAX(updated_at) AS last_seen
+ FROM healthchecks GROUP BY site_key ORDER BY last_seen DESC LIMIT ?');
+ $stmt->bindValue(1, $limit, \PDO::PARAM_INT);
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+ return array_map(static fn($r) => ['site_key' => $r['site_key'], 'last_seen' => (int) $r['last_seen']], $rows);
+ }
+}
diff --git a/server/src/Validate.php b/server/src/Validate.php
new file mode 100644
index 0000000..08bfb0e
--- /dev/null
+++ b/server/src/Validate.php
@@ -0,0 +1,46 @@
+ $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
+ return $body[$key];
+ }
+
+ public static function optionalString(array $body, string $key, int $maxLen = 1024): ?string {
+ if (!isset($body[$key])) return null;
+ if (!is_string($body[$key])) self::fail("{$key} must be a string");
+ if (strlen($body[$key]) > $maxLen) self::fail("{$key} exceeds {$maxLen} chars");
+ return $body[$key];
+ }
+
+ public static function requireInt(array $body, string $key): int {
+ if (!isset($body[$key]) || !is_int($body[$key])) {
+ self::fail("missing or non-int field: {$key}");
+ }
+ return $body[$key];
+ }
+
+ public static function optionalInt(array $body, string $key): ?int {
+ if (!isset($body[$key]) || $body[$key] === null) return null;
+ if (!is_int($body[$key])) self::fail("{$key} must be an integer");
+ return $body[$key];
+ }
+
+ public static function status(string $status): string {
+ $valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
+ if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));
+ return $status;
+ }
+}
diff --git a/server/src/bootstrap.php b/server/src/bootstrap.php
new file mode 100644
index 0000000..5641966
--- /dev/null
+++ b/server/src/bootstrap.php
@@ -0,0 +1,40 @@
+getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
+ if (!headers_sent()) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ }
+ echo json_encode(['error' => 'internal server error', 'code' => 'internal']);
+});
+
+$configPath = __DIR__ . '/../config.php';
+if (!is_file($configPath)) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => 'server not configured: missing config.php', 'code' => 'no_config']);
+ exit;
+}
+Config::load(require $configPath);
diff --git a/server/src/routes.php b/server/src/routes.php
new file mode 100644
index 0000000..0a342b0
--- /dev/null
+++ b/server/src/routes.php
@@ -0,0 +1,31 @@
+add('GET', '/', static function (): void {
+ Http::json(200, [
+ 'ok' => true,
+ 'service' => 'att-site-healthcheck-server',
+ 'version' => (string) Config::get('version', '0.0.0'),
+ ]);
+}, requiresAuth: false);
+
+$router->add('POST', '/healthchecks', [Healthchecks::class, 'create']);
+$router->add('GET', '/healthchecks', [Healthchecks::class, 'list']);
+$router->add('GET', '/healthchecks/{id}', [Healthchecks::class, 'get']);
+$router->add('PUT', '/healthchecks/{id}', [Healthchecks::class, 'update']);
+$router->add('PUT', '/healthchecks/{id}/steps/{step_id}', [Steps::class, 'upsert']);
+$router->add('GET', '/healthchecks/steps/{step_id}', [Steps::class, 'history']);
+$router->add('GET', '/step-counts', [Steps::class, 'counts']);
+$router->add('GET', '/sites', [Sites::class, 'recent']);
+
+return $router;