feat: complete jobcard client management foundation

This commit is contained in:
Marco0300
2026-09-01 20:43:24 +02:00
parent 168c15c6ae
commit de2bf277c4
34 changed files with 2072 additions and 74 deletions
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Domain\Attachment;
/** Validates upload metadata without reading, storing, or moving the file. */
final class AttachmentValidator
{
/** @var array<string, list<string>> */
private const MIME_BY_EXTENSION = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'pdf' => ['application/pdf'],
'txt' => ['text/plain'],
'csv' => ['text/csv', 'application/csv'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
];
public function __construct(private readonly int $maxSizeBytes = 10_000_000)
{
if ($maxSizeBytes < 1) {
throw new \InvalidArgumentException('Maximum attachment size must be positive.');
}
}
/** @return array<string, mixed> */
public function normalize(array $payload): array
{
$name = is_scalar($payload['name'] ?? $payload['original_name'] ?? null)
? trim((string) ($payload['name'] ?? $payload['original_name'])) : '';
$mime = is_scalar($payload['mime_type'] ?? $payload['mime'] ?? null)
? strtolower(trim((string) ($payload['mime_type'] ?? $payload['mime']))) : '';
$size = $this->normalizeSize($payload['size_bytes'] ?? $payload['size'] ?? null);
$extension = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
return [
'name' => $name,
'extension' => $extension,
'mime_type' => $mime,
'size_bytes' => $size,
'client_visible' => $this->normalizeBoolean($payload['client_visible'] ?? false),
'client_approved' => $this->normalizeBoolean($payload['client_approved'] ?? $payload['approved'] ?? false),
];
}
/** @return array<string, mixed> */
public function validate(array $payload): array
{
$normalized = $this->normalize($payload);
$errors = [];
$name = $normalized['name'];
$extension = $normalized['extension'];
$mime = $normalized['mime_type'];
if ($name === '' || mb_strlen($name) > 255 || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1 || $name[0] === '.') {
$errors['name'] = 'Attachment name must be a safe file name of 255 characters or fewer.';
}
if ($extension === '' || !isset(self::MIME_BY_EXTENSION[$extension]) || preg_match('/(?:^|\.)php(?:\.|$)/i', $name) === 1) {
$errors['extension'] = 'Attachment extension is not allowed.';
}
if ($mime === '' || !in_array($mime, self::MIME_BY_EXTENSION[$extension] ?? [], true)) {
$errors['mime_type'] = 'Attachment MIME type does not match the allowed extension.';
}
if (!is_int($normalized['size_bytes']) || $normalized['size_bytes'] < 0 || $normalized['size_bytes'] > $this->maxSizeBytes) {
$errors['size_bytes'] = 'Attachment size must be between 0 and the configured maximum.';
}
foreach (['client_visible', 'client_approved'] as $field) {
if (!is_bool($normalized[$field])) {
$errors[$field] = 'Attachment approval flags must be boolean.';
}
}
if ($normalized['client_visible'] === true && $normalized['client_approved'] !== true) {
$errors['client_approved'] = 'Client-visible attachments require explicit client approval.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
private function normalizeSize(mixed $value): mixed
{
if (is_int($value)) return $value;
if (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$integer = filter_var(trim($value), FILTER_VALIDATE_INT);
return $integer === false ? $value : $integer;
}
return $value;
}
private function normalizeBoolean(mixed $value): mixed
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
return $value;
}
}
+9 -1
View File
@@ -40,7 +40,7 @@ final class ClientRecord
public function normalize(array $record): array
{
return [
'name' => $this->text($record['name'] ?? null) ?? '',
'name' => $this->normalizeName($record['name'] ?? null),
'registration_number' => $this->text($record['registration_number'] ?? null),
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
'support_email' => $this->lowerText($record['support_email'] ?? null),
@@ -114,6 +114,14 @@ final class ClientRecord
return $text === null ? null : strtolower($text);
}
private function normalizeName(mixed $value): ?string
{
$text = $this->text($value);
if ($text === null) return null;
$collapsed = preg_replace('/\s+/u', ' ', $text);
return $collapsed === false ? $text : $collapsed;
}
private function validPhone(string $phone): bool
{
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ClientRecord.php';
/** Validates client create/edit and explicit active-state transitions. */
final class ClientUpdateCommand
{
public function __construct(private readonly ?ClientRecord $clients = null)
{
}
/** @return array<string, mixed> */
public function validate(array $input, array $existingClients = [], ?int $currentId = null): array
{
$result = ($this->clients ?? new ClientRecord())->validate($input);
$nameKey = $this->duplicateKey(is_string($result['name'] ?? null) ? $result['name'] : '');
if ($nameKey !== '' && $this->hasDuplicate($nameKey, $existingClients, $currentId)) {
$result['errors']['name'] = 'Client name is already in use.';
}
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function validateForCreate(array $input, array $existingClients = []): array
{
return $this->validate($input, $existingClients);
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingClients = []): array
{
$errors = [];
if ($id < 1) $errors['id'] = 'Client ID must be a positive integer.';
$result = $this->validate($input, $existingClients, $id);
$result['id'] = $id;
$result['errors'] = [...$errors, ...$result['errors']];
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function validateDeactivate(array $client): array
{
return $this->validateTransition($client, 'active', 'inactive');
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function validateReactivate(array $client): array
{
return $this->validateTransition($client, 'inactive', 'active');
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function deactivate(array $client): array
{
return $this->validateDeactivate($client);
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
public function reactivate(array $client): array
{
return $this->validateReactivate($client);
}
/** @return array<string, mixed> */
public function display(array $client): array
{
return ($this->clients ?? new ClientRecord())->display($client);
}
/** @return array<string, mixed> */
public function toDisplay(array $client): array
{
return $this->display($client);
}
/** @return array{valid: bool, id: int, status: string, errors: array<string, string>} */
private function validateTransition(array $client, string $from, string $to): array
{
$id = $this->positiveId($client['id'] ?? null);
$status = strtolower(trim(is_scalar($client['status'] ?? null) ? (string) $client['status'] : ''));
$errors = [];
if ($id === null) $errors['id'] = 'Client ID must be a positive integer.';
if ($status !== $from) $errors['status'] = "Only {$from} clients can be changed to {$to}.";
return ['valid' => $errors === [], 'id' => $id ?? 0, 'status' => $to, 'errors' => $errors];
}
private function hasDuplicate(string $candidate, array $rows, ?int $currentId): bool
{
foreach ($rows as $row) {
$name = is_array($row) ? ($row['name'] ?? null) : $row;
if (!is_scalar($name) || $this->duplicateKey((string) $name) !== $candidate) continue;
$rowId = is_array($row) ? $this->positiveId($row['id'] ?? null) : null;
if ($currentId === null || $rowId !== $currentId) return true;
}
return false;
}
private function duplicateKey(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return strtolower($collapsed === false ? trim($value) : $collapsed);
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ClientContactValidator.php';
/** Validates client-contact create/edit payloads, including primary promotion. */
final class ContactUpdateCommand
{
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'client_id', 'name', 'email', 'phone', 'is_primary', 'notes'];
/** @return array<string, mixed> */
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
{
$contact = validate_client_contact($input);
$contact['name'] = $this->name($contact['name']);
$clientId = $this->positiveId($input['client_id'] ?? null);
$notes = $this->text($input['notes'] ?? null);
$errors = $contact['errors'];
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
if ($notes !== null && mb_strlen($notes) > 10000) $errors['notes'] = 'Contact notes must be 10000 characters or fewer.';
if ($clientId !== null) {
foreach (['name' => $contact['name'], 'email' => $contact['email']] as $field => $value) {
if ($value === null || $value === '') continue;
if ($this->hasDuplicate($field, (string) $value, $clientId, $existingContacts, $currentId)) {
$errors[$field] = "Contact {$field} is already in use for this client.";
}
}
}
$replace = [];
if ($clientId !== null && $contact['is_primary'] === true) {
foreach ($existingContacts as $row) {
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
if (!$this->asBool($row['is_primary'] ?? false)) continue;
$id = $this->positiveId($row['id'] ?? null);
if ($id !== null && $id !== $currentId) $replace[] = $id;
}
}
return [
'client_id' => $clientId,
'name' => $contact['name'],
'email' => $contact['email'],
'phone' => $contact['phone'],
'is_primary' => $contact['is_primary'],
'notes' => $notes,
'replace_primary_contact_ids' => $replace,
'valid' => $errors === [],
'errors' => $errors,
];
}
/** @return array<string, mixed> */
public function validateForCreate(array $input, array $existingContacts = []): array
{
return $this->validate($input, $existingContacts);
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
{
$result = $this->validate($input, $existingContacts, $id);
$result['id'] = $id;
if ($id < 1) $result['errors']['id'] = 'Contact ID must be a positive integer.';
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function display(array $contact): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $contact)) $safe[$field] = $contact[$field];
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $contact): array
{
return $this->display($contact);
}
private function hasDuplicate(string $field, string $value, int $clientId, array $rows, ?int $currentId): bool
{
$candidate = $field === 'email' ? strtolower(trim($value)) : $this->duplicateKey($value);
foreach ($rows as $row) {
if (!is_array($row) || $this->positiveId($row['client_id'] ?? null) !== $clientId) continue;
$rowId = $this->positiveId($row['id'] ?? null);
if ($currentId !== null && $rowId === $currentId) continue;
$other = $row[$field] ?? null;
if ($other !== null && ($field === 'email' ? strtolower(trim((string) $other)) === $candidate : $this->duplicateKey((string) $other) === $candidate)) return true;
}
return false;
}
private function duplicateKey(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return strtolower($collapsed === false ? trim($value) : $collapsed);
}
private function name(string $value): string
{
$collapsed = preg_replace('/\s+/u', ' ', trim($value));
return $collapsed === false ? trim($value) : $collapsed;
}
private function text(mixed $value): ?string
{
if (!is_scalar($value)) return null;
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function positiveId(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$id = filter_var(trim($value), FILTER_VALIDATE_INT);
return $id === false ? null : $id;
}
return null;
}
private function asBool(mixed $value): bool
{
return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true));
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
use InvalidArgumentException;
use RuntimeException;
/**
* Framework-free credential encryption using XChaCha20-Poly1305 AEAD.
* Ciphertext is versioned and URL-safe; plaintext is never part of display output.
*/
final class CredentialVault
{
private const VERSION = 'v1';
private const ASSOCIATED_DATA = 'jobcard-system:credential:v1';
private string $key;
private TechnicalInformation $information;
public function __construct(?string $appKey = null)
{
$material = $appKey ?? getenv('APP_KEY');
if ($material === false || $material === null || trim($material) === '') {
throw new RuntimeException('APP_KEY is required for credential encryption.');
}
$material = trim($material);
if (in_array($material, ['generate-a-long-random-secret', 'replace-with-a-long-random-secret'], true)) {
throw new RuntimeException('APP_KEY contains an invalid placeholder value.');
}
$this->key = $this->keyFromMaterial($material);
$this->information = new TechnicalInformation();
}
public function encrypt(string $plaintext): string
{
if ($plaintext === '') throw new InvalidArgumentException('Credential secret must not be empty.');
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, self::ASSOCIATED_DATA, $nonce, $this->key);
return self::VERSION . '.' . $this->base64UrlEncode($nonce . $ciphertext);
}
public function decrypt(string $encoded): string
{
try {
if (!str_starts_with($encoded, self::VERSION . '.')) throw new RuntimeException();
$binary = $this->base64UrlDecode(substr($encoded, strlen(self::VERSION) + 1));
$nonceLength = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
if (strlen($binary) <= $nonceLength + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new RuntimeException();
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(substr($binary, $nonceLength), self::ASSOCIATED_DATA, substr($binary, 0, $nonceLength), $this->key);
if ($plaintext === false) throw new RuntimeException();
return $plaintext;
} catch (\Throwable) {
throw new RuntimeException('Unable to decrypt credential.');
}
}
public function mask(string $secret): string
{
return '••••••••••••••••••••';
}
/** @return array<string,mixed> */
public function display(array $credential): array
{
$safe = $this->projectMetadata($credential);
if (array_key_exists('secret', $credential) || array_key_exists('secret_ciphertext', $credential)) $safe['secret'] = $this->mask('');
return $safe;
}
/** @return array<string,mixed> */
public function toDisplay(array $credential): array
{
return $this->display($credential);
}
/** @return array<string,mixed> */
public function encryptCredential(array $credential): array
{
$validation = $this->information->validate($credential);
if (!$validation['valid']) throw new InvalidArgumentException('Invalid credential metadata: ' . implode(' ', $validation['errors']));
$secret = $credential['secret'] ?? null;
if (!is_string($secret) || $secret === '') throw new InvalidArgumentException('Credential secret must be a non-empty string.');
$stored = $this->projectMetadata(array_key_exists('id', $credential) ? [...$validation, 'id' => $credential['id']] : $validation);
$stored['secret_ciphertext'] = $this->encrypt($secret);
return $stored;
}
/** @return array<string,mixed> */
public function decryptCredential(array $credential): array
{
if (!isset($credential['secret_ciphertext']) || !is_string($credential['secret_ciphertext'])) {
throw new InvalidArgumentException('Encrypted credential secret is missing.');
}
return [...$this->projectMetadata($credential), 'secret' => $this->decrypt($credential['secret_ciphertext'])];
}
/** @return array<string,mixed> */
public function projectMetadata(array $credential): array
{
$normalized = $this->information->normalize($credential);
$safe = $this->information->display($normalized);
if (array_key_exists('id', $credential)) $safe = ['id' => $credential['id'], ...$safe];
return $safe;
}
private function keyFromMaterial(string $material): string
{
if (str_starts_with($material, 'base64:')) {
$decoded = base64_decode(substr($material, 7), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new RuntimeException('APP_KEY base64 material must decode to exactly 32 bytes.');
}
return $decoded;
}
$unprefixedBase64 = base64_decode($material, true);
if ($unprefixedBase64 !== false && strlen($unprefixedBase64) === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES && preg_match('/^[A-Za-z0-9+\/=]+$/', $material) === 1) {
return $unprefixedBase64;
}
if (preg_match('/^[a-f0-9]{64}$/i', $material) === 1) return hex2bin($material);
if (strlen($material) < 32) throw new RuntimeException('APP_KEY material must be at least 32 bytes.');
return hash('sha256', $material, true);
}
private function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string
{
if ($value === '' || preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) throw new RuntimeException();
$decoded = base64_decode(strtr($value, '-_', '+/') . str_repeat('=', (4 - strlen($value) % 4) % 4), true);
if ($decoded === false) throw new RuntimeException();
return $decoded;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
/** Normalizes and validates non-secret technical-information metadata. */
final class TechnicalInformation
{
public const CATEGORY_HOSTING = 'hosting';
public const CATEGORY_VPN = 'vpn';
public const CATEGORY_EMAIL = 'email';
public const CATEGORY_DOMAIN = 'domain';
public const CATEGORY_DATABASE = 'database';
public const CATEGORY_SSH = 'ssh';
public const CATEGORY_API = 'api';
public const CATEGORY_OTHER = 'other';
/** @return list<string> */
public static function categories(): array
{
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER];
}
/** @return array{category:string, label:string, username:string|null, notes:string|null} */
public function normalize(array $information): array
{
return [
'category' => strtolower($this->text($information['category'] ?? null) ?? ''),
'label' => $this->text($information['label'] ?? null) ?? '',
'username' => $this->text($information['username'] ?? null),
'notes' => $this->text($information['notes'] ?? null),
];
}
/** @return array{valid:bool,errors:array<string,string>,category:string,label:string,username:string|null,notes:string|null} */
public function validate(array $information): array
{
$normalized = $this->normalize($information);
$errors = [];
if (!in_array($normalized['category'], self::categories(), true)) {
$errors['category'] = 'Credential category is invalid.';
}
if ($normalized['label'] === '') {
$errors['label'] = 'Credential label is required.';
} elseif ($normalized['label'] !== '' && mb_strlen($normalized['label']) > 120 || ($normalized['label'] !== '' && $this->hasControlCharacter($normalized['label']))) {
$errors['label'] = 'Credential label must be 120 characters or fewer and contain no control characters.';
}
if ($normalized['username'] !== null && (mb_strlen($normalized['username']) > 190 || $this->hasControlCharacter($normalized['username']))) {
$errors['username'] = 'Credential username must be 190 characters or fewer and contain no control characters.';
}
if ($normalized['notes'] !== null && mb_strlen($normalized['notes']) > 2000) {
$errors['notes'] = 'Credential notes must be 2000 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** @return array<string,mixed> */
public function display(array $information): array
{
$safe = [];
foreach (['id', 'category', 'label', 'username', 'notes'] as $field) {
if (array_key_exists($field, $information)) $safe[$field] = $information[$field];
}
return $safe;
}
/** @return array<string,mixed> */
public function toDisplay(array $information): array
{
return $this->display($information);
}
private function text(mixed $value): ?string
{
if ($value === null) return null;
$text = trim(is_scalar($value) ? (string) $value : '');
return $text === '' ? null : $text;
}
private function hasControlCharacter(string $value): bool
{
return preg_match('/[\x00-\x1F\x7F]/', $value) === 1;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\Notification;
use PDO;
use InvalidArgumentException;
/** Persists validated notifications as per-user rows with database-backed deduplication. */
final class NotificationQueue
{
public function __construct(private readonly ?NotificationRecord $records = null)
{
}
/** @return list<int> inserted notification ids */
public function enqueue(PDO $pdo, array $record): array
{
$validation = ($this->records ?? new NotificationRecord())->validate($record);
if (!$validation['valid']) throw new InvalidArgumentException('Invalid notification: ' . implode(' ', $validation['errors']));
$title = is_scalar($record['title'] ?? null) ? trim((string)$record['title']) : '';
$body = is_scalar($record['body'] ?? null) ? trim((string)$record['body']) : '';
if ($title === '' || mb_strlen($title) > 190) throw new InvalidArgumentException('Notification title is required and must be 190 characters or fewer.');
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
$ids = [];
foreach ($validation['recipients'] as $email) {
$lookup->execute(['email' => $email]);
$userId = $lookup->fetchColumn();
if ($userId === false) continue;
$insert->execute(['user' => $userId, 'type' => $validation['type'], 'title' => $title, 'body' => $body === '' ? null : $body, 'dedup' => $validation['deduplication_key'], 'read_at' => $validation['is_read'] ? date('Y-m-d H:i:s') : null]);
$ids[] = (int)$pdo->lastInsertId();
}
return $ids;
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Domain\Notification;
/** Normalizes and validates notification metadata; delivery is deliberately out of scope. */
final class NotificationRecord
{
/** @var list<string> */
private const TYPES = [
'jobcard_created', 'jobcard_status_changed', 'assignment_created',
'time_entry_created', 'sla_threshold', 'attachment_uploaded',
];
/** @return array<string, mixed> */
public function normalize(array $record): array
{
$type = is_scalar($record['type'] ?? null) ? strtolower(trim((string) $record['type'])) : '';
$rawRecipients = $record['recipients'] ?? ($record['recipient'] ?? []);
if (!is_array($rawRecipients)) $rawRecipients = [$rawRecipients];
$recipients = [];
foreach ($rawRecipients as $recipient) {
if (is_scalar($recipient)) {
$value = strtolower(trim((string) $recipient));
if ($value !== '' && !in_array($value, $recipients, true)) $recipients[] = $value;
}
}
$key = $record['deduplication_key'] ?? $record['dedup_key'] ?? null;
$key = is_scalar($key) ? strtolower(trim((string) $key)) : '';
return [
'type' => $type,
'recipients' => $recipients,
'is_read' => $this->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
'deduplication_key' => $key,
];
}
/** @return array<string, mixed> */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if (!in_array($normalized['type'], self::TYPES, true)) {
$errors['type'] = 'Notification type is not supported.';
}
if ($normalized['recipients'] === []) {
$errors['recipients'] = 'At least one notification recipient is required.';
} else {
foreach ($normalized['recipients'] as $recipient) {
if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
$errors['recipients'] = 'Notification recipients must be valid email addresses.';
break;
}
}
}
if (!is_bool($normalized['is_read'])) $errors['is_read'] = 'Read state must be boolean.';
if ($normalized['deduplication_key'] === '' || mb_strlen($normalized['deduplication_key']) > 190 || preg_match('/[\x00-\x1F\x7F]/', $normalized['deduplication_key']) === 1) {
$errors['deduplication_key'] = 'A safe deduplication key is required and must be 190 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
public function deduplicationKey(array $record): string
{
return $this->normalize($record)['deduplication_key'];
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$normalized = $this->normalize($record);
return [
'type' => $normalized['type'],
'recipients' => $normalized['recipients'],
'is_read' => $normalized['is_read'],
'deduplication_key' => $normalized['deduplication_key'],
];
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array { return $this->display($record); }
private function normalizeBoolean(mixed $value): mixed
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
return $value;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientHistoryReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class ClientJobcardReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = [];
foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/** Framework-free HTML renderer intended for browser print/save-to-PDF. */
final class PrintReportRenderer
{
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
$body .= '<tr>' . implode('', array_map(fn(mixed $value): string => '<td>' . $this->escape($value) . '</td>', $row)) . '</tr>';
}
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $this->escape($title) . '</title><style>body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
}
/** @param list<array<string,mixed>> $rows */
public function renderRecords(string $title, array $rows): string
{
$headers = $rows === [] ? [] : array_keys($rows[0]);
return $this->render($title, $headers, array_map(fn(array $row): array => array_values($row), $rows));
}
private function escape(mixed $value): string { return htmlspecialchars((string)($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
}
+26 -52
View File
@@ -1,64 +1,38 @@
<?php
declare(strict_types=1);
/**
* Maps raw domain rows into an explicitly allow-listed client view and a
* separately retained internal view. This class has no framework or storage
* dependency and is safe to use before an export format is selected.
*/
final class ReportDataMapper
{
/** @var list<string> */
private const CLIENT_FIELDS = [
'id',
'name',
'registration_number',
'status',
'support_email',
'support_phone',
'preferred_contact_method',
'physical_address',
'postal_address',
'general_notes',
'client_id',
'client_name',
'reference_no',
'priority',
'work_requested',
'completed_at',
'closed_at',
'allocated_hours',
'used_hours',
'remaining_hours',
'usage_percentage',
'status_label',
'period_type',
'start_date',
'end_date',
'hours',
'id','name','status','support_email','client_id','client_name','reference_no','priority','work_requested','created_at','completed_at','closed_at','changed_at','from_status','to_status','hours','sla_hours','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date',
];
private const INTERNAL_FIELDS = [
'id','client_id','client_name','reference_no','status','priority','work_requested','technician_id','technician_name','created_at','completed_at','closed_at','changed_at','from_status','to_status','changed_by','changed_by_name','work_date','hours','sla_hours','counts_toward_sla','sla_status','allocated_hours','used_hours','remaining_hours','usage_percentage','status_label','period_type','start_date','end_date','internal_notes','technician_notes',
];
/** @return array<string, mixed> */
public function clientFacing(array $record): array
{
$safe = [];
foreach (self::CLIENT_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string,mixed> */
public function clientFacing(array $record): array { return $this->allow($record, self::CLIENT_FIELDS); }
/** @return array<string,mixed> */
public function internal(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array{client:array<string,mixed>,internal:array<string,mixed>} */
public function map(array $record): array { return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)]; }
/** @return array<string,mixed> */
public function clientJobcard(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','reference_no','status','priority','work_requested','created_at','completed_at','closed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalJobcard(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientHistory(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','reference_no','from_status','to_status','changed_at'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientActivity(array $record): array { return $this->clientFacing($record); }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string, mixed> */
public function internal(array $record): array
private function allow(array $record, array $fields): array
{
return array_diff_key($record, $this->clientFacing($record));
}
/** @return array{client: array<string, mixed>, internal: array<string, mixed>} */
public function map(array $record): array
{
return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)];
$result = [];
foreach ($fields as $field) if (array_key_exists($field, $record)) $result[$field] = $record[$field];
return $result;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/** Value object for the common report filter vocabulary. */
final class ReportFilters
{
public function __construct(
public readonly ?int $clientId = null,
public readonly ?string $dateFrom = null,
public readonly ?string $dateTo = null,
public readonly ?int $technicianId = null,
public readonly ?string $status = null,
public readonly ?string $priority = null,
public readonly ?string $sla = null,
) {
$this->validate();
}
public static function fromArray(array $input): self
{
return new self(
self::positiveInt($input['client_id'] ?? $input['clientId'] ?? null),
self::date($input['date_from'] ?? $input['dateFrom'] ?? null),
self::date($input['date_to'] ?? $input['dateTo'] ?? null),
self::positiveInt($input['technician_id'] ?? $input['technicianId'] ?? null),
self::text($input['status'] ?? null),
self::text($input['priority'] ?? null),
self::text($input['sla'] ?? $input['sla_status'] ?? null),
);
}
public function matches(array $row): bool
{
if ($this->clientId !== null && (int)($row['client_id'] ?? 0) !== $this->clientId) return false;
if ($this->technicianId !== null && (int)($row['technician_id'] ?? 0) !== $this->technicianId) return false;
if ($this->status !== null && (string)($row['status'] ?? $row['to_status'] ?? '') !== $this->status) return false;
if ($this->priority !== null && (string)($row['priority'] ?? '') !== $this->priority) return false;
if ($this->sla !== null && (string)($row['sla_status'] ?? $row['sla'] ?? '') !== $this->sla) return false;
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? '');
if ($this->dateFrom !== null && ($date === '' || substr($date, 0, 10) < $this->dateFrom)) return false;
if ($this->dateTo !== null && ($date === '' || substr($date, 0, 10) > $this->dateTo)) return false;
return true;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return ['client_id' => $this->clientId, 'date_from' => $this->dateFrom, 'date_to' => $this->dateTo, 'technician_id' => $this->technicianId, 'status' => $this->status, 'priority' => $this->priority, 'sla' => $this->sla];
}
private function validate(): void
{
if ($this->dateFrom !== null && $this->dateTo !== null && $this->dateFrom > $this->dateTo) throw new InvalidArgumentException('date_from must not be after date_to.');
}
private static function positiveInt(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return (int)$value;
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
private static function date(mixed $value): ?string
{
if (!is_scalar($value) || trim((string)$value) === '') return null;
$value = trim((string)$value);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = DateTimeImmutable::getLastErrors();
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) || $date->format('Y-m-d') !== $value) throw new InvalidArgumentException('Report dates must use YYYY-MM-DD.');
return $value;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
/** Common contract for deterministic, storage-agnostic report queries. */
interface ReportQuery
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'client'): array;
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
final class TechnicianActivityReport implements ReportQuery
{
public function __construct(private readonly ?ReportFilters $filters = null, private readonly ?ReportDataMapper $mapper = null) {}
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $totals = [];
foreach ($rows as $row) {
if (!$filters->matches($row)) continue;
$key = (string)(int)($row['technician_id'] ?? 0) . ':' . (string)(int)($row['client_id'] ?? 0);
if (!isset($totals[$key])) $totals[$key] = ['technician_id' => (int)($row['technician_id'] ?? 0), 'technician_name' => (string)($row['technician_name'] ?? ''), 'client_id' => (int)($row['client_id'] ?? 0), 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
$hours = max(0.0, (float)($row['hours'] ?? 0)); $totals[$key]['hours'] += $hours;
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
}
$result = array_values($totals);
foreach ($result as &$item) { $item['hours'] = round($item['hours'], 2); $item['sla_hours'] = round($item['sla_hours'], 2); if ($audience === 'client') $item = $mapper->clientActivity($item); }
unset($item);
usort($result, static fn(array $a,array $b): int => strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)($a['technician_id'] ?? 0) <=> (int)($b['technician_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
/** Canonicalizes permission names and exposes only safe permission data. */
final class PermissionMatrix
{
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'name', 'description', 'permissions'];
/**
* Trim permission names, canonicalize them, discard malformed entries, and
* preserve first-seen order while removing duplicates.
*
* @param array<mixed> $permissions
* @return list<string>
*/
public function normalize(array $permissions): array
{
$normalized = [];
$seen = [];
foreach ($permissions as $permission) {
if (!is_scalar($permission)) {
continue;
}
$permission = strtolower(trim((string) $permission));
if ($permission === '' || preg_match('/[\x00-\x20\x7F]/', $permission) === 1) {
continue;
}
if (!isset($seen[$permission])) {
$seen[$permission] = true;
$normalized[] = $permission;
}
}
return $normalized;
}
/** @param array<string, mixed> $record @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $field === 'permissions' && is_array($record[$field])
? $this->normalize($record[$field])
: $record[$field];
}
}
return $safe;
}
/** @param array<string, mixed> $record @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
use LogicException;
/** Framework-free normalization, validation, and safety rules for roles. */
final class RoleRecord
{
private const ADMINISTRATOR = 'administrator';
/** @var list<string> */
private const DISPLAY_FIELDS = ['id', 'name', 'description', 'created_at', 'permissions'];
/** @return array{name: string, description: string|null} */
public function normalize(array $record): array
{
$name = $record['name'] ?? null;
$description = $record['description'] ?? null;
return [
'name' => is_scalar($name) ? trim((string) $name) : '',
'description' => $this->normalizeDescription($description),
];
}
/** @return array{name: string, description: string|null, valid: bool, errors: array<string, string>} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if ($normalized['name'] === '') {
$errors['name'] = 'Role name is required.';
} elseif (mb_strlen($normalized['name']) > 80) {
$errors['name'] = 'Role name must be 80 characters or fewer.';
} elseif (preg_match('/[\x00-\x1F\x7F]/', $normalized['name']) === 1) {
$errors['name'] = 'Role name contains invalid control characters.';
}
if ($normalized['description'] !== null && mb_strlen($normalized['description']) > 255) {
$errors['description'] = 'Role description must be 255 characters or fewer.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
public function isAdministrator(array $record): bool
{
return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR;
}
public function canRename(array $record, mixed $newName): bool
{
return !$this->isAdministrator($record) && $this->canonicalName($newName) !== self::ADMINISTRATOR;
}
public function canDelete(array $record): bool
{
return !$this->isAdministrator($record);
}
public function canChangePermissions(array $record): bool
{
return !$this->isAdministrator($record);
}
public function assertCanRename(array $record, mixed $newName): void
{
if (!$this->canRename($record, $newName)) {
throw new LogicException('The protected Administrator role cannot be renamed.');
}
}
public function assertCanDelete(array $record): void
{
if (!$this->canDelete($record)) {
throw new LogicException('The protected Administrator role cannot be deleted.');
}
}
public function assertCanChangePermissions(array $record): void
{
if (!$this->canChangePermissions($record)) {
throw new LogicException('The protected Administrator role permissions cannot be changed.');
}
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $field === 'permissions' && is_array($record[$field])
? (new PermissionMatrix())->normalize($record[$field])
: $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
private function normalizeDescription(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function canonicalName(mixed $value): string
{
return is_scalar($value) ? strtolower(trim((string) $value)) : '';
}
}