From de2bf277c49bfab3635645f09818ba6a4e83cf06 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Tue, 1 Sep 2026 20:43:24 +0200 Subject: [PATCH] feat: complete jobcard client management foundation --- .gitignore | 6 +- app/Domain/Attachment/AttachmentValidator.php | 105 +++++++++++ app/Domain/Client/ClientRecord.php | 10 +- app/Domain/Client/ClientUpdateCommand.php | 118 ++++++++++++ app/Domain/Client/ContactUpdateCommand.php | 135 ++++++++++++++ app/Domain/Credential/CredentialVault.php | 136 ++++++++++++++ .../Credential/TechnicalInformation.php | 84 +++++++++ app/Domain/Notification/NotificationQueue.php | 36 ++++ .../Notification/NotificationRecord.php | 94 ++++++++++ app/Domain/Reporting/ClientHistoryReport.php | 19 ++ app/Domain/Reporting/ClientJobcardReport.php | 19 ++ app/Domain/Reporting/PrintReportRenderer.php | 25 +++ app/Domain/Reporting/ReportDataMapper.php | 78 +++----- app/Domain/Reporting/ReportFilters.php | 71 +++++++ app/Domain/Reporting/ReportQuery.php | 9 + .../Reporting/TechnicianActivityReport.php | 28 +++ app/Domain/User/PermissionMatrix.php | 60 ++++++ app/Domain/User/RoleRecord.php | 123 ++++++++++++ bin/healthcheck.php | 130 +++++++++++++ config/bootstrap.php | 10 + database/schema.sql | 70 ++++++- database/upgrade.sql | 111 +++++++++++ docs/uat-checklist.md | 76 ++++++++ public/index.php | 175 ++++++++++++++++-- storage/.gitkeep | 0 storage/logs/.gitkeep | 0 storage/uploads/.gitkeep | 0 tests/AttachmentNotificationTest.php | 68 +++++++ tests/ClientCrudTest.php | 60 ++++++ tests/CredentialVaultTest.php | 75 ++++++++ tests/DeploymentChecksTest.php | 43 +++++ tests/ReportWorkflowTest.php | 55 ++++++ tests/ReportingContractsTest.php | 2 +- tests/RolePermissionTest.php | 115 ++++++++++++ 34 files changed, 2072 insertions(+), 74 deletions(-) create mode 100644 app/Domain/Attachment/AttachmentValidator.php create mode 100644 app/Domain/Client/ClientUpdateCommand.php create mode 100644 app/Domain/Client/ContactUpdateCommand.php create mode 100644 app/Domain/Credential/CredentialVault.php create mode 100644 app/Domain/Credential/TechnicalInformation.php create mode 100644 app/Domain/Notification/NotificationQueue.php create mode 100644 app/Domain/Notification/NotificationRecord.php create mode 100644 app/Domain/Reporting/ClientHistoryReport.php create mode 100644 app/Domain/Reporting/ClientJobcardReport.php create mode 100644 app/Domain/Reporting/PrintReportRenderer.php create mode 100644 app/Domain/Reporting/ReportFilters.php create mode 100644 app/Domain/Reporting/ReportQuery.php create mode 100644 app/Domain/Reporting/TechnicianActivityReport.php create mode 100644 app/Domain/User/PermissionMatrix.php create mode 100644 app/Domain/User/RoleRecord.php create mode 100644 bin/healthcheck.php create mode 100644 database/upgrade.sql create mode 100644 docs/uat-checklist.md create mode 100644 storage/.gitkeep create mode 100644 storage/logs/.gitkeep create mode 100644 storage/uploads/.gitkeep create mode 100644 tests/AttachmentNotificationTest.php create mode 100644 tests/ClientCrudTest.php create mode 100644 tests/CredentialVaultTest.php create mode 100644 tests/DeploymentChecksTest.php create mode 100644 tests/ReportWorkflowTest.php create mode 100644 tests/RolePermissionTest.php diff --git a/.gitignore b/.gitignore index 28885c2..97bc49a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ vendor/ node_modules/ .DS_Store -storage/logs/ -storage/uploads/ +storage/logs/* +storage/uploads/* +!storage/logs/.gitkeep +!storage/uploads/.gitkeep .phpunit.result.cache diff --git a/app/Domain/Attachment/AttachmentValidator.php b/app/Domain/Attachment/AttachmentValidator.php new file mode 100644 index 0000000..d094f04 --- /dev/null +++ b/app/Domain/Attachment/AttachmentValidator.php @@ -0,0 +1,105 @@ +> */ + 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 */ + 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 */ + 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; + } +} diff --git a/app/Domain/Client/ClientRecord.php b/app/Domain/Client/ClientRecord.php index 9d10d3b..ecd230b 100644 --- a/app/Domain/Client/ClientRecord.php +++ b/app/Domain/Client/ClientRecord.php @@ -40,7 +40,7 @@ final class ClientRecord public function normalize(array $record): array { return [ - 'name' => $this->text($record['name'] ?? null) ?? '', + 'name' => $this->normalizeName($record['name'] ?? null), 'registration_number' => $this->text($record['registration_number'] ?? null), 'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'), 'support_email' => $this->lowerText($record['support_email'] ?? null), @@ -114,6 +114,14 @@ final class ClientRecord return $text === null ? null : strtolower($text); } + private function normalizeName(mixed $value): ?string + { + $text = $this->text($value); + if ($text === null) return null; + $collapsed = preg_replace('/\s+/u', ' ', $text); + return $collapsed === false ? $text : $collapsed; + } + private function validPhone(string $phone): bool { if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) { diff --git a/app/Domain/Client/ClientUpdateCommand.php b/app/Domain/Client/ClientUpdateCommand.php new file mode 100644 index 0000000..d3f0bca --- /dev/null +++ b/app/Domain/Client/ClientUpdateCommand.php @@ -0,0 +1,118 @@ + */ + 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 */ + public function validateForCreate(array $input, array $existingClients = []): array + { + return $this->validate($input, $existingClients); + } + + /** @return array */ + 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} */ + public function validateDeactivate(array $client): array + { + return $this->validateTransition($client, 'active', 'inactive'); + } + + /** @return array{valid: bool, id: int, status: string, errors: array} */ + public function validateReactivate(array $client): array + { + return $this->validateTransition($client, 'inactive', 'active'); + } + + /** @return array{valid: bool, id: int, status: string, errors: array} */ + public function deactivate(array $client): array + { + return $this->validateDeactivate($client); + } + + /** @return array{valid: bool, id: int, status: string, errors: array} */ + public function reactivate(array $client): array + { + return $this->validateReactivate($client); + } + + /** @return array */ + public function display(array $client): array + { + return ($this->clients ?? new ClientRecord())->display($client); + } + + /** @return array */ + public function toDisplay(array $client): array + { + return $this->display($client); + } + + /** @return array{valid: bool, id: int, status: string, errors: array} */ + 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; + } +} diff --git a/app/Domain/Client/ContactUpdateCommand.php b/app/Domain/Client/ContactUpdateCommand.php new file mode 100644 index 0000000..2530087 --- /dev/null +++ b/app/Domain/Client/ContactUpdateCommand.php @@ -0,0 +1,135 @@ + */ + private const DISPLAY_FIELDS = ['id', 'client_id', 'name', 'email', 'phone', 'is_primary', 'notes']; + + /** @return array */ + 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 */ + public function validateForCreate(array $input, array $existingContacts = []): array + { + return $this->validate($input, $existingContacts); + } + + /** @return array */ + 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 */ + 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 */ + 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)); + } +} diff --git a/app/Domain/Credential/CredentialVault.php b/app/Domain/Credential/CredentialVault.php new file mode 100644 index 0000000..74fded8 --- /dev/null +++ b/app/Domain/Credential/CredentialVault.php @@ -0,0 +1,136 @@ +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 */ + 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 */ + public function toDisplay(array $credential): array + { + return $this->display($credential); + } + + /** @return array */ + 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 */ + 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 */ + 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; + } +} diff --git a/app/Domain/Credential/TechnicalInformation.php b/app/Domain/Credential/TechnicalInformation.php new file mode 100644 index 0000000..b498d03 --- /dev/null +++ b/app/Domain/Credential/TechnicalInformation.php @@ -0,0 +1,84 @@ + */ + 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,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 */ + 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 */ + 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; + } +} diff --git a/app/Domain/Notification/NotificationQueue.php b/app/Domain/Notification/NotificationQueue.php new file mode 100644 index 0000000..aa8314d --- /dev/null +++ b/app/Domain/Notification/NotificationQueue.php @@ -0,0 +1,36 @@ + 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; + } +} diff --git a/app/Domain/Notification/NotificationRecord.php b/app/Domain/Notification/NotificationRecord.php new file mode 100644 index 0000000..919880d --- /dev/null +++ b/app/Domain/Notification/NotificationRecord.php @@ -0,0 +1,94 @@ + */ + private const TYPES = [ + 'jobcard_created', 'jobcard_status_changed', 'assignment_created', + 'time_entry_created', 'sla_threshold', 'attachment_uploaded', + ]; + + /** @return array */ + 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 */ + 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 */ + 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 */ + 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; + } +} diff --git a/app/Domain/Reporting/ClientHistoryReport.php b/app/Domain/Reporting/ClientHistoryReport.php new file mode 100644 index 0000000..7e1e151 --- /dev/null +++ b/app/Domain/Reporting/ClientHistoryReport.php @@ -0,0 +1,19 @@ +> $rows @return list> */ + 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); } +} diff --git a/app/Domain/Reporting/ClientJobcardReport.php b/app/Domain/Reporting/ClientJobcardReport.php new file mode 100644 index 0000000..808dc3c --- /dev/null +++ b/app/Domain/Reporting/ClientJobcardReport.php @@ -0,0 +1,19 @@ +> $rows @return list> */ + 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); } +} diff --git a/app/Domain/Reporting/PrintReportRenderer.php b/app/Domain/Reporting/PrintReportRenderer.php new file mode 100644 index 0000000..892de01 --- /dev/null +++ b/app/Domain/Reporting/PrintReportRenderer.php @@ -0,0 +1,25 @@ + $headers @param list> $rows */ + public function render(string $title, array $headers, array $rows): string + { + $head = implode('', array_map(fn(mixed $value): string => '' . $this->escape($value) . '', $headers)); + $body = ''; + foreach ($rows as $row) { + $body .= '' . implode('', array_map(fn(mixed $value): string => '' . $this->escape($value) . '', $row)) . ''; + } + return '' . $this->escape($title) . '

' . $this->escape($title) . '

' . $head . '' . $body . '
'; + } + + /** @param list> $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'); } +} diff --git a/app/Domain/Reporting/ReportDataMapper.php b/app/Domain/Reporting/ReportDataMapper.php index 479fe4c..25437e7 100644 --- a/app/Domain/Reporting/ReportDataMapper.php +++ b/app/Domain/Reporting/ReportDataMapper.php @@ -1,64 +1,38 @@ */ private const CLIENT_FIELDS = [ - 'id', - 'name', - 'registration_number', - 'status', - 'support_email', - 'support_phone', - 'preferred_contact_method', - 'physical_address', - 'postal_address', - 'general_notes', - 'client_id', - 'client_name', - 'reference_no', - 'priority', - 'work_requested', - 'completed_at', - 'closed_at', - 'allocated_hours', - 'used_hours', - 'remaining_hours', - 'usage_percentage', - 'status_label', - 'period_type', - 'start_date', - 'end_date', - 'hours', + 'id','name','status','support_email','client_id','client_name','reference_no','priority','work_requested','created_at','completed_at','closed_at','changed_at','from_status','to_status','hours','sla_hours','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date', + ]; + private const INTERNAL_FIELDS = [ + 'id','client_id','client_name','reference_no','status','priority','work_requested','technician_id','technician_name','created_at','completed_at','closed_at','changed_at','from_status','to_status','changed_by','changed_by_name','work_date','hours','sla_hours','counts_toward_sla','sla_status','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date','internal_notes','technician_notes', ]; - /** @return array */ - public function clientFacing(array $record): array - { - $safe = []; - foreach (self::CLIENT_FIELDS as $field) { - if (array_key_exists($field, $record)) { - $safe[$field] = $record[$field]; - } - } - return $safe; - } + /** @return array */ + public function clientFacing(array $record): array { return $this->allow($record, self::CLIENT_FIELDS); } + /** @return array */ + public function internal(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } + /** @return array{client:array,internal:array} */ + public function map(array $record): array { return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; } + /** @return array */ + 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 */ + public function internalJobcard(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } + /** @return array */ + 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 */ + public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } + /** @return array */ + public function clientActivity(array $record): array { return $this->clientFacing($record); } + /** @return array */ + public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } - /** @return array */ - public function internal(array $record): array + private function allow(array $record, array $fields): array { - return array_diff_key($record, $this->clientFacing($record)); - } - - /** @return array{client: array, internal: array} */ - public function map(array $record): array - { - return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; + $result = []; + foreach ($fields as $field) if (array_key_exists($field, $record)) $result[$field] = $record[$field]; + return $result; } } diff --git a/app/Domain/Reporting/ReportFilters.php b/app/Domain/Reporting/ReportFilters.php new file mode 100644 index 0000000..fd12c97 --- /dev/null +++ b/app/Domain/Reporting/ReportFilters.php @@ -0,0 +1,71 @@ +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 */ + 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; + } +} diff --git a/app/Domain/Reporting/ReportQuery.php b/app/Domain/Reporting/ReportQuery.php new file mode 100644 index 0000000..43c9489 --- /dev/null +++ b/app/Domain/Reporting/ReportQuery.php @@ -0,0 +1,9 @@ +> $rows @return list> */ + public function build(array $rows, string $audience = 'client'): array; +} diff --git a/app/Domain/Reporting/TechnicianActivityReport.php b/app/Domain/Reporting/TechnicianActivityReport.php new file mode 100644 index 0000000..235e7b7 --- /dev/null +++ b/app/Domain/Reporting/TechnicianActivityReport.php @@ -0,0 +1,28 @@ +> $rows @return list> */ + 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); } +} diff --git a/app/Domain/User/PermissionMatrix.php b/app/Domain/User/PermissionMatrix.php new file mode 100644 index 0000000..a29dbe6 --- /dev/null +++ b/app/Domain/User/PermissionMatrix.php @@ -0,0 +1,60 @@ + */ + 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 $permissions + * @return list + */ + 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 $record @return array */ + 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 $record @return array */ + public function toDisplay(array $record): array + { + return $this->display($record); + } +} diff --git a/app/Domain/User/RoleRecord.php b/app/Domain/User/RoleRecord.php new file mode 100644 index 0000000..5d023b3 --- /dev/null +++ b/app/Domain/User/RoleRecord.php @@ -0,0 +1,123 @@ + */ + 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} */ + 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 */ + 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 */ + 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)) : ''; + } +} diff --git a/bin/healthcheck.php b/bin/healthcheck.php new file mode 100644 index 0000000..0d6a948 --- /dev/null +++ b/bin/healthcheck.php @@ -0,0 +1,130 @@ + + */ +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 + */ +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 + */ +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 $checks + * @return array + */ +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()); +} diff --git a/config/bootstrap.php b/config/bootstrap.php index 35f42b8..75f4d17 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -128,6 +128,16 @@ function can_access_jobcard(int $jobcardId): bool return (bool)$stmt->fetchColumn(); } +function can_access_client(int $clientId): bool +{ + $user = current_user(); + if (!$user) return false; + if ($user['role_name'] !== 'Technician') return can('clients.view'); + $stmt = db()->prepare('SELECT 1 FROM jobcard_assignments ja JOIN jobcards j ON j.id = ja.jobcard_id WHERE ja.user_id = :user AND j.client_id = :client LIMIT 1'); + $stmt->execute(['user' => $user['id'], 'client' => $clientId]); + return (bool)$stmt->fetchColumn(); +} + function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void { $user = current_user(); diff --git a/database/schema.sql b/database/schema.sql index b2ddb05..8e66249 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -72,6 +72,37 @@ CREATE TABLE IF NOT EXISTS jobcard_sequences ( next_sequence INT UNSIGNED NOT NULL ) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS technical_information ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(80) NOT NULL, + data_json JSON NOT NULL, + updated_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY technical_client_category (client_id, category) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS credentials ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(80) NOT NULL, + label VARCHAR(120) NOT NULL, + username VARCHAR(190) NULL, + secret_ciphertext TEXT NOT NULL, + notes TEXT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT UNSIGNED NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX credentials_client_idx (client_id), + INDEX credentials_category_idx (category) +) ENGINE=InnoDB; + CREATE TABLE IF NOT EXISTS sla_agreements ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, client_id BIGINT UNSIGNED NOT NULL, @@ -153,6 +184,35 @@ CREATE TABLE IF NOT EXISTS time_entries ( INDEX time_date_idx (work_date) ) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS attachments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jobcard_id BIGINT UNSIGNED NOT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL UNIQUE, + mime_type VARCHAR(120) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL, + client_visible BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by BIGINT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX attachments_jobcard_idx (jobcard_id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(80) NOT NULL, + title VARCHAR(190) NOT NULL, + body TEXT NULL, + deduplication_key VARCHAR(190) NULL, + read_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY notification_dedup_idx (user_id, deduplication_key), + INDEX notification_unread_idx (user_id, read_at, created_at) +) ENGINE=InnoDB; + CREATE TABLE IF NOT EXISTS audit_events ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NULL, @@ -181,14 +241,20 @@ INSERT IGNORE INTO permissions (name, description) VALUES ('jobcards.assign', 'Assign technicians to jobcards'), ('jobcards.internal_notes', 'View and edit internal jobcard notes'), ('time_entries.record', 'Record technician time entries'), + ('technical.view', 'View client technical information'), + ('technical.manage', 'Manage client technical information'), + ('credentials.view', 'View protected credentials'), + ('credentials.manage', 'Manage protected credentials'), + ('attachments.view', 'View jobcard attachments'), + ('attachments.manage', 'Manage jobcard attachments'), + ('notifications.view', 'View notifications'), ('sla.view', 'View client SLA agreements and usage'), ('sla.manage', 'Configure client SLA agreements'), ('reports.view', 'View reports'), ('reports.export', 'Export reports'), ('users.manage', 'Manage users'), ('roles.manage', 'Manage roles and permissions'), - ('audit.view', 'View audit events'), - ('credentials.view', 'View protected credentials'); + ('audit.view', 'View audit events'); INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administrator'; diff --git a/database/upgrade.sql b/database/upgrade.sql new file mode 100644 index 0000000..ffd4853 --- /dev/null +++ b/database/upgrade.sql @@ -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; diff --git a/docs/uat-checklist.md b/docs/uat-checklist.md new file mode 100644 index 0000000..52a72c1 --- /dev/null +++ b/docs/uat-checklist.md @@ -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 diff --git a/public/index.php b/public/index.php index 195e80a..b113f32 100644 --- a/public/index.php +++ b/public/index.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/bootstrap.php'; require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php'; require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php'; require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php'; +require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php'; require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php'; @@ -15,6 +16,11 @@ require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php'; require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php'; require_once __DIR__ . '/../app/Domain/User/UserRecord.php'; require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php'; +require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php'; +require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php'; +require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php'; +require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php'; +require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php'; ini_set('session.use_strict_mode', '1'); $forwardedHttps = getenv('TRUST_PROXY') === '1' && scalar_input($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; @@ -46,7 +52,7 @@ function render_footer(): void $route = scalar_input($_GET['route'] ?? null, current_user() ? 'dashboard' : 'login'); if ($route === 'logout') { - if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit('Logout requires POST'); } + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { http_response_code(405); exit('Logout requires POST'); } verify_csrf(); if (current_user()) audit('logout', 'user', (int)current_user()['id']); $_SESSION = []; @@ -58,7 +64,7 @@ if ($route === 'logout') { if ($route === 'login') { if (current_user()) { header('Location: /?route=dashboard'); exit; } $error = null; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $stmt = db()->prepare('SELECT u.*, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = :email LIMIT 1'); $stmt->execute(['email' => strtolower(trim(scalar_input($_POST['email'] ?? null))) ]); @@ -101,6 +107,23 @@ if ($route === 'dashboard') { } $permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; +if ($route === 'attachment') { + require_login(); + $attachmentId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); + $attachmentStmt = db()->prepare('SELECT a.*, j.id AS jobcard_id FROM attachments a JOIN jobcards j ON j.id = a.jobcard_id WHERE a.id = :id'); + $attachmentStmt->execute(['id' => $attachmentId]); + $attachment = $attachmentStmt->fetch(); + if (!$attachment || !can_access_jobcard((int)$attachment['jobcard_id']) || !can('attachments.view')) { http_response_code(404); exit('Attachment not found'); } + $path = dirname(__DIR__) . '/storage/uploads/' . basename($attachment['stored_name']); + if (!is_file($path) || !is_readable($path)) { http_response_code(404); exit('Attachment not found'); } + audit('attachment_downloaded', 'attachment', $attachmentId, ['jobcard_id' => (int)$attachment['jobcard_id']]); + header('Content-Type: ' . $attachment['mime_type']); + header('Content-Length: ' . (string)filesize($path)); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $attachment['original_name']) . '"'); + header('X-Content-Type-Options: nosniff'); + readfile($path); exit; +} + if ($route === 'jobcard') { require_permission('jobcards.view'); $jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); @@ -111,7 +134,7 @@ if ($route === 'jobcard') { if (!$jobcard) { http_response_code(404); exit('Jobcard not found'); } if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); } $actionErrors = []; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $action = scalar_input($_POST['action'] ?? null); if ($action === 'status') { @@ -183,12 +206,57 @@ if ($route === 'jobcard') { audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]); header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } + } elseif ($action === 'attachment') { + require_permission('attachments.manage'); + $file = $_FILES['attachment'] ?? null; + if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'] ?? '')) { + $actionErrors[] = 'Select a valid attachment.'; + } else { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($file['tmp_name']); + $signatureValid = match ($mime) { + 'image/png' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 8), 0, 8) === "\x89PNG\x0D\x0A\x1A\x0A", + 'image/jpeg' => substr((string)file_get_contents($file['tmp_name'], false, null, 0, 3), 0, 3) === "\xFF\xD8\xFF", + 'image/gif' => in_array(substr((string)file_get_contents($file['tmp_name'], false, null, 0, 6), 0, 6), ['GIF87a', 'GIF89a'], true), + 'application/pdf' => str_starts_with((string)file_get_contents($file['tmp_name'], false, null, 0, 5), '%PDF-'), + default => true, + }; + if (!$signatureValid) $actionErrors[] = 'Attachment content does not match its detected type.'; + if ($actionErrors) { /* validation stops before storage */ } + else { + $attachment = (new \App\Domain\Attachment\AttachmentValidator())->validate(['name' => $file['name'] ?? '', 'mime_type' => $mime, 'size_bytes' => $file['size'] ?? -1, 'client_visible' => isset($_POST['client_visible']), 'client_approved' => isset($_POST['client_approved'])]); + $actionErrors = array_values($attachment['errors']); + if (!$actionErrors) { + $uploadDir = dirname(__DIR__) . '/storage/uploads'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) $actionErrors[] = 'Attachment storage is unavailable.'; + if (!$actionErrors) { + $storedName = bin2hex(random_bytes(24)) . '.' . $attachment['extension']; + if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $storedName)) $actionErrors[] = 'Attachment could not be stored.'; + else { + try { + db()->beginTransaction(); + db()->prepare('INSERT INTO attachments (jobcard_id, original_name, stored_name, mime_type, file_size, client_visible, uploaded_by) VALUES (:jobcard, :original, :stored, :mime, :size, :visible, :user)')->execute(['jobcard' => $jobcardId, 'original' => $attachment['name'], 'stored' => $storedName, 'mime' => $attachment['mime_type'], 'size' => $attachment['size_bytes'], 'visible' => $attachment['client_visible'] ? 1 : 0, 'user' => $user['id']]); + $attachmentId = (int)db()->lastInsertId(); + audit('attachment_uploaded', 'attachment', $attachmentId, ['jobcard_id' => $jobcardId]); + db()->commit(); + header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; + } catch (Throwable $exception) { + if (db()->inTransaction()) db()->rollBack(); + @unlink($uploadDir . '/' . $storedName); + $actionErrors[] = 'Attachment metadata could not be saved.'; + } + } + } + } + } + } } } $jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch(); $assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll(); $technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll(); $timeStmt = db()->prepare('SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll(); + $attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll(); $totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries)); render_header('Jobcard ' . $jobcard['reference_no']); echo '
← Back to jobcards

