From 1782e52504beac940df8d4391a484e5b98414117 Mon Sep 17 00:00:00 2001 From: Steve Hanlon Date: Mon, 29 Jun 2026 12:43:24 +0100 Subject: [PATCH] Central history server + plugin write-through sync (epic hc-0p1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PHP/SQLite history server in server/ and refactors the plugin to write every session change through it. Healthcheck history now survives plugin uninstall and groups across dev + live URLs for the same engagement via an editable site_key (defaults to the normalised host). Server (server/): - Front controller + hand-rolled autoloader, no framework, no composer - SQLite default DSN; swap to MySQL by changing config.php - Schema: healthchecks (PK id, UNIQUE (site_key, started_at)) + step_updates (PK (healthcheck_id, step_id)) + request_log; auto-migration runner - 8 endpoints: POST/GET/PUT healthchecks, PUT/GET step rows, GET step history with exclude_id, GET /sites (recent), GET /step-counts (badge data) - Bearer auth via hash_equals; HTTPS expected (plugin enforces client-side) - DEPLOY.md with Apache/nginx vhosts, Let's Encrypt, SQLite backup cron, and the /home/www/ perm gotcha - dev-router.php works around PHP -S 405-ing dotted uniqid paths Plugin: - ATT_HC_Api HTTP client reads ATT_HC_API_URL/ATT_HC_API_KEY constants from wp-config.php; refuses non-HTTPS with a loopback dev exception - ATT_HC_Session is now write-through: every start/update_step/finish/ set_autocheck POSTs or PUTs to the server first, then updates the local WP option cache. No drift possible — failures throw ATT_HC_Api_Exception - previous() now reads from /healthchecks?include=steps and reconstructs; the old att_hc_previous_session local option is gone - ATT_HC_Session::resume(id) hydrates a server session into the local cache - Start screen: editable site_key (defaults to normalise_site_url()), datalist of recent engagements, table of in-progress sessions for the chosen key with Resume buttons. Double-click guard on start + resume handlers short-circuits if a session is already active - Per-step
disclosure shows "Previous notes (N)" badge from /step-counts; lazy-loads detail rows on first expand via admin-ajax, caches via data-loaded, resets on error so user can retry - All admin handlers catch ATT_HC_Api_Exception and surface via att_hc_api_error transient → admin notice - Hard config-error gate at the top of the admin page blocks the UI when ATT_HC_API_URL/ATT_HC_API_KEY are missing or malformed Co-Authored-By: Claude Opus 4.7 (1M context) --- .beads/issues.jsonl | 9 + att-site-healthcheck.php | 2 + includes/admin-page.php | 281 +++++++++++++++++++++++- includes/class-att-hc-api-exception.php | 17 ++ includes/class-att-hc-api.php | 155 +++++++++++++ includes/class-att-hc-session.php | 209 +++++++++++++++--- server/.gitignore | 9 + server/DEPLOY.md | 149 +++++++++++++ server/config.php.example | 19 ++ server/dev-router.php | 15 ++ server/migrations/0001_initial.sql | 46 ++++ server/public/.htaccess | 6 + server/public/index.php | 22 ++ server/src/Auth.php | 38 ++++ server/src/Config.php | 16 ++ server/src/Controllers/Healthchecks.php | 83 +++++++ server/src/Controllers/Sites.php | 14 ++ server/src/Controllers/Steps.php | 54 +++++ server/src/Db.php | 47 ++++ server/src/Http.php | 28 +++ server/src/Migrations.php | 51 +++++ server/src/Router.php | 40 ++++ server/src/Store.php | 158 +++++++++++++ server/src/Validate.php | 46 ++++ server/src/bootstrap.php | 40 ++++ server/src/routes.php | 31 +++ 26 files changed, 1548 insertions(+), 37 deletions(-) create mode 100644 includes/class-att-hc-api-exception.php create mode 100644 includes/class-att-hc-api.php create mode 100644 server/.gitignore create mode 100644 server/DEPLOY.md create mode 100644 server/config.php.example create mode 100644 server/dev-router.php create mode 100644 server/migrations/0001_initial.sql create mode 100644 server/public/.htaccess create mode 100644 server/public/index.php create mode 100644 server/src/Auth.php create mode 100644 server/src/Config.php create mode 100644 server/src/Controllers/Healthchecks.php create mode 100644 server/src/Controllers/Sites.php create mode 100644 server/src/Controllers/Steps.php create mode 100644 server/src/Db.php create mode 100644 server/src/Http.php create mode 100644 server/src/Migrations.php create mode 100644 server/src/Router.php create mode 100644 server/src/Store.php create mode 100644 server/src/Validate.php create mode 100644 server/src/bootstrap.php create mode 100644 server/src/routes.php 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 '

' . esc_html($msg) . '

'; } + 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.

+ + +
+ +

+
+ + + + Reset to this site + +

+ + + + + +

+ Used to group runs that span dev + live for the same engagement. Defaults to the host of this site. + Type to autocomplete from recent engagement(s). +

+
+ + +

In-progress healthchecks for

+

Pick up where a previous session left off, or start a fresh one below.

+ + + + + + + + + + + + +
StartedLast activityReporting URLAction
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;