Lets a technician record when a site should next be looked at, and surfaces that in the dashboard so it can be used for planning. Server: - migration 0002 adds healthchecks.next_due, a VARCHAR(10) 'YYYY-MM-DD' calendar date rather than a timestamp — it's a diary date with no time-of-day, and a timestamp would render as the wrong day off-server. - Kept per-session rather than on a sites table, so the history of what was scheduled when is preserved. A site's current next-due is the value on its most recent session: if the latest visit scheduled nothing, the site reads as unscheduled rather than showing the just-completed visit as overdue. - Validate::optionalDate() round-trips through createFromFormat, which rejects both '2026-2-3' and '2026-02-30' (silently rolled to March 2nd). - next_due follows the same missing-vs-null PUT contract as finished_at, and lives in its own Store::setNextDue() so finishing or reopening a session never disturbs the date and vice versa. - Dashboard shows it on the site list, the per-site session table and the session detail, flagged overdue / today / soon (within a fortnight). Plugin: - Date control on both the active and finished panels — the moment you know when to return is often wrap-up, after the report is generated. - Write-through like every other mutation. Bad input is rejected with a notice rather than silently clearing an existing date. - Included in both the Markdown and HTML reports. Tests (new — tests/, export-ignored from the plugin zip): - Hand-rolled harness, no composer/PHPUnit, in keeping with the repo. - 42 tests: plugin-side date parsing units, plus server integration tests driven over real HTTP against a temp `php -S` instance with a throwaway SQLite DB, so a real server/config.php is never touched. - Mutation-checked: dropping the array_key_exists guard on PUT fails three tests, as intended.
243 lines
11 KiB
PHP
243 lines
11 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace AttHc\Server;
|
|
|
|
/**
|
|
* Thin DB access layer. All methods return plain arrays, ready to JSON-encode.
|
|
* Timestamps stored as unix seconds (UTC).
|
|
*/
|
|
final class Store {
|
|
public static function insertHealthcheck(array $row): void {
|
|
$now = time();
|
|
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks
|
|
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, next_due, created_at, updated_at)
|
|
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :next_due, :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,
|
|
':next_due' => $row['next_due'] ?? 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;
|
|
}
|
|
|
|
/**
|
|
* Set (or clear, with null) the next-due date on one healthcheck.
|
|
*
|
|
* Deliberately separate from updateHealthcheck() so that finishing or
|
|
* reopening a session never touches the scheduled date, and vice versa.
|
|
*/
|
|
public static function setNextDue(string $id, ?string $nextDue): bool {
|
|
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET next_due = :next_due, updated_at = :updated_at WHERE id = :id');
|
|
$stmt->execute([
|
|
':id' => $id,
|
|
':next_due' => $nextDue,
|
|
':updated_at' => time(),
|
|
]);
|
|
return $stmt->rowCount() > 0;
|
|
}
|
|
|
|
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
|
$pdo = Db::pdo();
|
|
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
|
|
|
$base = '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)';
|
|
|
|
if ($driver === 'mysql') {
|
|
// MySQL: VALUES(col) in ON DUPLICATE KEY UPDATE is deprecated in 8.0.20+
|
|
// but still works; the new spelling needs an alias on the row. Stick
|
|
// with VALUES() for broader compatibility back to MySQL 5.7.
|
|
$sql = $base . '
|
|
ON DUPLICATE KEY UPDATE
|
|
status = VALUES(status),
|
|
notes = VALUES(notes),
|
|
autocheck_json = VALUES(autocheck_json),
|
|
reporting_url = VALUES(reporting_url),
|
|
updated_at = VALUES(updated_at)';
|
|
} else {
|
|
// SQLite (and PostgreSQL, by happy accident).
|
|
$sql = $base . '
|
|
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 = $pdo->prepare($sql);
|
|
$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.
|
|
$pdo->prepare('UPDATE healthchecks SET updated_at = ? WHERE id = ?')
|
|
->execute([time(), $healthcheckId]);
|
|
}
|
|
|
|
/** @return array<int, 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<int, 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<int, 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<string, int> 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<int, string> */
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Summary of all sites for the dashboard: one row per site_key,
|
|
* ordered by most-recently-active first.
|
|
* `next_due` is taken from the most recent session, not from the most recent
|
|
* session that happens to have one set. If the latest visit didn't schedule a
|
|
* return, the site genuinely has nothing scheduled — carrying an older date
|
|
* forward would show a visit as overdue immediately after it was completed.
|
|
*
|
|
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string, next_due:string|null}>
|
|
*/
|
|
public static function allSitesSummary(): array {
|
|
$stmt = Db::pdo()->query(
|
|
'SELECT
|
|
site_key,
|
|
COUNT(*) AS total,
|
|
SUM(CASE WHEN finished_at IS NOT NULL THEN 1 ELSE 0 END) AS finished,
|
|
MAX(started_at) AS last_started_at
|
|
FROM healthchecks
|
|
GROUP BY site_key
|
|
ORDER BY MAX(updated_at) DESC'
|
|
);
|
|
$rows = $stmt->fetchAll();
|
|
// Fetch the reporting_url and finished_at from the most recent session per site.
|
|
$out = [];
|
|
foreach ($rows as $r) {
|
|
$detail = Db::pdo()->prepare(
|
|
'SELECT reporting_url, finished_at, next_due FROM healthchecks
|
|
WHERE site_key = ? ORDER BY started_at DESC LIMIT 1'
|
|
);
|
|
$detail->execute([$r['site_key']]);
|
|
$last = $detail->fetch() ?: [];
|
|
$out[] = [
|
|
'site_key' => (string) $r['site_key'],
|
|
'total' => (int) $r['total'],
|
|
'finished' => (int) $r['finished'],
|
|
'last_started_at' => (int) $r['last_started_at'],
|
|
'last_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null,
|
|
'last_reporting_url' => (string) ($last['reporting_url'] ?? ''),
|
|
'next_due' => isset($last['next_due']) && $last['next_due'] !== null && $last['next_due'] !== ''
|
|
? (string) $last['next_due']
|
|
: null,
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
}
|