' . e($jobcard['reference_no']) . '

' . e($jobcard['client_name']) . '

' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '
' . (isset($_GET['updated']) ? '
Jobcard updated.
' : '') . ($actionErrors ? '
' . e(implode(' ', $actionErrors)) . '
' : ''); @@ -203,13 +271,20 @@ if ($route === 'jobcard') { if (!$assigned) echo '

No technicians assigned.

'; foreach ($assigned as $assignment) echo '
' . e($assignment['name']) . '
'; if (can('jobcards.assign')) { echo '
'; } echo ''; + if (can('attachments.view') || can('attachments.manage')) { + echo '

Attachments

'; + if (!$attachments) echo '

No attachments.

'; + foreach ($attachments as $attachment) echo '
' . e($attachment['original_name']) . ' ' . e($attachment['mime_type']) . ' · ' . e((string)$attachment['file_size']) . ' bytes · ' . ($attachment['client_visible'] ? 'Client approved' : 'Internal') . '
'; + if (can('attachments.manage')) echo '
'; + echo '
'; + } render_footer(); exit; } if ($route === 'jobcards') { require_permission('jobcards.view'); $errors = []; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { require_permission('jobcards.manage'); verify_csrf(); $command = (new \App\Domain\Jobcard\JobcardWorkflow())->validateCommand($_POST); @@ -218,8 +293,8 @@ if ($route === 'jobcards') { $priority = $command['priority']; $errors = array_values($command['errors']); if (!$errors) { - $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'"); - $clientCheck->execute(['id' => $clientId]); + $clientCheck = db()->prepare("SELECT id FROM clients WHERE id = :id AND status = 'active'" . ($user['role_name'] === 'Technician' ? ' AND EXISTS (SELECT 1 FROM jobcards assigned_j JOIN jobcard_assignments assigned_a ON assigned_a.jobcard_id = assigned_j.id WHERE assigned_j.client_id = clients.id AND assigned_a.user_id = :user)' : '')); + $clientCheck->execute($user['role_name'] === 'Technician' ? ['id' => $clientId, 'user' => $user['id']] : ['id' => $clientId]); if (!$clientCheck->fetchColumn()) $errors[] = 'The selected client is not active or does not exist.'; } if (!$errors) { @@ -246,7 +321,13 @@ if ($route === 'jobcards') { } } } - $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); + if ($user['role_name'] === 'Technician') { + $clientListForJobcard = db()->prepare("SELECT DISTINCT c.id, c.name FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE c.status = 'active' ORDER BY c.name"); + $clientListForJobcard->execute(['user' => $user['id']]); + $clients = $clientListForJobcard->fetchAll(); + } else { + $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); + } if ($user['role_name'] === 'Technician') { $jobcardList = db()->prepare('SELECT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100'); $jobcardList->execute(['user' => $user['id']]); @@ -272,6 +353,7 @@ if ($route === 'client') { require_permission('clients.view'); $clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); if (!$clientId) { http_response_code(400); exit('Invalid client'); } + if (!can_access_client($clientId)) { http_response_code(404); exit('Client not found'); } $stmt = db()->prepare('SELECT * FROM clients WHERE id = :id'); $stmt->execute(['id' => $clientId]); $client = $stmt->fetch(); @@ -279,10 +361,36 @@ if ($route === 'client') { $contactErrors = []; $contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false]; $slaErrors = []; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $credentialErrors = []; + $revealedCredential = null; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $clientAction = scalar_input($_POST['action'] ?? null, 'contact'); - if ($clientAction === 'sla') { + if ($clientAction === 'credential_reveal') { + require_permission('credentials.view'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + $credentialId = filter_var(scalar_input($_POST['credential_id'] ?? null), FILTER_VALIDATE_INT); + $credentialStmt = db()->prepare('SELECT * FROM credentials WHERE id = :id AND client_id = :client AND is_active = 1'); + $credentialStmt->execute(['id' => $credentialId, 'client' => $clientId]); + $credential = $credentialStmt->fetch(); + if (!$credential) $credentialErrors[] = 'Credential not found.'; + else { + try { $revealedCredential = ['id' => (int)$credential['id'], 'secret' => (new \App\Domain\Credential\CredentialVault())->decrypt($credential['secret_ciphertext'])]; audit('credential_revealed', 'credential', (int)$credential['id'], ['client_id' => $clientId]); } + catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be decrypted.'; } + } + } elseif ($clientAction === 'client_update') { + require_permission('clients.manage'); + $existingClients = db()->query('SELECT id, name FROM clients')->fetchAll(); + $clientUpdate = (new \App\Domain\Client\ClientUpdateCommand())->validateForEdit($clientId, $_POST, $existingClients); + $contactErrors = $clientUpdate['errors']; + if (!$contactErrors) { + $stmt = db()->prepare('UPDATE clients SET name = :name, registration_number = :registration, status = :status, support_email = :email, support_phone = :phone, preferred_contact_method = :method, physical_address = :physical, postal_address = :postal, general_notes = :notes WHERE id = :id'); + $stmt->execute(['name' => $clientUpdate['name'], 'registration' => $clientUpdate['registration_number'], 'status' => $clientUpdate['status'], 'email' => $clientUpdate['support_email'], 'phone' => $clientUpdate['support_phone'], 'method' => $clientUpdate['preferred_contact_method'], 'physical' => $clientUpdate['physical_address'], 'postal' => $clientUpdate['postal_address'], 'notes' => $clientUpdate['general_notes'], 'id' => $clientId]); + audit('client_updated', 'client', $clientId); + header('Location: /?route=client&id=' . $clientId . '&client_updated=1'); exit; + } + } elseif ($clientAction === 'sla') { require_permission('sla.manage'); $sla = (new \App\Domain\SLA\SlaAgreement())->validate([...$_POST, 'client_id' => $clientId, 'enabled' => isset($_POST['enabled']) ? '1' : '0', 'rollover_enabled' => isset($_POST['rollover_enabled']) ? '1' : '0']); $slaErrors = array_values($sla['errors']); @@ -291,6 +399,16 @@ if ($route === 'client') { audit('sla_agreement_updated', 'client', $clientId); header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit; } + } elseif ($clientAction === 'credential') { + require_permission('credentials.manage'); + try { + $vault = new \App\Domain\Credential\CredentialVault(); + $storedCredential = $vault->encryptCredential(['category' => $_POST['category'] ?? null, 'label' => $_POST['label'] ?? null, 'username' => $_POST['username'] ?? null, 'notes' => $_POST['credential_notes'] ?? null, 'secret' => scalar_input($_POST['secret'] ?? null)]); + $credentialInsert = db()->prepare('INSERT INTO credentials (client_id, category, label, username, secret_ciphertext, notes, created_by) VALUES (:client, :category, :label, :username, :ciphertext, :notes, :user)'); + $credentialInsert->execute(['client' => $clientId, 'category' => $storedCredential['category'], 'label' => $storedCredential['label'], 'username' => $storedCredential['username'], 'ciphertext' => $storedCredential['secret_ciphertext'], 'notes' => $storedCredential['notes'], 'user' => $user['id']]); + $credentialId = (int)db()->lastInsertId(); audit('credential_created', 'credential', $credentialId, ['client_id' => $clientId]); + header('Location: /?route=client&id=' . $clientId . '&credential_created=1'); exit; + } catch (Throwable $exception) { $credentialErrors[] = 'Credential could not be saved.'; } } else { require_permission('clients.manage'); $contact = validate_client_contact($_POST); @@ -325,6 +443,12 @@ if ($route === 'client') { $slaStmt->execute(['client' => $clientId]); $slaAgreement = $slaStmt->fetch() ?: null; } + $credentialRows = []; + if (can('credentials.view') || can('credentials.manage')) { + $credentialStmt = db()->prepare('SELECT id, category, label, username, secret_ciphertext, notes FROM credentials WHERE client_id = :client AND is_active = 1 ORDER BY category, label'); + $credentialStmt->execute(['client' => $clientId]); + $credentialRows = $credentialStmt->fetchAll(); + } render_header('Client details'); echo '
← Back to clients

