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 */ 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); } /** * 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 */ 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; } }