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.
70 lines
2.7 KiB
PHP
70 lines
2.7 KiB
PHP
<?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];
|
|
}
|
|
|
|
/**
|
|
* A calendar date as 'YYYY-MM-DD', or null.
|
|
*
|
|
* Absent, null, and '' all mean "no date" (the plugin sends '' when the tech
|
|
* clears the field). Anything else must be a real date in exactly that
|
|
* format — the round-trip comparison rejects both sloppy input ('2026-2-3')
|
|
* and impossible dates ('2026-02-30', which createFromFormat would silently
|
|
* roll forward to March 2nd).
|
|
*/
|
|
public static function optionalDate(array $body, string $key): ?string {
|
|
if (!isset($body[$key])) return null;
|
|
if (!is_string($body[$key])) self::fail("{$key} must be a YYYY-MM-DD date string or null");
|
|
|
|
$value = trim($body[$key]);
|
|
if ($value === '') return null;
|
|
|
|
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
|
if ($date === false || $date->format('Y-m-d') !== $value) {
|
|
self::fail("{$key} must be a valid calendar date in YYYY-MM-DD format");
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|