' . e($client['name']) . '

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '
' . (isset($_GET['contact_created']) ? '
Contact added successfully.
' : '') . (isset($_GET['sla_updated']) ? '
SLA agreement updated.
' : '') . ($contactErrors ? '
' . e(implode(' ', $contactErrors)) . '
' : '') . ($slaErrors ? '
' . e(implode(' ', $slaErrors)) . '
' : '') . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; if (!$contacts) echo '

No contacts recorded.

'; @@ -338,6 +462,19 @@ if ($route === 'client') { else echo '

No SLA agreement configured.

'; echo '
'; } + if (can('credentials.view') || can('credentials.manage')) { + echo '

Protected credentials

'; + if ($credentialErrors) echo '
' . e(implode(' ', $credentialErrors)) . '
'; + if (isset($_GET['credential_created'])) echo '
Credential saved securely.
'; + foreach ($credentialRows as $credentialRow) { + echo '
' . e($credentialRow['label']) . ' ' . e($credentialRow['category']) . '
Username: ' . e((string)($credentialRow['username'] ?? '—')) . ' · Secret: ' . ($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'] ? '' . e($revealedCredential['secret']) . '' : '••••••••••••••••••••') . '
'; + if (can('credentials.view') && !($revealedCredential && $revealedCredential['id'] === (int)$credentialRow['id'])) echo '
'; + echo '
'; + } + if (can('credentials.manage')) { echo '

