Files
wp-healthcheck/includes/class-att-hc-session.php
Steve Hanlon 3c64dd8125 Plugin: backport PHP 8 syntax to PHP 7.4 (hc-eff)
The central history server work introduced four PHP 8.0+ syntax sites in
the plugin codebase. The main plugin file's "Requires PHP: 7.4" header
was already there; the code had silently drifted past that bound.

- includes/class-att-hc-session.php:244 — str_starts_with($host, 'www.')
  → substr($host, 0, 4) === 'www.'.
- includes/class-att-hc-api.php:52 — self::request(…, requires_auth: false)
  → positional false. Same default semantics, same callee signature.
- includes/admin-page.php:165 and class-att-hc-session.php:126 —
  list_healthchecks(…, include_steps: …, limit: …) → positional. Same
  values, no semantic change.

Verified by linting all 16 plugin files against PHP 7.4.33 — no syntax
errors, no residual PHP 8+ patterns (str_starts_with/contains/ends_with,
nullsafe, enum, readonly, mixed/never, constructor promotion, match).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 09:29:22 +01:00

282 lines
12 KiB
PHP

<?php
if (!defined('ABSPATH')) exit;
/**
* Healthcheck session — server is the source of truth.
*
* 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 {
public const STATUS_NOT_STARTED = 'not_started';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_NA = 'n_a';
public const VALID_STATUSES = [
self::STATUS_NOT_STARTED,
self::STATUS_DONE,
self::STATUS_SKIPPED,
self::STATUS_BLOCKED,
self::STATUS_NA,
];
private array $data;
private function __construct(array $data) {
$this->data = $data;
}
public static function current(): ?self {
$raw = get_option(ATT_HC_OPT_SESSION);
if (!is_array($raw) || empty($raw['id'])) return null;
return new self($raw);
}
/**
* 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_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 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, true, 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'] ?? 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; }
public function step_state(string $step_id): array {
return $this->data['steps'][$step_id] ?? [
'status' => self::STATUS_NOT_STARTED,
'notes' => '',
'updated_at' => null,
];
}
/**
* 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,
'updated_at' => time(),
];
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 {
$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);
}
/**
* 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 {
$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);
}
/** Returns ['checked_at'=>int, 'findings'=>array] or null. */
public function get_autocheck(string $step_id): ?array {
return $this->data['autocheck'][$step_id] ?? null;
}
public function progress(): array {
$steps = ATT_HC_Steps::instance()->all();
$total = count($steps);
$done = 0;
foreach ($steps as $step) {
$st = $this->step_state($step->id());
if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) {
$done++;
}
}
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 (substr($host, 0, 4) === '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;
}
}