Add a final "Notes for next time" step so the tech finishing today can flag
pending issues, watch-fors, and outstanding client decisions for whoever
picks up the next healthcheck on the same site.
On ATT_HC_Session::start() for a given site_key, the server's step history
for the handover step is queried (limit 1, excluding the just-created
session). If a prior session left handover notes, they're written into
the new session's "Before You Start" notes prefixed with the prior
session's date ("From previous session (YYYY-MM-DD):") so the carry-over
is obvious. The tech can edit/clear them as normal step notes from there.
- includes/steps/125-handover.php — new step (id=handover) using the
standard notes field. No server schema or API change; it's just another
step row in step_updates, surfaced like any other.
- ATT_HC_Session::seed_before_notes_from_prior_handover() — best-effort,
silent degrade on API failure. The session is already registered on
the server before this runs, so a failed seed never blocks start.
- No seed on resume() — resuming an existing session would clobber
whatever the tech had already typed.
Verified end-to-end against the live MySQL server: handover-test-XXXX
flow shows carry-over with date prefix; no-handover-XXXX flow confirms
no false-positive seed for a fresh site_key. Test rows purged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314 lines
13 KiB
PHP
314 lines
13 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);
|
|
$session = new self($data);
|
|
|
|
// Carry handover notes from the prior session into the new "before"
|
|
// notes. Best-effort: a failure here must not block session creation,
|
|
// since the session is already registered on the server above.
|
|
self::seed_before_notes_from_prior_handover($session);
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* If the prior session for this site left "Notes for next time" (id=handover),
|
|
* pre-fill the new session's "Before You Start" (id=before) notes with them,
|
|
* prefixed with the prior session's date so it's clear they're carried over.
|
|
*/
|
|
private static function seed_before_notes_from_prior_handover(self $session): void {
|
|
try {
|
|
$resp = ATT_HC_Api::step_history('handover', $session->site_key(), 1, $session->id());
|
|
} catch (ATT_HC_Api_Exception $e) {
|
|
return;
|
|
}
|
|
$rows = $resp['history'] ?? [];
|
|
if (!$rows) return;
|
|
|
|
$notes = trim((string) ($rows[0]['notes'] ?? ''));
|
|
if ($notes === '') return;
|
|
|
|
$prefix = 'From previous session (' . date('Y-m-d', (int) $rows[0]['started_at']) . "):\n";
|
|
try {
|
|
$session->update_step('before', self::STATUS_NOT_STARTED, $prefix . $notes);
|
|
} catch (ATT_HC_Api_Exception $e) {
|
|
// Already-rare degrade path — the session is alive, just no auto-seed.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|