Add credential

'; } + echo '
'; + } + if (can('clients.manage')) echo '

Edit client

'; render_footer(); exit; } @@ -346,7 +483,7 @@ if ($route === 'clients') { require_permission('clients.view'); $errors = []; $old = ['name' => '', 'status' => 'active']; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { require_permission('clients.manage'); verify_csrf(); $validated = validate_client($_POST); @@ -363,8 +500,14 @@ if ($route === 'clients') { } $search = trim(scalar_input($_GET['q'] ?? null)); $stmt = db()->prepare('SELECT id, name, status, support_email, support_phone, created_at FROM clients WHERE (:search = \'\' OR name LIKE :like_name OR support_email LIKE :like_email) ORDER BY name LIMIT 100'); - $stmt->execute(['search' => $search, 'like_name' => "%{$search}%", 'like_email' => "%{$search}%"]); - $clients = $stmt->fetchAll(); + if ($user['role_name'] === 'Technician') { + $clientList = db()->prepare("SELECT DISTINCT c.id, c.name, c.status, c.support_email, c.support_phone, c.created_at FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user WHERE (:search = '' OR c.name LIKE :like_name OR c.support_email LIKE :like_email) ORDER BY c.name LIMIT 100"); + $clientList->execute(['user' => $user['id'], 'search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']); + $clients = $clientList->fetchAll(); + } else { + $stmt->execute(['search' => $search, 'like_name' => '%' . $search . '%', 'like_email' => '%' . $search . '%']); + $clients = $stmt->fetchAll(); + } render_header('Clients'); echo '

