Central history server + plugin write-through sync (epic hc-0p1)
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 <details> 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) <noreply@anthropic.com>
This commit is contained in:
38
server/src/Auth.php
Normal file
38
server/src/Auth.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Auth {
|
||||
/** Halts with 401 if Authorization header doesn't match the configured key. */
|
||||
public static function require(): void {
|
||||
$expected = (string) Config::get('api_key', '');
|
||||
if ($expected === '' || $expected === 'REPLACE_WITH_A_LONG_RANDOM_STRING') {
|
||||
Http::error(500, 'no_api_key', 'server has no api_key configured');
|
||||
exit;
|
||||
}
|
||||
$header = self::headerValue();
|
||||
if ($header === null || !str_starts_with($header, 'Bearer ')) {
|
||||
Http::error(401, 'unauthorized', 'missing bearer token');
|
||||
exit;
|
||||
}
|
||||
$given = substr($header, 7);
|
||||
if (!hash_equals($expected, $given)) {
|
||||
Http::error(401, 'unauthorized', 'invalid credentials');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static function headerValue(): ?string {
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) return (string) $_SERVER['HTTP_AUTHORIZATION'];
|
||||
// Apache strips Authorization unless rewrite copies it; check both forms.
|
||||
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) return (string) $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
if (function_exists('getallheaders')) {
|
||||
$headers = getallheaders();
|
||||
foreach ($headers as $name => $value) {
|
||||
if (strcasecmp($name, 'Authorization') === 0) return (string) $value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
16
server/src/Config.php
Normal file
16
server/src/Config.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Config {
|
||||
private static array $data = [];
|
||||
|
||||
public static function load(array $data): void {
|
||||
self::$data = $data;
|
||||
}
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed {
|
||||
return self::$data[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
83
server/src/Controllers/Healthchecks.php
Normal file
83
server/src/Controllers/Healthchecks.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
use AttHc\Server\Validate;
|
||||
|
||||
final class Healthchecks {
|
||||
public static function create(): void {
|
||||
$body = Http::readJsonBody();
|
||||
|
||||
$row = [
|
||||
'id' => 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');
|
||||
}
|
||||
}
|
||||
14
server/src/Controllers/Sites.php
Normal file
14
server/src/Controllers/Sites.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
|
||||
final class Sites {
|
||||
public static function recent(): void {
|
||||
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
|
||||
Http::json(200, ['sites' => Store::recentSiteKeys($limit)]);
|
||||
}
|
||||
}
|
||||
54
server/src/Controllers/Steps.php
Normal file
54
server/src/Controllers/Steps.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server\Controllers;
|
||||
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Store;
|
||||
use AttHc\Server\Validate;
|
||||
|
||||
final class Steps {
|
||||
public static function upsert(array $params): void {
|
||||
$healthcheckId = $params['id'];
|
||||
$stepId = $params['step_id'];
|
||||
|
||||
if (Store::getHealthcheck($healthcheckId) === null) {
|
||||
Http::error(404, 'not_found', 'no healthcheck with that id');
|
||||
return;
|
||||
}
|
||||
|
||||
$body = Http::readJsonBody();
|
||||
$row = [
|
||||
'status' => 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)]);
|
||||
}
|
||||
}
|
||||
47
server/src/Db.php
Normal file
47
server/src/Db.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Db {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO {
|
||||
if (self::$pdo !== null) return self::$pdo;
|
||||
|
||||
$dsn = (string) Config::get('db_dsn', '');
|
||||
$user = Config::get('db_user');
|
||||
$pass = Config::get('db_pass');
|
||||
|
||||
if ($dsn === '') {
|
||||
Http::error(500, 'no_db_dsn', 'server has no db_dsn configured');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (str_starts_with($dsn, 'sqlite:')) {
|
||||
$path = substr($dsn, 7);
|
||||
if ($path !== '' && $path !== ':memory:') {
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => 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;
|
||||
}
|
||||
}
|
||||
28
server/src/Http.php
Normal file
28
server/src/Http.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Http {
|
||||
public static function json(int $status, array $body): void {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
public static function error(int $status, string $code, string $message): void {
|
||||
self::json($status, ['error' => $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 : [];
|
||||
}
|
||||
}
|
||||
51
server/src/Migrations.php
Normal file
51
server/src/Migrations.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Migrations {
|
||||
/**
|
||||
* Runs any *.sql files in server/migrations/ that haven't been applied yet.
|
||||
* Idempotent: tracks applied filenames in the `migrations` table.
|
||||
*/
|
||||
public static function ensureRan(PDO $pdo): void {
|
||||
$pdo->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
server/src/Router.php
Normal file
40
server/src/Router.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Router {
|
||||
/** @var array<int, array{0:string,1:string,2:callable,3:bool}> [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;
|
||||
}
|
||||
}
|
||||
158
server/src/Store.php
Normal file
158
server/src/Store.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?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, 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<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);
|
||||
}
|
||||
}
|
||||
46
server/src/Validate.php
Normal file
46
server/src/Validate.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
final class Validate {
|
||||
/** Hard exit 422 with a message. */
|
||||
public static function fail(string $message): never {
|
||||
Http::error(422, 'invalid', $message);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function requireString(array $body, string $key, int $maxLen = 1024): string {
|
||||
if (!isset($body[$key]) || !is_string($body[$key]) || $body[$key] === '') {
|
||||
self::fail("missing or empty string field: {$key}");
|
||||
}
|
||||
if (strlen($body[$key]) > $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;
|
||||
}
|
||||
}
|
||||
40
server/src/bootstrap.php
Normal file
40
server/src/bootstrap.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace AttHc\Server;
|
||||
|
||||
// Hand-rolled PSR-4-ish autoloader. No composer dependency.
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'AttHc\\Server\\';
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) return;
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) require $path;
|
||||
});
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
if (!(error_reporting() & $severity)) return false;
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
error_log('[att-hc-server] ' . $e::class . ': ' . $e->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);
|
||||
31
server/src/routes.php
Normal file
31
server/src/routes.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use AttHc\Server\Config;
|
||||
use AttHc\Server\Controllers\Healthchecks;
|
||||
use AttHc\Server\Controllers\Sites;
|
||||
use AttHc\Server\Controllers\Steps;
|
||||
use AttHc\Server\Http;
|
||||
use AttHc\Server\Router;
|
||||
|
||||
$router = new Router();
|
||||
|
||||
// Health check / version. Public, no auth — used by the plugin to verify reachability before submitting.
|
||||
$router->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;
|
||||
Reference in New Issue
Block a user