feat: complete jobcard client management foundation
This commit is contained in:
+4
-2
@@ -4,6 +4,8 @@
|
|||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
storage/logs/
|
storage/logs/*
|
||||||
storage/uploads/
|
storage/uploads/*
|
||||||
|
!storage/logs/.gitkeep
|
||||||
|
!storage/uploads/.gitkeep
|
||||||
.phpunit.result.cache
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ final class ClientRecord
|
|||||||
public function normalize(array $record): array
|
public function normalize(array $record): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->text($record['name'] ?? null) ?? '',
|
'name' => $this->normalizeName($record['name'] ?? null),
|
||||||
'registration_number' => $this->text($record['registration_number'] ?? null),
|
'registration_number' => $this->text($record['registration_number'] ?? null),
|
||||||
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
|
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
|
||||||
'support_email' => $this->lowerText($record['support_email'] ?? null),
|
'support_email' => $this->lowerText($record['support_email'] ?? null),
|
||||||
@@ -114,6 +114,14 @@ final class ClientRecord
|
|||||||
return $text === null ? null : strtolower($text);
|
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
|
private function validPhone(string $phone): bool
|
||||||
{
|
{
|
||||||
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
|
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'); }
|
||||||
|
}
|
||||||
@@ -1,64 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
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
|
final class ReportDataMapper
|
||||||
{
|
{
|
||||||
/** @var list<string> */
|
|
||||||
private const CLIENT_FIELDS = [
|
private const CLIENT_FIELDS = [
|
||||||
'id',
|
'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',
|
||||||
'name',
|
];
|
||||||
'registration_number',
|
private const INTERNAL_FIELDS = [
|
||||||
'status',
|
'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',
|
||||||
'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',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string,mixed> */
|
||||||
public function clientFacing(array $record): array
|
public function clientFacing(array $record): array { return $this->allow($record, self::CLIENT_FIELDS); }
|
||||||
{
|
/** @return array<string,mixed> */
|
||||||
$safe = [];
|
public function internal(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
|
||||||
foreach (self::CLIENT_FIELDS as $field) {
|
/** @return array{client:array<string,mixed>,internal:array<string,mixed>} */
|
||||||
if (array_key_exists($field, $record)) {
|
public function map(array $record): array { return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; }
|
||||||
$safe[$field] = $record[$field];
|
/** @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> */
|
||||||
return $safe;
|
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> */
|
private function allow(array $record, array $fields): array
|
||||||
public function internal(array $record): array
|
|
||||||
{
|
{
|
||||||
return array_diff_key($record, $this->clientFacing($record));
|
$result = [];
|
||||||
}
|
foreach ($fields as $field) if (array_key_exists($field, $record)) $result[$field] = $record[$field];
|
||||||
|
return $result;
|
||||||
/** @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)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -128,6 +128,16 @@ function can_access_jobcard(int $jobcardId): bool
|
|||||||
return (bool)$stmt->fetchColumn();
|
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
|
function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void
|
||||||
{
|
{
|
||||||
$user = current_user();
|
$user = current_user();
|
||||||
|
|||||||
+68
-2
@@ -72,6 +72,37 @@ CREATE TABLE IF NOT EXISTS jobcard_sequences (
|
|||||||
next_sequence INT UNSIGNED NOT NULL
|
next_sequence INT UNSIGNED NOT NULL
|
||||||
) ENGINE=InnoDB;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS sla_agreements (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
client_id BIGINT UNSIGNED NOT NULL,
|
client_id BIGINT UNSIGNED NOT NULL,
|
||||||
@@ -153,6 +184,35 @@ CREATE TABLE IF NOT EXISTS time_entries (
|
|||||||
INDEX time_date_idx (work_date)
|
INDEX time_date_idx (work_date)
|
||||||
) ENGINE=InnoDB;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id BIGINT UNSIGNED NULL,
|
user_id BIGINT UNSIGNED NULL,
|
||||||
@@ -181,14 +241,20 @@ INSERT IGNORE INTO permissions (name, description) VALUES
|
|||||||
('jobcards.assign', 'Assign technicians to jobcards'),
|
('jobcards.assign', 'Assign technicians to jobcards'),
|
||||||
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
|
('jobcards.internal_notes', 'View and edit internal jobcard notes'),
|
||||||
('time_entries.record', 'Record technician time entries'),
|
('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.view', 'View client SLA agreements and usage'),
|
||||||
('sla.manage', 'Configure client SLA agreements'),
|
('sla.manage', 'Configure client SLA agreements'),
|
||||||
('reports.view', 'View reports'),
|
('reports.view', 'View reports'),
|
||||||
('reports.export', 'Export reports'),
|
('reports.export', 'Export reports'),
|
||||||
('users.manage', 'Manage users'),
|
('users.manage', 'Manage users'),
|
||||||
('roles.manage', 'Manage roles and permissions'),
|
('roles.manage', 'Manage roles and permissions'),
|
||||||
('audit.view', 'View audit events'),
|
('audit.view', 'View audit events');
|
||||||
('credentials.view', 'View protected credentials');
|
|
||||||
|
|
||||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
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';
|
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
+157
-14
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/bootstrap.php';
|
|||||||
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php';
|
||||||
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
|
require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php';
|
||||||
require_once __DIR__ . '/../app/Domain/Client/ClientRecord.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/JobcardReference.php';
|
||||||
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
|
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php';
|
||||||
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.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/PasswordPolicy.php';
|
||||||
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
|
||||||
require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.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');
|
ini_set('session.use_strict_mode', '1');
|
||||||
$forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
$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');
|
$route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login');
|
||||||
|
|
||||||
if ($route === 'logout') {
|
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();
|
verify_csrf();
|
||||||
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
|
if (current_user()) audit('logout', 'user', (int)current_user()['id']);
|
||||||
$_SESSION = [];
|
$_SESSION = [];
|
||||||
@@ -58,7 +64,7 @@ if ($route === 'logout') {
|
|||||||
if ($route === 'login') {
|
if ($route === 'login') {
|
||||||
if (current_user()) { header('Location: /?route=dashboard'); exit; }
|
if (current_user()) { header('Location: /?route=dashboard'); exit; }
|
||||||
$error = null;
|
$error = null;
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
verify_csrf();
|
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 = 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))) ]);
|
$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'];
|
$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') {
|
if ($route === 'jobcard') {
|
||||||
require_permission('jobcards.view');
|
require_permission('jobcards.view');
|
||||||
$jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
$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 (!$jobcard) { http_response_code(404); exit('Jobcard not found'); }
|
||||||
if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); }
|
if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); }
|
||||||
$actionErrors = [];
|
$actionErrors = [];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$action = scalar_input($_POST['action'] ?? null);
|
$action = scalar_input($_POST['action'] ?? null);
|
||||||
if ($action === 'status') {
|
if ($action === 'status') {
|
||||||
@@ -183,12 +206,57 @@ if ($route === 'jobcard') {
|
|||||||
audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]);
|
audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]);
|
||||||
header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit;
|
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();
|
$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();
|
$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();
|
$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();
|
$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));
|
$totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries));
|
||||||
render_header('Jobcard ' . $jobcard['reference_no']);
|
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>' : '');
|
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 (!$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>'; }
|
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>';
|
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;
|
render_footer(); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($route === 'jobcards') {
|
if ($route === 'jobcards') {
|
||||||
require_permission('jobcards.view');
|
require_permission('jobcards.view');
|
||||||
$errors = [];
|
$errors = [];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
require_permission('jobcards.manage');
|
require_permission('jobcards.manage');
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST);
|
$command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST);
|
||||||
@@ -218,8 +293,8 @@ if ($route === 'jobcards') {
|
|||||||
$priority = $command['priority'];
|
$priority = $command['priority'];
|
||||||
$errors = array_values($command['errors']);
|
$errors = array_values($command['errors']);
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
$clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'");
|
$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(['id' => $clientId]);
|
$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 (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.';
|
||||||
}
|
}
|
||||||
if (!$errors) {
|
if (!$errors) {
|
||||||
@@ -246,7 +321,13 @@ if ($route === 'jobcards') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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();
|
$clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll();
|
||||||
|
}
|
||||||
if ($user['role_name'] === 'Technician') {
|
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 = 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']]);
|
$jobcardList->execute(['user' => $user['id']]);
|
||||||
@@ -272,6 +353,7 @@ if ($route === 'client') {
|
|||||||
require_permission('clients.view');
|
require_permission('clients.view');
|
||||||
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
$clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT);
|
||||||
if (!$clientId) { http_response_code(400); exit('Invalid client'); }
|
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 = db()->prepare('SELECT * FROM clients WHERE id = :id');
|
||||||
$stmt->execute(['id' => $clientId]);
|
$stmt->execute(['id' => $clientId]);
|
||||||
$client = $stmt->fetch();
|
$client = $stmt->fetch();
|
||||||
@@ -279,10 +361,36 @@ if ($route === 'client') {
|
|||||||
$contactErrors = [];
|
$contactErrors = [];
|
||||||
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
|
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
|
||||||
$slaErrors = [];
|
$slaErrors = [];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
$credentialErrors = [];
|
||||||
|
$revealedCredential = null;
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$clientAction = scalar_input($_POST['action'] ?? null, 'contact');
|
$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');
|
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']);
|
$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']);
|
$slaErrors = array_values($sla['errors']);
|
||||||
@@ -291,6 +399,16 @@ if ($route === 'client') {
|
|||||||
audit('sla_agreement_updated', 'client', $clientId);
|
audit('sla_agreement_updated', 'client', $clientId);
|
||||||
header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit;
|
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 {
|
} else {
|
||||||
require_permission('clients.manage');
|
require_permission('clients.manage');
|
||||||
$contact = validate_client_contact($_POST);
|
$contact = validate_client_contact($_POST);
|
||||||
@@ -325,6 +443,12 @@ if ($route === 'client') {
|
|||||||
$slaStmt->execute(['client' => $clientId]);
|
$slaStmt->execute(['client' => $clientId]);
|
||||||
$slaAgreement = $slaStmt->fetch() ?: null;
|
$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');
|
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>';
|
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>';
|
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>';
|
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
|
||||||
echo '</div></div>';
|
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();
|
render_footer();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -346,7 +483,7 @@ if ($route === 'clients') {
|
|||||||
require_permission('clients.view');
|
require_permission('clients.view');
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$old = ['name' => '', 'status' => 'active'];
|
$old = ['name' => '', 'status' => 'active'];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
require_permission('clients.manage');
|
require_permission('clients.manage');
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$validated = validate_client($_POST);
|
$validated = validate_client($_POST);
|
||||||
@@ -363,8 +500,14 @@ if ($route === 'clients') {
|
|||||||
}
|
}
|
||||||
$search = trim(scalar_input($_GET['q'] ?? null));
|
$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 = 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}%"]);
|
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();
|
$clients = $stmt->fetchAll();
|
||||||
|
}
|
||||||
render_header('Clients');
|
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>';
|
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>';
|
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');
|
require_permission('users.manage');
|
||||||
$userErrors = [];
|
$userErrors = [];
|
||||||
$userOld = ['name' => '', 'email' => '', 'role_id' => ''];
|
$userOld = ['name' => '', 'email' => '', 'role_id' => ''];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
|
$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);
|
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
|
||||||
@@ -415,7 +558,7 @@ if ($route === 'users') {
|
|||||||
if (false) {
|
if (false) {
|
||||||
require_permission('users.manage');
|
require_permission('users.manage');
|
||||||
$userErrors = [];
|
$userErrors = [];
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
verify_csrf();
|
verify_csrf();
|
||||||
$userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null];
|
$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);
|
$validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput);
|
||||||
@@ -446,8 +589,8 @@ if ($route === 'reports') {
|
|||||||
$format = scalar_input($_GET['format'] ?? null);
|
$format = scalar_input($_GET['format'] ?? null);
|
||||||
if ($format === 'csv') require_permission('reports.export');
|
if ($format === 'csv') require_permission('reports.export');
|
||||||
if ($user['role_name'] === 'Technician') {
|
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 = 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']]);
|
$reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]);
|
||||||
$reportRows = $reportStmt->fetchAll();
|
$reportRows = $reportStmt->fetchAll();
|
||||||
} else {
|
} 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();
|
$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();
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
@@ -30,7 +30,7 @@ reporting_assert_same(
|
|||||||
'Client-facing report data must be allow-listed.'
|
'Client-facing report data must be allow-listed.'
|
||||||
);
|
);
|
||||||
reporting_assert_same(
|
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),
|
$mapper->internal($record),
|
||||||
'Internal report data must remain separate from client-facing data.'
|
'Internal report data must remain separate from client-facing data.'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user