feat: complete jobcard client management foundation

This commit is contained in:
Marco0300
2026-09-01 20:43:24 +02:00
parent 168c15c6ae
commit de2bf277c4
34 changed files with 2072 additions and 74 deletions
+4 -2
View File
@@ -4,6 +4,8 @@
vendor/
node_modules/
.DS_Store
storage/logs/
storage/uploads/
storage/logs/*
storage/uploads/*
!storage/logs/.gitkeep
!storage/uploads/.gitkeep
.phpunit.result.cache
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Domain\Attachment;
/** Validates upload metadata without reading, storing, or moving the file. */
final class AttachmentValidator
{
/** @var array<string, list<string>> */
private const MIME_BY_EXTENSION = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'pdf' => ['application/pdf'],
'txt' => ['text/plain'],
'csv' => ['text/csv', 'application/csv'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
];
public function __construct(private readonly int $maxSizeBytes = 10_000_000)
{
if ($maxSizeBytes < 1) {
throw new \InvalidArgumentException('Maximum attachment size must be positive.');
}
}
/** @return array<string, mixed> */
public function normalize(array $payload): array
{
$name = is_scalar($payload['name'] ?? $payload['original_name'] ?? null)
? trim((string) ($payload['name'] ?? $payload['original_name'])) : '';
$mime = is_scalar($payload['mime_type'] ?? $payload['mime'] ?? null)
? strtolower(trim((string) ($payload['mime_type'] ?? $payload['mime']))) : '';
$size = $this->normalizeSize($payload['size_bytes'] ?? $payload['size'] ?? null);
$extension = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
return [
'name' => $name,
'extension' => $extension,
'mime_type' => $mime,
'size_bytes' => $size,
'client_visible' => $this->normalizeBoolean($payload['client_visible'] ?? false),
'client_approved' => $this->normalizeBoolean($payload['client_approved'] ?? $payload['approved'] ?? false),
];
}
/** @return array<string, mixed> */
public function validate(array $payload): array
{
$normalized = $this->normalize($payload);
$errors = [];
$name = $normalized['name'];
$extension = $normalized['extension'];
$mime = $normalized['mime_type'];
if ($name === '' || mb_strlen($name) > 255 || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1 || $name[0] === '.') {
$errors['name'] = 'Attachment name must be a safe file name of 255 characters or fewer.';
}
if ($extension === '' || !isset(self::MIME_BY_EXTENSION[$extension]) || preg_match('/(?:^|\.)php(?:\.|$)/i', $name) === 1) {
$errors['extension'] = 'Attachment extension is not allowed.';
}
if ($mime === '' || !in_array($mime, self::MIME_BY_EXTENSION[$extension] ?? [], true)) {
$errors['mime_type'] = 'Attachment MIME type does not match the allowed extension.';
}
if (!is_int($normalized['size_bytes']) || $normalized['size_bytes'] < 0 || $normalized['size_bytes'] > $this->maxSizeBytes) {
$errors['size_bytes'] = 'Attachment size must be between 0 and the configured maximum.';
}
foreach (['client_visible', 'client_approved'] as $field) {
if (!is_bool($normalized[$field])) {
$errors[$field] = 'Attachment approval flags must be boolean.';
}
}
if ($normalized['client_visible'] === true && $normalized['client_approved'] !== true) {
$errors['client_approved'] = 'Client-visible attachments require explicit client approval.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
private function normalizeSize(mixed $value): mixed
{
if (is_int($value)) return $value;
if (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$integer = filter_var(trim($value), FILTER_VALIDATE_INT);
return $integer === false ? $value : $integer;
}
return $value;
}
private function normalizeBoolean(mixed $value): mixed
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
return $value;
}
}
+9 -1
View File
@@ -40,7 +40,7 @@ final class ClientRecord
public function normalize(array $record): array
{
return [
'name' => $this->text($record['name'] ?? null) ?? '',
'name' => $this->normalizeName($record['name'] ?? null),
'registration_number' => $this->text($record['registration_number'] ?? null),
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
'support_email' => $this->lowerText($record['support_email'] ?? null),
@@ -114,6 +114,14 @@ final class ClientRecord
return $text === null ? null : strtolower($text);
}
private function normalizeName(mixed $value): ?string
{
$text = $this->text($value);
if ($text === null) return null;
$collapsed = preg_replace('/\s+/u', ' ', $text);
return $collapsed === false ? $text : $collapsed;
}
private function validPhone(string $phone): bool
{
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ClientRecord.php';
/** Validates client create/edit and explicit active-state transitions. */
final class ClientUpdateCommand
{
public function __construct(private readonly ?ClientRecord $clients = null)
{
}
/** @return array<string, mixed> */
public function validate(array $input, array $existingClients = [], ?int $currentId = null): array
{
$result = ($this->clients ?? new ClientRecord())->validate($input);
$nameKey = $this->duplicateKey(is_string($result['name'] ?? null) ? $result['name'] : '');
if ($nameKey !== '' && $this->hasDuplicate($nameKey, $existingClients, $currentId)) {
$result['errors']['name'] = 'Client name is already in use.';
}
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function validateForCreate(array $input, array $existingClients = []): array
{
return $this->validate($input, $existingClients);
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingClients = []): array
{
$errors = [];
if ($id < 1) $errors['id'] = 'Client ID must be a positive integer.';
$result = $this->validate($input, $existingClients, $id);
$result['id'] = $id;
$result['errors'] = [...$errors, ...$result['errors']];
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function validateDeactivate(array $client): array
{
return $this->validateTransition($client, 'active', 'inactive');
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function validateReactivate(array $client): array
{
return $this->validateTransition($client, 'inactive', 'active');
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function deactivate(array $client): array
{
return $this->validateDeactivate($client);
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function reactivate(array $client): array
{
return $this->validateReactivate($client);
}
/** @return array<string, mixed> */
public function display(array $client): array
{
return ($this->clients ?? new ClientRecord())->display($client);
}
/** @return array<string, mixed> */
public function toDisplay(array $client): array
{
return $this->display($client);
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
private function validateTransition(array $client, string $from, string $to): array
{
$id = $this->positiveId($client['id'] ?? null);
$status = strtolower(trim(is_scalar($client['status'] ?? null) ? (string) $client['status'] : ''));
$errors = [];
if ($id === null) $errors['id'] = 'Client ID must be a positive integer.';
if ($status !== $from) $errors['status'] = "Only {$from} clients can be changed to {$to}.";
return ['valid' => $errors === [], 'id' => $id ?? 0, 'status' => $to, 'errors' => $errors];
}
private function hasDuplicate(string $candidate, array $rows, ?int $currentId): bool
{
foreach ($rows as $row) {
$name = is_array($row) ? ($row['name'] ?? null) : $row;
if (!is_scalar($name) || $this->duplicateKey((string) $name) !== $candidate) continue;
$rowId = is_array($row) ? $this->positiveId($row['id'] ?? null) : null;
if ($currentId === null || $rowId !== $currentId) return true;
}
return false;
}
private function duplicateKey(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return strtolower($collapsed === false ? trim($value) : $collapsed);
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ClientContactValidator.php';
/** Validates client-contact create/edit payloads, including primary promotion. */
final class ContactUpdateCommand
{
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'client_id', 'name', 'email', 'phone', 'is_primary', 'notes'];
/** @return array<string, mixed> */
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
{
$contact = validate_client_contact($input);
$contact['name'] = $this->name($contact['name']);
$clientId = $this->positiveId($input['client_id'] ?? null);
$notes = $this->text($input['notes'] ?? null);
$errors = $contact['errors'];
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
if ($notes !== null && mb_strlen($notes) > 10000) $errors['notes'] = 'Contact notes must be 10000 characters or fewer.';
if ($clientId !== null) {
foreach (['name' => $contact['name'], 'email' => $contact['email']] as $field => $value) {
if ($value === null || $value === '') continue;
if ($this->hasDuplicate($field, (string) $value, $clientId, $existingContacts, $currentId)) {
$errors[$field] = "Contact {$field} is already in use for this client.";
}
}
}
$replace = [];
if ($clientId !== null && $contact['is_primary'] === true) {
foreach ($existingContacts as $row) {
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
if (!$this->asBool($row['is_primary'] ?? false)) continue;
$id = $this->positiveId($row['id'] ?? null);
if ($id !== null && $id !== $currentId) $replace[] = $id;
}
}
return [
'client_id' => $clientId,
'name' => $contact['name'],
'email' => $contact['email'],
'phone' => $contact['phone'],
'is_primary' => $contact['is_primary'],
'notes' => $notes,
'replace_primary_contact_ids' => $replace,
'valid' => $errors === [],
'errors' => $errors,
];
}
/** @return array<string, mixed> */
public function validateForCreate(array $input, array $existingContacts = []): array
{
return $this->validate($input, $existingContacts);
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
{
$result = $this->validate($input, $existingContacts, $id);
$result['id'] = $id;
if ($id < 1) $result['errors']['id'] = 'Contact ID must be a positive integer.';
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function display(array $contact): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $contact)) $safe[$field] = $contact[$field];
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $contact): array
{
return $this->display($contact);
}
private function hasDuplicate(string $field, string $value, int $clientId, array $rows, ?int $currentId): bool
{
$candidate = $field === 'email' ? strtolower(trim($value)) : $this->duplicateKey($value);
foreach ($rows as $row) {
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
$rowId = $this->positiveId($row['id'] ?? null);
if ($currentId !== null && $rowId === $currentId) continue;
$other = $row[$field] ?? null;
if ($other !== null && ($field === 'email' ? strtolower(trim((string) $other)) === $candidate : $this->duplicateKey((string) $other) === $candidate)) return true;
}
return false;
}
private function duplicateKey(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return strtolower($collapsed === false ? trim($value) : $collapsed);
}
private function name(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return $collapsed === false ? trim($value) : $collapsed;
}
private function text(mixed $value): ?string
{
if (!is_scalar($value)) return null;
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
private function asBool(mixed $value): bool
{
return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true));
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
use InvalidArgumentException;
use RuntimeException;
/**
* Framework-free credential encryption using XChaCha20-Poly1305 AEAD.
* Ciphertext is versioned and URL-safe; plaintext is never part of display output.
*/
final class CredentialVault
{
private const VERSION = 'v1';
private const ASSOCIATED_DATA = 'jobcard-system:credential:v1';
private string $key;
private TechnicalInformation $information;
public function __construct(?string $appKey = null)
{
$material = $appKey ?? getenv('APP_KEY');
if ($material === false || $material === null || trim($material) === '') {
throw new RuntimeException('APP_KEY is required for credential encryption.');
}
$material = trim($material);
if (in_array($material, ['generate-a-long-random-secret', 'replace-with-a-long-random-secret'], true)) {
throw new RuntimeException('APP_KEY contains an invalid placeholder value.');
}
$this->key = $this->keyFromMaterial($material);
$this->information = new TechnicalInformation();
}
public function encrypt(string $plaintext): string
{
if ($plaintext === '') throw new InvalidArgumentException('Credential secret must not be empty.');
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, self::ASSOCIATED_DATA, $nonce, $this->key);
return self::VERSION . '.' . $this->base64UrlEncode($nonce . $ciphertext);
}
public function decrypt(string $encoded): string
{
try {
if (!str_starts_with($encoded, self::VERSION . '.')) throw new RuntimeException();
$binary = $this->base64UrlDecode(substr($encoded, strlen(self::VERSION) + 1));
$nonceLength = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
if (strlen($binary) <= $nonceLength + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new RuntimeException();
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(substr($binary, $nonceLength), self::ASSOCIATED_DATA, substr($binary, 0, $nonceLength), $this->key);
if ($plaintext === false) throw new RuntimeException();
return $plaintext;
} catch (\Throwable) {
throw new RuntimeException('Unable to decrypt credential.');
}
}
public function mask(string $secret): string
{
return '••••••••••••••••••••';
}
/** @return array<string,mixed> */
public function display(array $credential): array
{
$safe = $this->projectMetadata($credential);
if (array_key_exists('secret', $credential) || array_key_exists('secret_ciphertext', $credential)) $safe['secret'] = $this->mask('');
return $safe;
}
/** @return array<string,mixed> */
public function toDisplay(array $credential): array
{
return $this->display($credential);
}
/** @return array<string,mixed> */
public function encryptCredential(array $credential): array
{
$validation = $this->information->validate($credential);
if (!$validation['valid']) throw new InvalidArgumentException('Invalid credential metadata: ' . implode(' ', $validation['errors']));
$secret = $credential['secret'] ?? null;
if (!is_string($secret) || $secret === '') throw new InvalidArgumentException('Credential secret must be a non-empty string.');
$stored = $this->projectMetadata(array_key_exists('id', $credential) ? [...$validation, 'id' => $credential['id']] : $validation);
$stored['secret_ciphertext'] = $this->encrypt($secret);
return $stored;
}
/** @return array<string,mixed> */
public function decryptCredential(array $credential): array
{
if (!isset($credential['secret_ciphertext']) || !is_string($credential['secret_ciphertext'])) {
throw new InvalidArgumentException('Encrypted credential secret is missing.');
}
return [...$this->projectMetadata($credential), 'secret' => $this->decrypt($credential['secret_ciphertext'])];
}
/** @return array<string,mixed> */
public function projectMetadata(array $credential): array
{
$normalized = $this->information->normalize($credential);
$safe = $this->information->display($normalized);
if (array_key_exists('id', $credential)) $safe = ['id' => $credential['id'], ...$safe];
return $safe;
}
private function keyFromMaterial(string $material): string
{
if (str_starts_with($material, 'base64:')) {
$decoded = base64_decode(substr($material, 7), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new RuntimeException('APP_KEY base64 material must decode to exactly 32 bytes.');
}
return $decoded;
}
$unprefixedBase64 = base64_decode($material, true);
if ($unprefixedBase64 !== false && strlen($unprefixedBase64) === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES && preg_match('/^[A-Za-z0-9+\/=]+$/', $material) === 1) {
return $unprefixedBase64;
}
if (preg_match('/^[a-f0-9]{64}$/i', $material) === 1) return hex2bin($material);
if (strlen($material) < 32) throw new RuntimeException('APP_KEY material must be at least 32 bytes.');
return hash('sha256', $material, true);
}
private function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string
{
if ($value === '' || preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) throw new RuntimeException();
$decoded = base64_decode(strtr($value, '-_', '+/') . str_repeat('=', (4 - strlen($value) % 4) % 4), true);
if ($decoded === false) throw new RuntimeException();
return $decoded;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
/** Normalizes and validates non-secret technical-information metadata. */
final class TechnicalInformation
{
public const CATEGORY_HOSTING = 'hosting';
public const CATEGORY_VPN = 'vpn';
public const CATEGORY_EMAIL = 'email';
public const CATEGORY_DOMAIN = 'domain';
public const CATEGORY_DATABASE = 'database';
public const CATEGORY_SSH = 'ssh';
public const CATEGORY_API = 'api';
public const CATEGORY_OTHER = 'other';
/** @return list<string> */
public static function categories(): array
{
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER];
}
/** @return array{category:string, label:string, username:string|null, notes:string|null} */
public function normalize(array $information): array
{
return [
'category' => strtolower($this->text($information['category'] ?? null) ?? ''),
'label' => $this->text($information['label'] ?? null) ?? '',
'username' => $this->text($information['username'] ?? null),
'notes' => $this->text($information['notes'] ?? null),
];
}
/** @return array{valid:bool,errors:array<string,string>,category:string,label:string,username:string|null,notes:string|null} */
public function validate(array $information): array
{
$normalized = $this->normalize($information);
$errors = [];
if (!in_array($normalized['category'], self::categories(), true)) {
$errors['category'] = 'Credential category is invalid.';
}
if ($normalized['label'] === '') {
$errors['label'] = 'Credential label is required.';
} elseif ($normalized['label'] !== '' && mb_strlen($normalized['label']) > 120 || ($normalized['label'] !== '' && $this->hasControlCharacter($normalized['label']))) {
$errors['label'] = 'Credential label must be 120 characters or fewer and contain no control characters.';
}
if ($normalized['username'] !== null && (mb_strlen($normalized['username']) > 190 || $this->hasControlCharacter($normalized['username']))) {
$errors['username'] = 'Credential username must be 190 characters or fewer and contain no control characters.';
}
if ($normalized['notes'] !== null && mb_strlen($normalized['notes']) > 2000) {
$errors['notes'] = 'Credential notes must be 2000 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** @return array<string,mixed> */
public function display(array $information): array
{
$safe = [];
foreach (['id', 'category', 'label', 'username', 'notes'] as $field) {
if (array_key_exists($field, $information)) $safe[$field] = $information[$field];
}
return $safe;
}
/** @return array<string,mixed> */
public function toDisplay(array $information): array
{
return $this->display($information);
}
private function text(mixed $value): ?string
{
if ($value === null) return null;
$text = trim(is_scalar($value) ? (string) $value : '');
return $text === '' ? null : $text;
}
private function hasControlCharacter(string $value): bool
{
return preg_match('/[\x00-\x1F\x7F]/', $value) === 1;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\Notification;
use PDO;
use InvalidArgumentException;
/** Persists validated notifications as per-user rows with database-backed deduplication. */
final class NotificationQueue
{
public function __construct(private readonly ?NotificationRecord $records = null)
{
}
/** @return list<int> inserted notification ids */
public function enqueue(PDO $pdo, array $record): array
{
$validation = ($this->records ?? new NotificationRecord())->validate($record);
if (!$validation['valid']) throw new InvalidArgumentException('Invalid notification: ' . implode(' ', $validation['errors']));
$title = is_scalar($record['title'] ?? null) ? trim((string)$record['title']) : '';
$body = is_scalar($record['body'] ?? null) ? trim((string)$record['body']) : '';
if ($title === '' || mb_strlen($title) > 190) throw new InvalidArgumentException('Notification title is required and must be 190 characters or fewer.');
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
$ids = [];
foreach ($validation['recipients'] as $email) {
$lookup->execute(['email' => $email]);
$userId = $lookup->fetchColumn();
if ($userId === false) continue;
$insert->execute(['user' => $userId, 'type' => $validation['type'], 'title' => $title, 'body' => $body === '' ? null : $body, 'dedup' => $validation['deduplication_key'], 'read_at' => $validation['is_read'] ? date('Y-m-d H:i:s') : null]);
$ids[] = (int)$pdo->lastInsertId();
}
return $ids;
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Domain\Notification;
/** Normalizes and validates notification metadata; delivery is deliberately out of scope. */
final class NotificationRecord
{
/** @var list<string> */
private const TYPES = [
'jobcard_created', 'jobcard_status_changed', 'assignment_created',
'time_entry_created', 'sla_threshold', 'attachment_uploaded',
];
/** @return array<string, mixed> */
public function normalize(array $record): array
{
$type = is_scalar($record['type'] ?? null) ? strtolower(trim((string) $record['type'])) : '';
$rawRecipients = $record['recipients'] ?? ($record['recipient'] ?? []);
if (!is_array($rawRecipients)) $rawRecipients = [$rawRecipients];
$recipients = [];
foreach ($rawRecipients as $recipient) {
if (is_scalar($recipient)) {
$value = strtolower(trim((string) $recipient));
if ($value !== '' && !in_array($value, $recipients, true)) $recipients[] = $value;
}
}
$key = $record['deduplication_key'] ?? $record['dedup_key'] ?? null;
$key = is_scalar($key) ? strtolower(trim((string) $key)) : '';
return [
'type' => $type,
'recipients' => $recipients,
'is_read' => $this->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
'deduplication_key' => $key,
];
}
/** @return array<string, mixed> */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if (!in_array($normalized['type'], self::TYPES, true)) {
$errors['type'] = 'Notification type is not supported.';
}
if ($normalized['recipients'] === []) {
$errors['recipients'] = 'At least one notification recipient is required.';
} else {
foreach ($normalized['recipients'] as $recipient) {
if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
$errors['recipients'] = 'Notification recipients must be valid email addresses.';
break;
}
}
}
if (!is_bool($normalized['is_read'])) $errors['is_read'] = 'Read state must be boolean.';
if ($normalized['deduplication_key'] === '' || mb_strlen($normalized['deduplication_key']) > 190 || preg_match('/[\x00-\x1F\x7F]/', $normalized['deduplication_key']) === 1) {
$errors['deduplication_key'] = 'A safe deduplication key is required and must be 190 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
public function deduplicationKey(array $record): string
{
return $this->normalize($record)['deduplication_key'];
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$normalized = $this->normalize($record);
return [
'type' => $normalized['type'],
'recipients' => $normalized['recipients'],
'is_read' => $normalized['is_read'],
'deduplication_key' => $normalized['deduplication_key'],
];
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array { return $this->display($record); }
private function normalizeBoolean(mixed $value): mixed
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
return $value;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientHistoryReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientJobcardReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/** Framework-free HTML renderer intended for browser print/save-to-PDF. */
final class PrintReportRenderer
{
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
$body .= '<tr>' . implode('', array_map(fn(mixed $value): string => '<td>' . $this->escape($value) . '</td>', $row)) . '</tr>';
}
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $this->escape($title) . '</title><style>body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
}
/** @param list<array<string,mixed>> $rows */
public function renderRecords(string $title, array $rows): string
{
$headers = $rows === [] ? [] : array_keys($rows[0]);
return $this->render($title, $headers, array_map(fn(array $row): array => array_values($row), $rows));
}
private function escape(mixed $value): string { return htmlspecialchars((string)($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
}
+26 -52
View File
@@ -1,64 +1,38 @@
<?php
declare(strict_types=1);
/**
* Maps raw domain rows into an explicitly allow-listed client view and a
* separately retained internal view. This class has no framework or storage
* dependency and is safe to use before an export format is selected.
*/
final class ReportDataMapper
{
/** @var list<string> */
private const CLIENT_FIELDS = [
'id',
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
'client_id',
'client_name',
'reference_no',
'priority',
'work_requested',
'completed_at',
'closed_at',
'allocated_hours',
'used_hours',
'remaining_hours',
'usage_percentage',
'status_label',
'period_type',
'start_date',
'end_date',
'hours',
'id','name','status','support_email','client_id','client_name','reference_no','priority','work_requested','created_at','completed_at','closed_at','changed_at','from_status','to_status','hours','sla_hours','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date',
];
private const INTERNAL_FIELDS = [
'id','client_id','client_name','reference_no','status','priority','work_requested','technician_id','technician_name','created_at','completed_at','closed_at','changed_at','from_status','to_status','changed_by','changed_by_name','work_date','hours','sla_hours','counts_toward_sla','sla_status','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date','internal_notes','technician_notes',
];
/** @return array<string, mixed> */
public function clientFacing(array $record): array
{
$safe = [];
foreach (self::CLIENT_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string,mixed> */
public function clientFacing(array $record): array { return $this->allow($record, self::CLIENT_FIELDS); }
/** @return array<string,mixed> */
public function internal(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array{client:array<string,mixed>,internal:array<string,mixed>} */
public function map(array $record): array { return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; }
/** @return array<string,mixed> */
public function clientJobcard(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','reference_no','status','priority','work_requested','created_at','completed_at','closed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalJobcard(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientHistory(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','reference_no','from_status','to_status','changed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientActivity(array $record): array { return $this->clientFacing($record); }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string, mixed> */
public function internal(array $record): array
private function allow(array $record, array $fields): array
{
return array_diff_key($record, $this->clientFacing($record));
}
/** @return array{client: array<string, mixed>, internal: array<string, mixed>} */
public function map(array $record): array
{
return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)];
$result = [];
foreach ($fields as $field) if (array_key_exists($field, $record)) $result[$field] = $record[$field];
return $result;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/** Value object for the common report filter vocabulary. */
final class ReportFilters
{
public function __construct(
public readonly ?int $clientId = null,
public readonly ?string $dateFrom = null,
public readonly ?string $dateTo = null,
public readonly ?int $technicianId = null,
public readonly ?string $status = null,
public readonly ?string $priority = null,
public readonly ?string $sla = null,
) {
$this->validate();
}
public static function fromArray(array $input): self
{
return new self(
self::positiveInt($input['client_id'] ?? $input['clientId'] ?? null),
self::date($input['date_from'] ?? $input['dateFrom'] ?? null),
self::date($input['date_to'] ?? $input['dateTo'] ?? null),
self::positiveInt($input['technician_id'] ?? $input['technicianId'] ?? null),
self::text($input['status'] ?? null),
self::text($input['priority'] ?? null),
self::text($input['sla'] ?? $input['sla_status'] ?? null),
);
}
public function matches(array $row): bool
{
if ($this->clientId !== null && (int)($row['client_id'] ?? 0) !== $this->clientId) return false;
if ($this->technicianId !== null && (int)($row['technician_id'] ?? 0) !== $this->technicianId) return false;
if ($this->status !== null && (string)($row['status'] ?? $row['to_status'] ?? '') !== $this->status) return false;
if ($this->priority !== null && (string)($row['priority'] ?? '') !== $this->priority) return false;
if ($this->sla !== null && (string)($row['sla_status'] ?? $row['sla'] ?? '') !== $this->sla) return false;
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? '');
if ($this->dateFrom !== null && ($date === '' || substr($date, 0, 10) < $this->dateFrom)) return false;
if ($this->dateTo !== null && ($date === '' || substr($date, 0, 10) > $this->dateTo)) return false;
return true;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return ['client_id' => $this->clientId, 'date_from' => $this->dateFrom, 'date_to' => $this->dateTo, 'technician_id' => $this->technicianId, 'status' => $this->status, 'priority' => $this->priority, 'sla' => $this->sla];
}
private function validate(): void
{
if ($this->dateFrom !== null && $this->dateTo !== null && $this->dateFrom > $this->dateTo) throw new InvalidArgumentException('date_from must not be after date_to.');
}
private static function positiveInt(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return (int)$value;
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
private static function date(mixed $value): ?string
{
if (!is_scalar($value) || trim((string)$value) === '') return null;
$value = trim((string)$value);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) || $date->format('Y-m-d') !== $value) throw new InvalidArgumentException('Report dates must use YYYY-MM-DD.');
return $value;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
/** Common contract for deterministic, storage-agnostic report queries. */
interface ReportQuery
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class TechnicianActivityReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$key = (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
if (!isset($totals[$key])) $totals[$key] = ['technician_id' => (int)($row['technician_id'] ?? 0), 'technician_name' => (string)($row['technician_name'] ?? ''), 'client_id' => (int)($row['client_id'] ?? 0), 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
$hours = max(0.0, (float)($row['hours'] ?? 0)); $totals[$key]['hours'] += $hours;
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
}
$result = array_values($totals);
foreach ($result as &$item) { $item['hours'] = round($item['hours'], 2); $item['sla_hours'] = round($item['sla_hours'], 2); if ($audience === 'client') $item = $mapper->clientActivity($item); }
unset($item);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
/** Canonicalizes permission names and exposes only safe permission data. */
final class PermissionMatrix
{
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'name', 'description', 'permissions'];
/**
* Trim permission names, canonicalize them, discard malformed entries, and
* preserve first-seen order while removing duplicates.
*
* @param array<mixed> $permissions
* @return list<string>
*/
public function normalize(array $permissions): array
{
$normalized = [];
$seen = [];
foreach ($permissions as $permission) {
if (!is_scalar($permission)) {
continue;
}
$permission = strtolower(trim((string) $permission));
if ($permission === '' || preg_match('/[\x00-\x20\x7F]/', $permission) === 1) {
continue;
}
if (!isset($seen[$permission])) {
$seen[$permission] = true;
$normalized[] = $permission;
}
}
return $normalized;
}
/** @param array<string, mixed> $record @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $field === 'permissions' && is_array($record[$field])
? $this->normalize($record[$field])
: $record[$field];
}
}
return $safe;
}
/** @param array<string, mixed> $record @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
use LogicException;
/** Framework-free normalization, validation, and safety rules for roles. */
final class RoleRecord
{
private const ADMINISTRATOR = 'administrator';
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'name', 'description', 'created_at', 'permissions'];
/** @return array{name: string, description: string|null} */
public function normalize(array $record): array
{
$name = $record['name'] ?? null;
$description = $record['description'] ?? null;
return [
'name' => is_scalar($name) ? trim((string) $name) : '',
'description' => $this->normalizeDescription($description),
];
}
/** @return array{name: string, description: string|null, valid: bool, errors: array<string, string>} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if ($normalized['name'] === '') {
$errors['name'] = 'Role name is required.';
} elseif (mb_strlen($normalized['name']) > 80) {
$errors['name'] = 'Role name must be 80 characters or fewer.';
} elseif (preg_match('/[\x00-\x1F\x7F]/', $normalized['name']) === 1) {
$errors['name'] = 'Role name contains invalid control characters.';
}
if ($normalized['description'] !== null && mb_strlen($normalized['description']) > 255) {
$errors['description'] = 'Role description must be 255 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
public function isAdministrator(array $record): bool
{
return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR;
}
public function canRename(array $record, mixed $newName): bool
{
return !$this->isAdministrator($record) && $this->canonicalName($newName) !== self::ADMINISTRATOR;
}
public function canDelete(array $record): bool
{
return !$this->isAdministrator($record);
}
public function canChangePermissions(array $record): bool
{
return !$this->isAdministrator($record);
}
public function assertCanRename(array $record, mixed $newName): void
{
if (!$this->canRename($record, $newName)) {
throw new LogicException('The protected Administrator role cannot be renamed.');
}
}
public function assertCanDelete(array $record): void
{
if (!$this->canDelete($record)) {
throw new LogicException('The protected Administrator role cannot be deleted.');
}
}
public function assertCanChangePermissions(array $record): void
{
if (!$this->canChangePermissions($record)) {
throw new LogicException('The protected Administrator role permissions cannot be changed.');
}
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $field === 'permissions' && is_array($record[$field])
? (new PermissionMatrix())->normalize($record[$field])
: $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
private function normalizeDescription(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function canonicalName(mixed $value): string
{
return is_scalar($value) ? strtolower(trim((string) $value)) : '';
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/config/bootstrap.php';
/**
* Deployment checks are kept as small functions so they can be tested without
* loading application state or requiring a live database.
*
* @return array<string, bool>
*/
function deployment_check_extensions(array $required, ?callable $loader = null): array
{
$loader ??= static fn(string $extension): bool => extension_loaded($extension);
$result = [];
foreach ($required as $extension) {
$result[(string)$extension] = (bool)$loader((string)$extension);
}
return $result;
}
/**
* @return array<string, bool>
*/
function deployment_check_environment(array $required, ?callable $reader = null): array
{
$reader ??= static fn(string $name): mixed => getenv($name);
$result = [];
foreach ($required as $name) {
$value = $reader((string)$name);
$result[(string)$name] = $value !== false && $value !== null && trim((string)$value) !== '';
}
return $result;
}
/**
* @return array<string, bool>
*/
function deployment_check_directories(array $directories, ?callable $checker = null): array
{
$checker ??= static fn(string $directory): bool => is_dir($directory) && is_writable($directory);
$result = [];
foreach ($directories as $directory) {
$result[(string)$directory] = (bool)$checker((string)$directory);
}
return $result;
}
/**
* Return a deliberately value-free report suitable for CLI output or logging.
*
* @param array<string, bool> $checks
* @return array<string, string>
*/
function deployment_format_check_report(array $checks): array
{
$report = [];
foreach ($checks as $name => $passed) {
$report[$name] = $passed ? 'OK' : 'FAIL';
}
return $report;
}
function deployment_check_schema(PDO $pdo): bool
{
foreach (['roles', 'permissions', 'role_permissions', 'users', 'clients', 'client_contacts', 'jobcard_sequences', 'technical_information', 'credentials', 'sla_agreements', 'jobcards', 'jobcard_assignments', 'jobcard_status_history', 'time_entries', 'attachments', 'notifications', 'audit_events'] as $table) {
$quoted = '`' . str_replace('`', '``', $table) . '`';
$pdo->query("SELECT 1 FROM {$quoted} LIMIT 1");
}
return true;
}
function deployment_print_check(string $label, bool $passed): void
{
printf("[%s] %s\n", $passed ? 'OK' : 'FAIL', $label);
}
function deployment_run_healthcheck(): int
{
$requiredExtensions = ['pdo_mysql', 'mbstring', 'openssl', 'sodium', 'json', 'fileinfo'];
$requiredEnvironment = [
'APP_ENV', 'APP_KEY', 'DB_HOST', 'DB_PORT', 'DB_DATABASE',
'DB_USERNAME', 'DB_PASSWORD', 'ADMIN_EMAIL', 'ADMIN_PASSWORD',
];
$runtimeDirectories = [
dirname(__DIR__) . '/storage',
dirname(__DIR__) . '/storage/logs',
dirname(__DIR__) . '/storage/uploads',
];
fwrite(STDOUT, "JOBcard deployment health check\n");
$allPassed = true;
foreach (deployment_format_check_report(deployment_check_extensions($requiredExtensions)) as $extension => $status) {
$passed = $status === 'OK';
deployment_print_check("PHP extension: {$extension}", $passed);
$allPassed = $allPassed && $passed;
}
foreach (deployment_format_check_report(deployment_check_environment($requiredEnvironment)) as $name => $status) {
$passed = $status === 'OK';
// Only the variable name and status are emitted; values are never printed.
deployment_print_check("Environment variable: {$name}", $passed);
$allPassed = $allPassed && $passed;
}
foreach (deployment_format_check_report(deployment_check_directories($runtimeDirectories)) as $directory => $status) {
$passed = $status === 'OK';
deployment_print_check("Writable runtime directory: {$directory}", $passed);
$allPassed = $allPassed && $passed;
}
try {
require_once dirname(__DIR__) . '/app/Domain/Credential/TechnicalInformation.php';
require_once dirname(__DIR__) . '/app/Domain/Credential/CredentialVault.php';
new \App\Domain\Credential\CredentialVault((string)getenv('APP_KEY'));
require_once dirname(__DIR__) . '/config/bootstrap.php';
deployment_check_schema(db());
deployment_print_check('Database connection and schema: core tables available', true);
} catch (Throwable) {
// Database exception text can contain infrastructure details; keep the check output safe.
deployment_print_check('Database connection and schema: roles table available', false);
$allPassed = false;
}
fwrite(STDOUT, $allPassed ? "Health check passed.\n" : "Health check failed.\n");
return $allPassed ? 0 : 1;
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
exit(deployment_run_healthcheck());
}
+10
View File
@@ -128,6 +128,16 @@ function can_access_jobcard(int $jobcardId): bool
return (bool)$stmt->fetchColumn();
}
function can_access_client(int $clientId): bool
{
$user = current_user();
if (!$user) return false;
if ($user['role_name'] !== 'Technician') return can('clients.view');
$stmt = db()->prepare('SELECT 1 FROM jobcard_assignments ja JOIN jobcards j ON j.id = ja.jobcard_id WHERE ja.user_id = :user AND j.client_id = :client LIMIT 1');
$stmt->execute(['user' => $user['id'], 'client' => $clientId]);
return (bool)$stmt->fetchColumn();
}
function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void
{
$user = current_user();
+68 -2
View File
@@ -72,6 +72,37 @@ CREATE TABLE IF NOT EXISTS jobcard_sequences (
next_sequence INT UNSIGNED NOT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS technical_information (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
category VARCHAR(80) NOT NULL,
data_json JSON NOT NULL,
updated_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY technical_client_category (client_id, category)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS credentials (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
category VARCHAR(80) NOT NULL,
label VARCHAR(120) NOT NULL,
username VARCHAR(190) NULL,
secret_ciphertext TEXT NOT NULL,
notes TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT UNSIGNED NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX credentials_client_idx (client_id),
INDEX credentials_category_idx (category)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS sla_agreements (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
@@ -153,6 +184,35 @@ CREATE TABLE IF NOT EXISTS time_entries (
INDEX time_date_idx (work_date)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jobcard_id BIGINT UNSIGNED NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL UNIQUE,
mime_type VARCHAR(120) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL,
client_visible BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX attachments_jobcard_idx (jobcard_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS notifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(80) NOT NULL,
title VARCHAR(190) NOT NULL,
body TEXT NULL,
deduplication_key VARCHAR(190) NULL,
read_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY notification_dedup_idx (user_id, deduplication_key),
INDEX notification_unread_idx (user_id, read_at, created_at)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS audit_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NULL,
@@ -181,14 +241,20 @@ INSERT IGNORE INTO permissions (name, description) VALUES
('jobcards.assign', 'Assign technicians to jobcards'),
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
('time_entries.record', 'Record technician time entries'),
('technical.view', 'View client technical information'),
('technical.manage', 'Manage client technical information'),
('credentials.view', 'View protected credentials'),
('credentials.manage', 'Manage protected credentials'),
('attachments.view', 'View jobcard attachments'),
('attachments.manage', 'Manage jobcard attachments'),
('notifications.view', 'View notifications'),
('sla.view', 'View client SLA agreements and usage'),
('sla.manage', 'Configure client SLA agreements'),
('reports.view', 'View reports'),
('reports.export', 'Export reports'),
('users.manage', 'Manage users'),
('roles.manage', 'Manage roles and permissions'),
('audit.view', 'View audit events'),
('credentials.view', 'View protected credentials');
('audit.view', 'View audit events');
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator';
+111
View File
@@ -0,0 +1,111 @@
-- JOBcard additive upgrade for installations created before the current schema.
-- Take a database backup first. Run with the target database selected:
-- mysql --default-character-set=utf8mb4 -u USER -p DATABASE < database/upgrade.sql
-- Resolve duplicate SLA rows before adding the unique client constraint.
CREATE TABLE IF NOT EXISTS technical_information (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
category VARCHAR(80) NOT NULL,
data_json JSON NOT NULL,
updated_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY technical_client_category (client_id, category)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS credentials (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
client_id BIGINT UNSIGNED NOT NULL,
category VARCHAR(80) NOT NULL,
label VARCHAR(120) NOT NULL,
username VARCHAR(190) NULL,
secret_ciphertext TEXT NOT NULL,
notes TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT UNSIGNED NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX credentials_client_idx (client_id),
INDEX credentials_category_idx (category)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS jobcard_sequences (
sequence_year SMALLINT UNSIGNED PRIMARY KEY,
next_sequence INT UNSIGNED NOT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS jobcard_status_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jobcard_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(60) NOT NULL,
to_status VARCHAR(60) NOT NULL,
changed_by BIGINT UNSIGNED NULL,
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE,
FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX status_history_jobcard_idx (jobcard_id, changed_at)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jobcard_id BIGINT UNSIGNED NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL UNIQUE,
mime_type VARCHAR(120) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL,
client_visible BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX attachments_jobcard_idx (jobcard_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS notifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(80) NOT NULL,
title VARCHAR(190) NOT NULL,
body TEXT NULL,
deduplication_key VARCHAR(190) NULL,
read_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY notification_dedup_idx (user_id, deduplication_key),
INDEX notification_unread_idx (user_id, read_at, created_at)
) ENGINE=InnoDB;
INSERT IGNORE INTO permissions (name, description) VALUES
('technical.view', 'View client technical information'),
('technical.manage', 'Manage client technical information'),
('credentials.view', 'View protected credentials'),
('credentials.manage', 'Manage protected credentials'),
('attachments.view', 'View jobcard attachments'),
('attachments.manage', 'Manage jobcard attachments'),
('notifications.view', 'View notifications');
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator';
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN ('technical.view') WHERE r.name = 'Technician';
-- Enforce one SLA agreement per client after duplicate remediation.
SET @sla_unique_exists := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'sla_agreements' AND index_name = 'sla_client_unique'
);
SET @sla_duplicate_count := (
SELECT COUNT(*) FROM (SELECT client_id FROM sla_agreements GROUP BY client_id HAVING COUNT(*) > 1) duplicates_check
);
SET @sla_add_unique_sql := IF(@sla_duplicate_count = 0 AND @sla_unique_exists = 0,
'ALTER TABLE sla_agreements ADD UNIQUE KEY sla_client_unique (client_id)',
IF(@sla_unique_exists > 0, 'SELECT 1', 'SELECT 1 FROM jobcard_upgrade_duplicate_sla_rows'));
PREPARE sla_add_unique_stmt FROM @sla_add_unique_sql;
EXECUTE sla_add_unique_stmt;
DEALLOCATE PREPARE sla_add_unique_stmt;
+76
View File
@@ -0,0 +1,76 @@
# JOBcard UAT checklist
Run this checklist against a production-like deployment over HTTPS with a fresh backup available. Record the date, application version, PHP/MariaDB versions, tester and evidence for each item. Do not record passwords, API keys or credential values in the evidence.
## Pre-flight and deployment
- [ ] The virtual host/document root is `public/`; the repository root, `.env`, SQL files and `storage/` are not web-accessible.
- [ ] `.env` is present outside the public root, has restrictive permissions (for example `chmod 600 .env`), and contains production-only values.
- [ ] `php bin/healthcheck.php` passes with no secret values printed.
- [ ] HTTPS is enabled and HTTP redirects to HTTPS; the certificate and hostname are valid.
- [ ] A backup was taken before UAT and its location/time is recorded separately from this checklist.
## Administrator
- [ ] Sign in with the bootstrap Administrator account; invalid credentials are rejected.
- [ ] The dashboard loads and the Administrator can view clients, jobcards, SLA data, reports and audit events.
- [ ] Create, deactivate and reactivate a test Accounts user and a test Technician user.
- [ ] Assign roles/permissions; verify an unauthorized permission is not granted by merely hiding a navigation link.
- [ ] Assign a Technician to a test jobcard and verify the assignment is visible in the expected workflow.
- [ ] Review audit events for login and test administrative changes; confirm timestamps and actor are present.
- [ ] Verify sensitive credentials are masked by default, access is permission-controlled, and reveal/access is audited (where that module is enabled).
## Accounts
- [ ] Create and edit a client, including contact details and preferred contact method.
- [ ] Add a primary contact and verify duplicate primary contacts are rejected.
- [ ] Create a jobcard with client, priority and requested work; verify its reference number and initial status.
- [ ] Assign a Technician, update the jobcard through the supported statuses, and verify status history.
- [ ] Configure an SLA agreement and verify allocated/used/remaining hours and period boundaries.
- [ ] View reports and export a report if permitted; verify exported data contains only intended fields and no internal notes or credentials.
- [ ] Verify validation errors are understandable and do not discard unrelated entered fields.
## Technician
- [ ] Sign in as a Technician and verify only assigned jobcards are accessible.
- [ ] Verify a Technician cannot access another Technician's jobcard by changing an ID in the URL or form payload.
- [ ] View assigned work, update allowed status/work fields, and add technician notes.
- [ ] Record a valid time entry and verify hours and SLA usage update correctly.
- [ ] Verify invalid, negative, overlapping or unauthorized time-entry cases are rejected according to the configured rules.
- [ ] Verify internal notes, credentials, user administration and unrestricted reports are not exposed to Technician accounts.
## Security and recovery
- [ ] Invalid and expired sessions redirect to login; logout invalidates the session.
- [ ] Verify CSRF protection rejects missing or invalid tokens on every state-changing form.
- [ ] Verify output escaping with a test value containing HTML/script characters; no script executes.
- [ ] Verify prepared statements/parameterized inputs by testing quote and SQL-like characters in names, notes and searches.
- [ ] Confirm login/session cookies use Secure, HttpOnly and SameSite settings appropriate to the deployment.
- [ ] Confirm production error responses do not disclose stack traces, SQL, filesystem paths or secrets; server logs are access-controlled.
- [ ] Confirm `.env`, backups and uploaded files cannot be downloaded through the web server.
- [ ] Verify brute-force/rate-limit and account deactivation controls if configured by the host/application.
## Reports
- [ ] Run reports for an empty date range, a normal range and a boundary date; totals are deterministic and timezone expectations are documented.
- [ ] Verify role-specific report visibility and filters; direct URL access cannot bypass authorization.
- [ ] Verify CSV/other exports handle commas, quotes, line breaks and formula-like values safely.
- [ ] Compare a report total with the underlying test jobcards/time entries and retain the comparison evidence.
## Restore verification
- [ ] Restore the pre-UAT backup to a separate database/server, never over the live database.
- [ ] Apply the documented upgrade (`database/upgrade.sql`, if the restored installation predates the current schema) and record the command/output.
- [ ] Run `php bin/healthcheck.php` against the restored configuration; do not paste secret values into evidence.
- [ ] Log in to the restored system using a test account and verify clients, jobcards, time entries, reports and audit history are present.
- [ ] Verify restored uploads/attachments and permissions, if that module is enabled.
- [ ] Record restore duration, backup timestamp, row/data spot checks and any missing items.
- [ ] Confirm the live system was not modified by restore testing and securely remove the temporary restored copy when approved.
## Sign-off
- Environment/version: ______________________________
- Backup reference: __________________________________
- Tester/date: _______________________________________
- Defects and follow-up owner: _______________________
- UAT result: [ ] Pass [ ] Pass with follow-up [ ] Fail
+159 -16
View File
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/bootstrap.php';
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php';
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
@@ -15,6 +16,11 @@ require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php';
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php';
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
ini_set('session.use_strict_mode', '1');
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
@@ -46,7 +52,7 @@ function render_footer(): void
$route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login');
if ($route === 'logout') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit('Logout requires POST'); }
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Logout requires POST'); }
verify_csrf();
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
$_SESSION = [];
@@ -58,7 +64,7 @@ if ($route === 'logout') {
if ($route === 'login') {
if (current_user()) { header('Location: /?route=dashboard'); exit; }
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => strtolower(trim(scalar_input($_POST['email'] ?? null))) ]);
@@ -101,6 +107,23 @@ if ($route === 'dashboard') {
}
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view'];
if ($route === 'attachment') {
require_login();
$attachmentId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
$attachmentStmt = db()->prepare('SELECT a.*, j.id AS jobcard_id FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id WHERE a.id = :id');
$attachmentStmt->execute(['id' => $attachmentId]);
$attachment = $attachmentStmt->fetch();
if (!$attachment || !can_access_jobcard((int)$attachment['jobcard_id']) || !can('attachments.view')) { http_response_code(404); exit('Attachment not found'); }
$path = dirname(__DIR__) . '/storage/uploads/' . basename($attachment['stored_name']);
if (!is_file($path) || !is_readable($path)) { http_response_code(404); exit('Attachment not found'); }
audit('attachment_downloaded', 'attachment', $attachmentId, ['jobcard_id' => (int)$attachment['jobcard_id']]);
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Length: ' . (string)filesize($path));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $attachment['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path); exit;
}
if ($route === 'jobcard') {
require_permission('jobcards.view');
$jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
@@ -111,7 +134,7 @@ if ($route === 'jobcard') {
if (!$jobcard) { http_response_code(404); exit('Jobcard not found'); }
if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); }
$actionErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$action = scalar_input($_POST['action'] ?? null);
if ($action === 'status') {
@@ -183,12 +206,57 @@ if ($route === 'jobcard') {
audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]);
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
}
} elseif ($action === 'attachment') {
require_permission('attachments.manage');
$file = $_FILES['attachment'] ?? null;
if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) {
$actionErrors[] = 'Select a valid attachment.';
} else {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$signatureValid = match ($mime) {
'image/png' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 8), 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A",
'image/jpeg' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 3), 0, 3) === "\xFF\xD8\xFF",
'image/gif' => in_array(substr((string)file_get_contents($file['tmp_name'], false, null, 0, 6), 0, 6), ['GIF87a', 'GIF89a'], true),
'application/pdf' => str_starts_with((string)file_get_contents($file['tmp_name'], false, null, 0, 5), '%PDF-'),
default => true,
};
if (!$signatureValid) $actionErrors[] = 'Attachment content does not match its detected type.';
if ($actionErrors) { /* validation stops before storage */ }
else {
$attachment = (new \App\Domain\Attachment\AttachmentValidator())->validate(['name' => $file['name'] ?? '', 'mime_type' => $mime, 'size_bytes' => $file['size'] ?? -1, 'client_visible' => isset($_POST['client_visible']), 'client_approved' => isset($_POST['client_approved'])]);
$actionErrors = array_values($attachment['errors']);
if (!$actionErrors) {
$uploadDir = dirname(__DIR__) . '/storage/uploads';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) $actionErrors[] = 'Attachment storage is unavailable.';
if (!$actionErrors) {
$storedName = bin2hex(random_bytes(24)) . '.' . $attachment['extension'];
if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $storedName)) $actionErrors[] = 'Attachment could not be stored.';
else {
try {
db()->beginTransaction();
db()->prepare('INSERT INTO attachments (jobcard_id, original_name, stored_name, mime_type, file_size, client_visible, uploaded_by) VALUES (:jobcard, :original, :stored, :mime, :size, :visible, :user)')->execute(['jobcard' => $jobcardId, 'original' => $attachment['name'], 'stored' => $storedName, 'mime' => $attachment['mime_type'], 'size' => $attachment['size_bytes'], 'visible' => $attachment['client_visible'] ? 1 : 0, 'user' => $user['id']]);
$attachmentId = (int)db()->lastInsertId();
audit('attachment_uploaded', 'attachment', $attachmentId, ['jobcard_id' => $jobcardId]);
db()->commit();
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
} catch (Throwable $exception) {
if (db()->inTransaction()) db()->rollBack();
@unlink($uploadDir . '/' . $storedName);
$actionErrors[] = 'Attachment metadata could not be saved.';
}
}
}
}
}
}
}
}
$jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch();
$assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll();
$technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll();
$timeStmt = db()->prepare('SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll();
$attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll();
$totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries));
render_header('Jobcard ' . $jobcard['reference_no']);
echo '<div class="d-flex justify-content-between align-items-start mb-4"><div><a href="/?route=jobcards" class="text-decoration-none">← Back to jobcards</a><h1 class="h3 mt-2 mb-1">' . e($jobcard['reference_no']) . '</h1><p class="text-muted mb-0">' . e($jobcard['client_name']) . '</p></div><span class="badge text-bg-primary">' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</span></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Jobcard updated.</div>' : '') . ($actionErrors ? '<div class="alert alert-danger">' . e(implode(' ', $actionErrors)) . '</div>' : '');
@@ -203,13 +271,20 @@ if ($route === 'jobcard') {
if (!$assigned) echo '<p class="text-muted">No technicians assigned.</p>'; foreach ($assigned as $assignment) echo '<div class="py-1">' . e($assignment['name']) . '</div>';
if (can('jobcards.assign')) { echo '<hr><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="assign"><select class="form-select mb-2" name="technician_id"><option value="">Select technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select><button class="btn btn-outline-primary w-100">Assign</button></form>'; }
echo '</div></div></div></div>';
if (can('attachments.view') || can('attachments.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Attachments</h2>';
if (!$attachments) echo '<p class="text-muted">No attachments.</p>';
foreach ($attachments as $attachment) echo '<div class="border-bottom py-2"><a href="/?route=attachment&id=' . (int)$attachment['id'] . '"><strong>' . e($attachment['original_name']) . '</strong></a> <span class="small text-muted">' . e($attachment['mime_type']) . ' · ' . e((string)$attachment['file_size']) . ' bytes · ' . ($attachment['client_visible'] ? 'Client approved' : 'Internal') . '</span></div>';
if (can('attachments.manage')) echo '<hr><form method="post" enctype="multipart/form-data" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="attachment"><div class="col-md-6"><input class="form-control" type="file" name="attachment" required></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_visible" value="1" id="attachment-visible"><label class="form-check-label" for="attachment-visible">Client visible</label></div><div class="col-md-3 form-check pt-2"><input class="form-check-input" type="checkbox" name="client_approved" value="1" id="attachment-approved"><label class="form-check-label" for="attachment-approved">Client approval confirmed</label></div><div class="col-12"><button class="btn btn-outline-primary">Upload attachment</button></div></form>';
echo '</div></div>';
}
render_footer(); exit;
}
if ($route === 'jobcards') {
require_permission('jobcards.view');
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
require_permission('jobcards.manage');
verify_csrf();
$command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST);
@@ -218,8 +293,8 @@ if ($route === 'jobcards') {
$priority = $command['priority'];
$errors = array_values($command['errors']);
if (!$errors) {
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
$clientCheck->execute(['id' => $clientId]);
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'" . ($user['role_name'] === 'Technician' ? ' AND EXISTS (SELECT 1 FROM jobcards assigned_j JOIN jobcard_assignments assigned_a ON assigned_a.jobcard_id = assigned_j.id WHERE assigned_j.client_id = clients.id AND assigned_a.user_id = :user)' : ''));
$clientCheck->execute($user['role_name'] === 'Technician' ? ['id' => $clientId, 'user' => $user['id']] : ['id' => $clientId]);
if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
}
if (!$errors) {
@@ -246,7 +321,13 @@ if ($route === 'jobcards') {
}
}
}
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
if ($user['role_name'] === 'Technician') {
$clientListForJobcard = db()->prepare("SELECT DISTINCT c.id, c.name FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE c.status = 'active' ORDER BY c.name");
$clientListForJobcard->execute(['user' => $user['id']]);
$clients = $clientListForJobcard->fetchAll();
} else {
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
}
if ($user['role_name'] === 'Technician') {
$jobcardList = db()->prepare('SELECT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100');
$jobcardList->execute(['user' => $user['id']]);
@@ -272,6 +353,7 @@ if ($route === 'client') {
require_permission('clients.view');
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
if (!$clientId) { http_response_code(400); exit('Invalid client'); }
if (!can_access_client($clientId)) { http_response_code(404); exit('Client not found'); }
$stmt = db()->prepare('SELECT * FROM clients WHERE id = :id');
$stmt->execute(['id' => $clientId]);
$client = $stmt->fetch();
@@ -279,10 +361,36 @@ if ($route === 'client') {
$contactErrors = [];
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
$slaErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$credentialErrors = [];
$revealedCredential = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$clientAction = scalar_input($_POST['action'] ?? null, 'contact');
if ($clientAction === 'sla') {
if ($clientAction === 'credential_reveal') {
require_permission('credentials.view');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$credentialId = filter_var(scalar_input($_POST['credential_id'] ?? null), FILTER_VALIDATE_INT);
$credentialStmt = db()->prepare('SELECT * FROM credentials WHERE id = :id AND client_id = :client AND is_active = 1');
$credentialStmt->execute(['id' => $credentialId, 'client' => $clientId]);
$credential = $credentialStmt->fetch();
if (!$credential) $credentialErrors[] = 'Credential not found.';
else {
try { $revealedCredential = ['id' => (int)$credential['id'], 'secret' => (new \App\Domain\Credential\CredentialVault())->decrypt($credential['secret_ciphertext'])]; audit('credential_revealed', 'credential', (int)$credential['id'], ['client_id' => $clientId]); }
catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be decrypted.'; }
}
} elseif ($clientAction === 'client_update') {
require_permission('clients.manage');
$existingClients = db()->query('SELECT id, name FROM clients')->fetchAll();
$clientUpdate = (new \App\Domain\Client\ClientUpdateCommand())->validateForEdit($clientId, $_POST, $existingClients);
$contactErrors = $clientUpdate['errors'];
if (!$contactErrors) {
$stmt = db()->prepare('UPDATE clients SET name = :name, registration_number = :registration, status = :status, support_email = :email, support_phone = :phone, preferred_contact_method = :method, physical_address = :physical, postal_address = :postal, general_notes = :notes WHERE id = :id');
$stmt->execute(['name' => $clientUpdate['name'], 'registration' => $clientUpdate['registration_number'], 'status' => $clientUpdate['status'], 'email' => $clientUpdate['support_email'], 'phone' => $clientUpdate['support_phone'], 'method' => $clientUpdate['preferred_contact_method'], 'physical' => $clientUpdate['physical_address'], 'postal' => $clientUpdate['postal_address'], 'notes' => $clientUpdate['general_notes'], 'id' => $clientId]);
audit('client_updated', 'client', $clientId);
header('Location: /?route=client&id=' . $clientId . '&client_updated=1'); exit;
}
} elseif ($clientAction === 'sla') {
require_permission('sla.manage');
$sla = (new \App\Domain\SLA\SlaAgreement())->validate([...$_POST, 'client_id' => $clientId, 'enabled' => isset($_POST['enabled']) ? '1' : '0', 'rollover_enabled' => isset($_POST['rollover_enabled']) ? '1' : '0']);
$slaErrors = array_values($sla['errors']);
@@ -291,6 +399,16 @@ if ($route === 'client') {
audit('sla_agreement_updated', 'client', $clientId);
header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit;
}
} elseif ($clientAction === 'credential') {
require_permission('credentials.manage');
try {
$vault = new \App\Domain\Credential\CredentialVault();
$storedCredential = $vault->encryptCredential(['category' => $_POST['category'] ?? null, 'label' => $_POST['label'] ?? null, 'username' => $_POST['username'] ?? null, 'notes' => $_POST['credential_notes'] ?? null, 'secret' => scalar_input($_POST['secret'] ?? null)]);
$credentialInsert = db()->prepare('INSERT INTO credentials (client_id, category, label, username, secret_ciphertext, notes, created_by) VALUES (:client, :category, :label, :username, :ciphertext, :notes, :user)');
$credentialInsert->execute(['client' => $clientId, 'category' => $storedCredential['category'], 'label' => $storedCredential['label'], 'username' => $storedCredential['username'], 'ciphertext' => $storedCredential['secret_ciphertext'], 'notes' => $storedCredential['notes'], 'user' => $user['id']]);
$credentialId = (int)db()->lastInsertId(); audit('credential_created', 'credential', $credentialId, ['client_id' => $clientId]);
header('Location: /?route=client&id=' . $clientId . '&credential_created=1'); exit;
} catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be saved.'; }
} else {
require_permission('clients.manage');
$contact = validate_client_contact($_POST);
@@ -325,6 +443,12 @@ if ($route === 'client') {
$slaStmt->execute(['client' => $clientId]);
$slaAgreement = $slaStmt->fetch() ?: null;
}
$credentialRows = [];
if (can('credentials.view') || can('credentials.manage')) {
$credentialStmt = db()->prepare('SELECT id, category, label, username, secret_ciphertext, notes FROM credentials WHERE client_id = :client AND is_active = 1 ORDER BY category, label');
$credentialStmt->execute(['client' => $clientId]);
$credentialRows = $credentialStmt->fetchAll();
}
render_header('Client details');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
@@ -338,6 +462,19 @@ if ($route === 'client') {
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
echo '</div></div>';
}
if (can('credentials.view') || can('credentials.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Protected credentials</h2>';
if ($credentialErrors) echo '<div class="alert alert-danger">' . e(implode(' ', $credentialErrors)) . '</div>';
if (isset($_GET['credential_created'])) echo '<div class="alert alert-success">Credential saved securely.</div>';
foreach ($credentialRows as $credentialRow) {
echo '<div class="border-bottom py-2"><strong>' . e($credentialRow['label']) . '</strong> <span class="badge text-bg-secondary">' . e($credentialRow['category']) . '</span><div class="small text-muted">Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Secret: ' . ($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'] ? '<code>' . e($revealedCredential['secret']) . '</code>' : '••••••••••••••••••••') . '</div>';
if (can('credentials.view') && !($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'])) echo '<form method="post" class="d-inline"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential_reveal"><input type="hidden" name="credential_id" value="' . (int)$credentialRow['id'] . '"><button class="btn btn-sm btn-link p-0">Reveal once</button></form>';
echo '</div>';
}
if (can('credentials.manage')) { echo '<hr><h3 class="h6">Add credential</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="credential"><div class="col-md-3"><select class="form-select" name="category">'; foreach (\App\Domain\Credential\TechnicalInformation::categories() as $category) echo '<option value="' . e($category) . '">' . e(ucfirst($category)) . '</option>'; echo '</select></div><div class="col-md-3"><input class="form-control" name="label" placeholder="Label" required></div><div class="col-md-3"><input class="form-control" name="username" placeholder="Username"></div><div class="col-md-3"><input class="form-control" type="password" name="secret" placeholder="Secret" required></div><div class="col-12"><textarea class="form-control" name="credential_notes" rows="2" placeholder="Notes"></textarea></div><div class="col-12"><button class="btn btn-primary">Encrypt and save</button></div></form>'; }
echo '</div></div>';
}
if (can('clients.manage')) echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">Edit client</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="client_update"><div class="col-md-6"><label class="form-label">Client name</label><input class="form-control" name="name" value="' . e($client['name']) . '" required></div><div class="col-md-3"><label class="form-label">Status</label><select class="form-select" name="status"><option value="active"' . ($client['status'] === 'active' ? ' selected' : '') . '>Active</option><option value="inactive"' . ($client['status'] === 'inactive' ? ' selected' : '') . '>Inactive</option></select></div><div class="col-md-3"><label class="form-label">Preferred contact</label><input class="form-control" name="preferred_contact_method" value="' . e((string)($client['preferred_contact_method'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support email</label><input class="form-control" type="email" name="support_email" value="' . e((string)($client['support_email'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Support phone</label><input class="form-control" name="support_phone" value="' . e((string)($client['support_phone'] ?? '')) . '"></div><div class="col-md-6"><label class="form-label">Physical address</label><textarea class="form-control" name="physical_address" rows="3">' . e((string)($client['physical_address'] ?? '')) . '</textarea></div><div class="col-md-6"><label class="form-label">Postal address</label><textarea class="form-control" name="postal_address" rows="3">' . e((string)($client['postal_address'] ?? '')) . '</textarea></div><div class="col-12"><label class="form-label">General notes</label><textarea class="form-control" name="general_notes" rows="3">' . e((string)($client['general_notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save client</button></div></form></div></div>';
render_footer();
exit;
}
@@ -346,7 +483,7 @@ if ($route === 'clients') {
require_permission('clients.view');
$errors = [];
$old = ['name' => '', 'status' => 'active'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
require_permission('clients.manage');
verify_csrf();
$validated = validate_client($_POST);
@@ -363,8 +500,14 @@ if ($route === 'clients') {
}
$search = trim(scalar_input($_GET['q'] ?? null));
$stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100');
$stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]);
$clients = $stmt->fetchAll();
if ($user['role_name'] === 'Technician') {
$clientList = db()->prepare("SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE (:search = '' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 100");
$clientList->execute(['user' => $user['id'], 'search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']);
$clients = $clientList->fetchAll();
} else {
$stmt->execute(['search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']);
$clients = $stmt->fetchAll();
}
render_header('Clients');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Clients</h1><p class="text-muted mb-0">Manage client records and support contacts.</p></div>';
if (can('clients.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-client">New client</button>';
@@ -385,7 +528,7 @@ if ($route === 'users') {
require_permission('users.manage');
$userErrors = [];
$userOld = ['name' => '', 'email' => '', 'role_id' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
@@ -415,7 +558,7 @@ if ($route === 'users') {
if (false) {
require_permission('users.manage');
$userErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
@@ -446,8 +589,8 @@ if ($route === 'reports') {
$format = scalar_input($_GET['format'] ?? null);
if ($format === 'csv') require_permission('reports.export');
if ($user['role_name'] === 'Technician') {
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name');
$reportStmt->execute(['user' => $user['id']]);
$reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN te.technician_id = :user THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name');
$reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]);
$reportRows = $reportStmt->fetchAll();
} else {
$reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll();
View File
View File
View File
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php';
require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php';
use App\Domain\Attachment\AttachmentValidator;
use App\Domain\Notification\NotificationRecord;
function attachment_notification_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$attachments = new AttachmentValidator(5_000_000);
$validAttachment = $attachments->validate([
'name' => ' Site Photo.JPG ',
'mime_type' => 'IMAGE/JPEG',
'size' => '2048',
'client_visible' => 'yes',
'client_approved' => 'true',
]);
attachment_notification_assert_same(true, $validAttachment['valid'], 'A safe approved image attachment should validate.');
attachment_notification_assert_same('Site Photo.JPG', $validAttachment['name'], 'Attachment names should be trimmed without changing case.');
attachment_notification_assert_same('jpg', $validAttachment['extension'], 'Attachment extensions should normalize to lowercase.');
attachment_notification_assert_same('image/jpeg', $validAttachment['mime_type'], 'Attachment MIME types should normalize to lowercase.');
attachment_notification_assert_same(2048, $validAttachment['size_bytes'], 'Attachment sizes should normalize to bytes.');
attachment_notification_assert_same(true, $validAttachment['client_visible'], 'Client visibility should normalize to boolean.');
foreach ([
['name' => '../secret.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10],
['name' => 'invoice.php.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10],
['name' => 'photo.jpg', 'mime_type' => 'application/x-php', 'size_bytes' => 10],
['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 5_000_001],
['name' => 'photo.jpg', 'mime_type' => 'image/jpeg', 'size_bytes' => 10, 'client_visible' => true, 'client_approved' => false],
] as $invalidPayload) {
attachment_notification_assert_same(false, $attachments->validate($invalidPayload)['valid'], 'Unsafe attachment metadata should be rejected.');
}
$notifications = new NotificationRecord();
$notification = $notifications->validate([
'type' => ' JOBCARD_STATUS_CHANGED ',
'recipients' => [' Support@Example.com ', 'support@example.com', 'client@example.com'],
'is_read' => '0',
'deduplication_key' => ' Jobcard:42:Status:closed ',
]);
attachment_notification_assert_same(true, $notification['valid'], 'A valid notification should validate.');
attachment_notification_assert_same('jobcard_status_changed', $notification['type'], 'Notification types should normalize to lowercase snake case.');
attachment_notification_assert_same(['support@example.com', 'client@example.com'], $notification['recipients'], 'Recipients should normalize, lowercase and de-duplicate.');
attachment_notification_assert_same(false, $notification['is_read'], 'Unread notification state should normalize to false.');
attachment_notification_assert_same('jobcard:42:status:closed', $notification['deduplication_key'], 'Deduplication keys should normalize case and whitespace.');
$invalidNotification = $notifications->validate([
'type' => 'unknown-event',
'recipients' => ['not-an-email'],
'is_read' => 'maybe',
'deduplication_key' => '',
]);
attachment_notification_assert_same(false, $invalidNotification['valid'], 'Invalid notification metadata should be rejected.');
foreach (['type', 'recipients', 'is_read', 'deduplication_key'] as $field) {
if (!isset($invalidNotification['errors'][$field])) {
throw new RuntimeException("Expected validation error for {$field}.");
}
}
printf("Attachment and notification tests: 7 passed\n");
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php';
require_once __DIR__ . '/../app/Domain/Client/ContactUpdateCommand.php';
use App\Domain\Client\ClientUpdateCommand;
use App\Domain\Client\ContactUpdateCommand;
function client_crud_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$clients = [
['id' => 7, 'name' => 'Acme IT', 'status' => 'active'],
['id' => 9, 'name' => 'Other Client', 'status' => 'inactive'],
];
$clientCommand = new ClientUpdateCommand();
$created = $clientCommand->validateForCreate([
'name' => ' New Client ',
'support_email' => ' NEW@EXAMPLE.TEST ',
], $clients);
client_crud_assert_same(true, $created['valid'], 'A unique client should be accepted for creation.');
client_crud_assert_same('New Client', $created['name'], 'Client names should be normalized before duplicate checks.');
client_crud_assert_same('new@example.test', $created['support_email'], 'Client email should be normalized.');
$duplicate = $clientCommand->validateForCreate(['name' => ' acme it '], $clients);
client_crud_assert_same(false, $duplicate['valid'], 'Normalized duplicate client names should be rejected.');
if (!isset($duplicate['errors']['name'])) throw new RuntimeException('Duplicate client names should produce a name error.');
$edited = $clientCommand->validateForEdit(7, ['name' => ' ACME IT ', 'status' => 'active'], $clients);
client_crud_assert_same(true, $edited['valid'], 'Editing a client should ignore its own duplicate row.');
$deactivated = $clientCommand->validateDeactivate(['id' => 7, 'status' => 'active']);
client_crud_assert_same(['valid' => true, 'id' => 7, 'status' => 'inactive', 'errors' => []], $deactivated, 'Active clients should be deactivatable.');
$reactivated = $clientCommand->validateReactivate(['id' => 9, 'status' => 'inactive']);
client_crud_assert_same(['valid' => true, 'id' => 9, 'status' => 'active', 'errors' => []], $reactivated, 'Inactive clients should be reactivatable.');
$contacts = [
['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'is_primary' => true],
];
$contactCommand = new ContactUpdateCommand();
$contact = $contactCommand->validateForCreate([
'client_id' => '7', 'name' => ' John Doe ', 'email' => ' JOHN@EXAMPLE.TEST ', 'is_primary' => 'yes',
], $contacts);
client_crud_assert_same(true, $contact['valid'], 'A unique contact should be accepted.');
client_crud_assert_same(7, $contact['client_id'], 'Contact client IDs should normalize to integers.');
client_crud_assert_same(true, $contact['is_primary'], 'Primary flags should normalize to booleans.');
client_crud_assert_same([11], $contact['replace_primary_contact_ids'], 'Promoting a contact should identify the prior primary contact.');
$contactDuplicate = $contactCommand->validateForCreate(['client_id' => 7, 'name' => ' jane doe ', 'email' => 'other@example.test'], $contacts);
client_crud_assert_same(false, $contactDuplicate['valid'], 'Duplicate contacts should be rejected after normalization.');
if (!isset($contactDuplicate['errors']['name'])) throw new RuntimeException('Duplicate contact names should produce a name error.');
$contactDisplay = $contactCommand->display(['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'phone' => null, 'is_primary' => true, 'password' => 'secret', 'credentials' => 'token', 'internal_secret' => 'omit']);
client_crud_assert_same(['id' => 11, 'client_id' => 7, 'name' => 'Jane Doe', 'email' => 'jane@example.test', 'phone' => null, 'is_primary' => true], $contactDisplay, 'Contact display projections must exclude credentials and internal secrets.');
printf("Client CRUD tests: 10 passed\n");
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php';
require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php';
use App\Domain\Credential\CredentialVault;
use App\Domain\Credential\TechnicalInformation;
function credential_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$key = base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES));
$vault = new CredentialVault($key);
$secret = 'P@ssword & token 123';
$ciphertext = $vault->encrypt($secret);
if ($ciphertext === $secret || $vault->decrypt($ciphertext) !== $secret) {
throw new RuntimeException('Credentials must round-trip through authenticated encryption.');
}
try {
(new CredentialVault(base64_encode(random_bytes(32))))->decrypt($ciphertext);
throw new RuntimeException('Decrypting with the wrong key should fail.');
} catch (RuntimeException $expected) {
if (!str_contains($expected->getMessage(), 'decrypt')) {
throw new RuntimeException('Wrong-key failure should be explicit.');
}
}
$masked = $vault->mask($secret);
credential_assert_same('••••••••••••••••••••', $masked, 'Secrets must never be shown in plaintext.');
credential_assert_same('••••••••••••••••••••', $vault->display(['secret' => $secret])['secret'], 'Display must mask secret fields.');
$credential = [
'id' => 7,
'category' => 'hosting',
'label' => ' Production Host ',
'username' => ' deploy ',
'notes' => ' SSH access ',
'secret' => $secret,
'internal_token' => 'do not expose',
];
$encrypted = $vault->encryptCredential($credential);
if (array_key_exists('secret', $encrypted) || !isset($encrypted['secret_ciphertext'])) {
throw new RuntimeException('Stored credential records must contain ciphertext, not plaintext.');
}
credential_assert_same($secret, $vault->decryptCredential($encrypted)['secret'], 'Credential records must decrypt their secret.');
$projection = $vault->projectMetadata($encrypted);
credential_assert_same(['id' => 7, 'category' => 'hosting', 'label' => 'Production Host', 'username' => 'deploy', 'notes' => 'SSH access'], $projection, 'Metadata projection must be allow-listed and plaintext-free.');
$info = new TechnicalInformation();
$valid = $info->validate(['category' => 'vpn', 'label' => ' Office VPN ', 'username' => ' alice ', 'notes' => ' MFA enabled ']);
credential_assert_same(true, $valid['valid'], 'Valid technical information should pass.');
credential_assert_same('Office VPN', $valid['label'], 'Labels should be normalized.');
$invalid = $info->validate(['category' => 'unknown', 'label' => ' ', 'username' => "bad\nname", 'notes' => str_repeat('x', 2001)]);
if ($invalid['valid'] || !isset($invalid['errors']['category'], $invalid['errors']['label'], $invalid['errors']['username'], $invalid['errors']['notes'])) {
throw new RuntimeException('Technical information must validate category, label, username, and notes.');
}
foreach (['missing' => null, 'short' => 'short', 'placeholder' => 'generate-a-long-random-secret'] as $name => $badKey) {
try {
new CredentialVault($badKey);
throw new RuntimeException("{$name} APP_KEY material should fail explicitly.");
} catch (RuntimeException $expected) {
if (!str_contains($expected->getMessage(), 'APP_KEY')) {
throw new RuntimeException("{$name} APP_KEY error should mention APP_KEY.");
}
}
}
printf("Credential vault tests: 6 passed\n");
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../bin/healthcheck.php';
function deployment_test_assert(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$checks = 0;
$extensions = deployment_check_extensions(
['present_ext', 'missing_ext'],
static fn(string $extension): bool => $extension === 'present_ext',
);
deployment_test_assert($extensions === ['present_ext' => true, 'missing_ext' => false], 'Extension checks must preserve names and availability.');
$checks++;
$environment = deployment_check_environment(
['DB_HOST', 'DB_PASSWORD', 'EMPTY_VALUE'],
static fn(string $name): ?string => ['DB_HOST' => 'localhost', 'DB_PASSWORD' => 'secret', 'EMPTY_VALUE' => ' '][$name] ?? null,
);
deployment_test_assert($environment === ['DB_HOST' => true, 'DB_PASSWORD' => true, 'EMPTY_VALUE' => false], 'Environment checks must only report presence, never values.');
$checks++;
$directories = deployment_check_directories(
['/srv/jobcard/runtime', '/srv/jobcard/uploads'],
static fn(string $directory): bool => $directory === '/srv/jobcard/runtime',
);
deployment_test_assert($directories === ['/srv/jobcard/runtime' => true, '/srv/jobcard/uploads' => false], 'Directory checks must report writability without changing directories.');
$checks++;
$report = deployment_format_check_report([
'DB_PASSWORD' => true,
'DB_HOST' => false,
]);
deployment_test_assert($report === ['DB_PASSWORD' => 'OK', 'DB_HOST' => 'FAIL'], 'Reports must contain statuses only.');
$checks++;
printf("Deployment checks tests: %d passed\n", $checks);
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.php';
require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php';
require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php';
$filters = ReportFilters::fromArray([
'client_id' => 7,
'date_from' => '2026-09-01',
'date_to' => '2026-09-30',
'technician_id' => 4,
'status' => 'open',
'priority' => 'high',
'sla' => 'at_risk',
]);
if (!$filters->matches(['client_id' => 7, 'created_at' => '2026-09-10', 'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla_status' => 'at_risk'])) {
throw new RuntimeException('Expected report filters to match all selected criteria.');
}
if ($filters->matches(['client_id' => 7, 'created_at' => '2026-10-01', 'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla_status' => 'at_risk'])) {
throw new RuntimeException('Expected date range to exclude rows outside the range.');
}
$jobcards = (new ClientJobcardReport($filters))->build([
['id' => 2, 'client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-2', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-10', 'technician_id' => 4, 'technician_name' => 'Tess', 'sla_status' => 'at_risk', 'internal_notes' => 'secret', 'credentials' => 'omit'],
['id' => 1, 'client_id' => 8, 'client_name' => 'Beta', 'reference_no' => 'JC-1', 'status' => 'closed', 'priority' => 'low', 'created_at' => '2026-09-01', 'internal_notes' => 'secret'],
], 'client');
if ($jobcards !== [['client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-2', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-10']]) {
throw new RuntimeException('Client jobcard report must filter, sort and allow-list deterministically.');
}
$history = (new ClientHistoryReport())->build([
['jobcard_id' => 3, 'client_id' => 7, 'reference_no' => 'JC-3', 'from_status' => 'new', 'to_status' => 'open', 'changed_at' => '2026-09-03', 'changed_by_name' => 'Tess', 'internal_notes' => 'secret'],
], 'client');
if ($history[0] !== ['client_id' => 7, 'reference_no' => 'JC-3', 'from_status' => 'new', 'to_status' => 'open', 'changed_at' => '2026-09-03']) {
throw new RuntimeException('Client history report must exclude internal actor/details.');
}
$activity = (new TechnicianActivityReport())->build([
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1.25, 'counts_toward_sla' => true, 'internal_notes' => 'secret'],
['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-03', 'hours' => 2.75, 'counts_toward_sla' => false],
], 'internal');
if ($activity !== [['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 4.0, 'sla_hours' => 1.25]]) {
throw new RuntimeException('Technician activity must aggregate hours deterministically.');
}
$html = (new PrintReportRenderer())->render('Client Jobcards', ['Reference', 'Status'], [['JC-2', 'open']]);
if (!str_contains($html, '@media print') || !str_contains($html, '<th>Reference</th>') || !str_contains($html, 'JC-2') || str_contains($html, '<script>')) {
throw new RuntimeException('Print report renderer must emit escaped, print-friendly HTML.');
}
printf("Report workflow tests: 5 passed\n");
+1 -1
View File
@@ -30,7 +30,7 @@ reporting_assert_same(
'Client-facing report data must be allow-listed.'
);
reporting_assert_same(
['internal_notes' => 'never disclose', 'password' => 'secret', 'credentials' => 'token', 'technical_ip' => '10.0.0.1', 'unknown_field' => 'not approved'],
['id' => 7, 'status' => 'active', 'internal_notes' => 'never disclose'],
$mapper->internal($record),
'Internal report data must remain separate from client-facing data.'
);
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/User/RoleRecord.php';
require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php';
use App\Domain\User\PermissionMatrix;
use App\Domain\User\RoleRecord;
function role_permission_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
function role_permission_assert_throws(callable $callback, string $message): void
{
try {
$callback();
} catch (LogicException) {
return;
}
throw new RuntimeException($message);
}
$roles = new RoleRecord();
role_permission_assert_same([
'name' => 'Support Team',
'description' => 'Handles customer support',
], $roles->normalize([
'name' => ' Support Team ',
'description' => ' Handles customer support ',
'permissions' => ['users.manage'],
]), 'Role normalization should trim supported fields and ignore unrelated fields.');
$valid = $roles->validate(['name' => 'Support Team', 'description' => str_repeat('x', 255)]);
role_permission_assert_same(true, $valid['valid'], 'A schema-sized custom role should validate.');
role_permission_assert_same([], $valid['errors'], 'Valid role should have no errors.');
$invalid = $roles->validate(['name' => "\x01", 'description' => str_repeat('x', 256)]);
role_permission_assert_same(false, $invalid['valid'], 'Invalid role data should report invalid.');
if (!isset($invalid['errors']['name'], $invalid['errors']['description'])) {
throw new RuntimeException('Role validation should report name and description errors.');
}
role_permission_assert_throws(
fn() => $roles->assertCanRename(['name' => 'Administrator'], 'Security Administrator'),
'Administrator must not be renamed.'
);
role_permission_assert_throws(
fn() => $roles->assertCanDelete(['name' => ' administrator ']),
'Administrator must not be deleted.'
);
role_permission_assert_throws(
fn() => $roles->assertCanChangePermissions(['name' => 'Administrator']),
'Administrator permissions must not be changed.'
);
role_permission_assert_throws(
fn() => $roles->assertCanRename(['name' => 'Accounts'], ' administrator '),
'A custom role must not be renamed to Administrator.'
);
role_permission_assert_same(true, $roles->canRename(['name' => 'Accounts'], 'Support'), 'Custom roles should be renameable.');
role_permission_assert_same(false, $roles->canDelete(['name' => 'Administrator']), 'Administrator deletion safeguard should be queryable.');
$display = $roles->display([
'id' => 4,
'name' => 'Support Team',
'description' => 'Handles support',
'created_at' => '2026-09-01 08:00:00',
'permissions' => ['clients.view'],
'password_hash' => 'secret',
'internal_notes' => 'omit',
]);
role_permission_assert_same([
'id' => 4,
'name' => 'Support Team',
'description' => 'Handles support',
'created_at' => '2026-09-01 08:00:00',
'permissions' => ['clients.view'],
], $display, 'Role display projection must allow-list safe fields.');
$permissions = new PermissionMatrix();
role_permission_assert_same([
'clients.view',
'jobcards.manage',
'reports.view',
], $permissions->normalize([
' clients.view ',
'jobcards.manage',
'clients.view',
'',
'reports.view',
' ',
]), 'Permissions should be trimmed, blank entries removed, and duplicates de-duplicated.');
role_permission_assert_same([
'clients.view',
'jobcards.manage',
], $permissions->normalize(['CLIENTS.VIEW', 'clients.view', ' jobcards.manage ']), 'Permission normalization should use canonical lower-case names.');
role_permission_assert_same([
'id' => 4,
'name' => 'Support Team',
'permissions' => ['clients.view', 'jobcards.manage'],
], $permissions->display([
'id' => 4,
'name' => 'Support Team',
'permissions' => ['clients.view', 'jobcards.manage', 'clients.view'],
'password' => 'secret',
'token' => 'secret',
]), 'Permission display projection must be safe and normalized.');
printf("Role and permission tests: 10 passed\n");