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