Clients

Manage client records and support contacts.

'; if (can('clients.manage')) echo ''; @@ -385,7 +528,7 @@ if ($route === 'users') { require_permission('users.manage'); $userErrors = []; $userOld = ['name' => '', 'email' => '', 'role_id' => '']; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null]; $validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput); @@ -415,7 +558,7 @@ if ($route === 'users') { if (false) { require_permission('users.manage'); $userErrors = []; - if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null]; $validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput); @@ -446,8 +589,8 @@ if ($route === 'reports') { $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); if ($user['role_name'] === 'Technician') { - $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name'); - $reportStmt->execute(['user' => $user['id']]); + $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN te.technician_id = :user THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name'); + $reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]); $reportRows = $reportStmt->fetchAll(); } else { $reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll(); diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/logs/.gitkeep b/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/uploads/.gitkeep b/storage/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/AttachmentNotificationTest.php b/tests/AttachmentNotificationTest.php new file mode 100644 index 0000000..04fff28 --- /dev/null +++ b/tests/AttachmentNotificationTest.php @@ -0,0 +1,68 @@ +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"); diff --git a/tests/ClientCrudTest.php b/tests/ClientCrudTest.php new file mode 100644 index 0000000..b150ea8 --- /dev/null +++ b/tests/ClientCrudTest.php @@ -0,0 +1,60 @@ + 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"); diff --git a/tests/CredentialVaultTest.php b/tests/CredentialVaultTest.php new file mode 100644 index 0000000..0fe984f --- /dev/null +++ b/tests/CredentialVaultTest.php @@ -0,0 +1,75 @@ +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"); diff --git a/tests/DeploymentChecksTest.php b/tests/DeploymentChecksTest.php new file mode 100644 index 0000000..679e462 --- /dev/null +++ b/tests/DeploymentChecksTest.php @@ -0,0 +1,43 @@ + $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); diff --git a/tests/ReportWorkflowTest.php b/tests/ReportWorkflowTest.php new file mode 100644 index 0000000..8774105 --- /dev/null +++ b/tests/ReportWorkflowTest.php @@ -0,0 +1,55 @@ + 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, 'Reference') || !str_contains($html, 'JC-2') || str_contains($html, '