feat: add administration reporting and correction services

This commit is contained in:
Marco0300
2026-09-01 21:03:38 +02:00
parent de2bf277c4
commit b983f90dcb
24 changed files with 1392 additions and 31 deletions
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Domain\Client;
require_once __DIR__ . '/ContactUpdateCommand.php';
/**
* Storage-agnostic command for safe contact edits, primary promotion and deletion.
* Persistence adapters can apply the returned replacement IDs transactionally.
*/
final class ContactEditCommand
{
public function __construct(private readonly ?ContactUpdateCommand $contacts = null)
{
}
/** @return array<string,mixed> */
public function validateEdit(int $id, array $input, array $existingContacts = []): array
{
$result = ($this->contacts ?? new ContactUpdateCommand())->validateForEdit($id, $input, $existingContacts);
if ($id < 1) {
$result['errors']['id'] = 'Contact ID must be a positive integer.';
$result['valid'] = false;
}
return $result;
}
/** Alias matching the create/update command vocabulary. */
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
{
return $this->validateEdit($id, $input, $existingContacts);
}
/** @return array<string,mixed> */
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
{
return $currentId === null
? ($this->contacts ?? new ContactUpdateCommand())->validateForCreate($input, $existingContacts)
: $this->validateEdit($currentId, $input, $existingContacts);
}
/**
* Validate logical deletion. A primary is promoted to the lowest remaining
* contact for the same client; deleting the sole contact is not allowed.
* @return array<string,mixed>
*/
public function validateDelete(int $id, array $existingContacts = []): array
{
$errors = [];
$target = null;
foreach ($existingContacts as $contact) {
if (is_array($contact) && $this->id($contact['id'] ?? null) === $id) {
$target = $contact;
break;
}
}
if ($id < 1) $errors['id'] = 'Contact ID must be a positive integer.';
if ($target === null) {
$errors['id'] = 'Contact was not found.';
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
}
$clientId = $this->id($target['client_id'] ?? null);
if ($clientId === null) {
$errors['client_id'] = 'Contact client ID must be a positive integer.';
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
}
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $id));
$replacement = null;
if (count($sameClient) === 0) {
$errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
} elseif ($this->boolean($target['is_primary'] ?? false)) {
usort($sameClient, fn (array $a, array $b): int => ($this->id($a['id'] ?? null) ?? PHP_INT_MAX) <=> ($this->id($b['id'] ?? null) ?? PHP_INT_MAX));
$replacement = isset($sameClient[0]) ? $this->id($sameClient[0]['id'] ?? null) : null;
if ($replacement === null) $errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
}
return ['valid' => $errors === [], 'id' => $id, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors];
}
public function validateForDelete(int $id, array $existingContacts = []): array
{
return $this->validateDelete($id, $existingContacts);
}
/** @return array<string,mixed> */
public function delete(int $id, array $existingContacts = []): array
{
return $this->validateDelete($id, $existingContacts);
}
/** @return array<string,mixed> */
public function validatePrimary(int $id, array $existingContacts = []): array
{
foreach ($existingContacts as $row) {
if (is_array($row) && $this->id($row['id'] ?? null) === $id) {
$clientId = $this->id($row['client_id'] ?? null);
$demote = [];
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $id && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
return ['valid' => true, 'id' => $id, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => []];
}
}
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => 'Contact was not found.']];
}
/** @return array<string,mixed> */
public function setPrimary(int $id, array $existingContacts = []): array
{
return $this->validatePrimary($id, $existingContacts);
}
private function id(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) return filter_var(trim($value), FILTER_VALIDATE_INT) ?: null;
return null;
}
private function boolean(mixed $value): bool { return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes','on'], true)); }
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Domain\Credential;
require_once __DIR__ . '/TechnicalInformation.php';
use InvalidArgumentException;
use PDO;
use PDOException;
/**
* PDO persistence for one non-secret technical-information record per client/category.
*
* The repository deliberately stores only the validated metadata fields in JSON and
* returns an allow-listed display projection alongside audit identifiers.
*/
final class TechnicalInformationRepository
{
private TechnicalInformation $information;
public function __construct(private PDO $pdo, ?TechnicalInformation $information = null)
{
$this->information = $information ?? new TechnicalInformation();
}
/**
* Validate, normalize and atomically upsert a record using the schema's
* technical_client_category unique key.
*
* @return array{id:int,client_id:int,category:string,data:array{label:string,username:string|null,notes:string|null},updated_by:int|null,created_at:string|null,updated_at:string|null,display:array<string,mixed>,audit:array<string,mixed>}
*/
public function upsert(int $clientId, string $category, array $data, ?int $updatedBy = null): array
{
$this->assertIds($clientId, $updatedBy);
$validation = $this->information->validate([...$data, 'category' => $category]);
if (!$validation['valid']) {
throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
}
$normalizedCategory = $validation['category'];
$jsonData = [
'label' => $validation['label'],
'username' => $validation['username'],
'notes' => $validation['notes'],
];
$json = json_encode($jsonData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$statement = $this->pdo->prepare(
'INSERT INTO technical_information (client_id, category, data_json, updated_by) '
. 'VALUES (:client_id, :category, :data_json, :updated_by) '
. 'ON DUPLICATE KEY UPDATE data_json = VALUES(data_json), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
$statement->execute([
'client_id' => $clientId,
'category' => $normalizedCategory,
'data_json' => $json,
'updated_by' => $updatedBy,
]);
$record = $this->find($clientId, $normalizedCategory);
if ($record === null) {
throw new PDOException('Technical information upsert did not produce a readable record.');
}
return $this->withAudit($record, 'technical_information.upserted');
}
/** Convenience form for callers holding category inside the information payload. */
public function upsertInformation(int $clientId, array $information, ?int $updatedBy = null): array
{
$category = $information['category'] ?? null;
if (!is_string($category)) {
throw new InvalidArgumentException('Technical information category is required.');
}
unset($information['category']);
return $this->upsert($clientId, $category, $information, $updatedBy);
}
/** @return array<string,mixed>|null */
public function find(int $clientId, string $category): ?array
{
$this->assertIds($clientId, null);
$normalizedCategory = strtolower(trim($category));
if (!in_array($normalizedCategory, TechnicalInformation::categories(), true)) {
throw new InvalidArgumentException('Credential category is invalid.');
}
$statement = $this->pdo->prepare(
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
. 'FROM technical_information WHERE client_id = :client_id AND category = :category LIMIT 1'
);
$statement->execute(['client_id' => $clientId, 'category' => $normalizedCategory]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->hydrate($row) : null;
}
/** @return list<array<string,mixed>> */
public function forClient(int $clientId): array
{
$this->assertIds($clientId, null);
$statement = $this->pdo->prepare(
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
. 'FROM technical_information WHERE client_id = :client_id ORDER BY category ASC, id ASC'
);
$statement->execute(['client_id' => $clientId]);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn(array $row): array => $this->hydrate($row), $rows);
}
/** @param array<string,mixed> $record @return array<string,mixed> */
public function display(array $record): array
{
$data = is_array($record['data'] ?? null) ? $record['data'] : $this->decodeData($record['data_json'] ?? null);
$display = [];
foreach (['id', 'client_id', 'category'] as $field) {
if (array_key_exists($field, $record)) $display[$field] = $record[$field];
}
foreach (['label', 'username', 'notes'] as $field) {
if (array_key_exists($field, $data)) $display[$field] = $data[$field];
}
return $display;
}
/** @param array<string,mixed> $record @return array<string,mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
/** @param array<string,mixed> $row @return array<string,mixed> */
private function hydrate(array $row): array
{
$data = $this->decodeData($row['data_json'] ?? null);
$record = [
'id' => (int) $row['id'],
'client_id' => (int) $row['client_id'],
'category' => (string) $row['category'],
'data' => $data,
'updated_by' => $row['updated_by'] === null ? null : (int) $row['updated_by'],
'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : null,
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
$record['display'] = $this->display($record);
return $record;
}
/** @param array<string,mixed> $record @return array<string,mixed> */
private function withAudit(array $record, string $event): array
{
$record['audit'] = [
'event' => $event,
'entity_type' => 'technical_information',
'entity_id' => $record['id'],
'client_id' => $record['client_id'],
'category' => $record['category'],
'updated_by' => $record['updated_by'],
];
return $record;
}
/** @return array{label:string,username:string|null,notes:string|null} */
private function decodeData(mixed $json): array
{
if (!is_string($json) || $json === '') throw new PDOException('Technical information JSON is missing.');
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) throw new PDOException('Technical information JSON must be an object.');
return [
'label' => is_string($decoded['label'] ?? null) ? $decoded['label'] : '',
'username' => isset($decoded['username']) && is_string($decoded['username']) ? $decoded['username'] : null,
'notes' => isset($decoded['notes']) && is_string($decoded['notes']) ? $decoded['notes'] : null,
];
}
private function assertIds(int $clientId, ?int $updatedBy): void
{
if ($clientId < 1) throw new InvalidArgumentException('Client id must be a positive integer.');
if ($updatedBy !== null && $updatedBy < 1) throw new InvalidArgumentException('Updated-by id must be a positive integer.');
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeEntryCommand.php';
/**
* Validates immutable time-entry corrections and logical voids.
* No database deletion is represented: adapters should persist the returned
* action in a transaction and retain the original entry for auditability.
*/
final class TimeEntryCorrectionCommand
{
public function __construct(private readonly ?TimeEntryCommand $entries = null)
{
}
/** @return array<string,mixed> */
public function validateCorrection(array $existing, array $changes): array
{
$id = $this->positiveId($existing['id'] ?? null);
$errors = $id === null ? ['id' => 'Time-entry ID must be a positive integer.'] : [];
if ($this->isVoided($existing)) $errors['voided'] = 'A voided time entry cannot be corrected.';
foreach (['jobcard_id', 'technician_id'] as $field) {
if (array_key_exists($field, $changes) && $this->positiveId($changes[$field]) !== $this->positiveId($existing[$field] ?? null)) $errors[$field] = "{$field} cannot be changed during correction.";
}
$allowed = ['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'];
$payload = $existing;
foreach ($allowed as $field) if (array_key_exists($field, $changes)) $payload[$field] = $changes[$field];
$validated = ($this->entries ?? new TimeEntryCommand())->validate($payload);
$errors = [...$validated['errors'], ...$errors];
$entry = [...$payload, ...$validated];
unset($entry['valid'], $entry['errors']);
$entry['id'] = $id;
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors];
}
/** @return array<string,mixed> */
public function validate(array $existing, array $changes = []): array
{
return $this->validateCorrection($existing, $changes);
}
/** @return array<string,mixed> */
public function validateForCorrection(array $existing, array $changes = []): array
{
return $this->validateCorrection($existing, $changes);
}
/** @return array<string,mixed> */
public function validateVoid(array $existing, array $input = []): array
{
$id = $this->positiveId($existing['id'] ?? null);
$errors = $id === null ? ['id' => 'Time-entry ID must be a positive integer.'] : [];
if ($this->isVoided($existing)) $errors['voided'] = 'Time entry is already voided.';
$reason = is_scalar($input['reason'] ?? null) ? trim((string)$input['reason']) : '';
if ($reason === '') $errors['reason'] = 'A void reason is required.';
elseif (mb_strlen($reason) > 1000) $errors['reason'] = 'Void reason must be 1000 characters or fewer.';
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors];
}
public function void(array $existing, array $input = []): array
{
return $this->validateVoid($existing, $input);
}
/** @return array<string,mixed> */
public function validateForVoid(array $existing, array $input = []): array
{
return $this->validateVoid($existing, $input);
}
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) return filter_var(trim($value), FILTER_VALIDATE_INT) ?: null;
return null;
}
private function isVoided(array $entry): bool
{
$value = $entry['voided'] ?? false;
return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes'], true));
}
}
+32 -7
View File
@@ -19,18 +19,43 @@ final class NotificationQueue
$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();
try {
$pdo->beginTransaction();
foreach ($validation['recipients'] as $email) {
$dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email);
$lookup->execute(['email' => $email]);
$userId = $lookup->fetchColumn();
if ($userId === false) continue;
$insert->execute(['user' => $userId, 'type' => $dto['type'], 'title' => $dto['title'], 'body' => $dto['body'], 'dedup' => $dto['deduplication_key'], 'read_at' => $dto['is_read'] ? date('Y-m-d H:i:s') : null]);
$id = (int)$pdo->lastInsertId();
if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id;
}
$pdo->commit();
} catch (\Throwable $exception) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $exception;
}
return $ids;
}
/** Map a multi-recipient notification into one queue DTO for one user. */
public function mapForUser(array $record, string $recipient): array
{
return ($this->records ?? new NotificationRecord())->toQueueDto($record, $recipient);
}
/** Mark a notification read only for the authenticated user's row. */
public function markRead(PDO $pdo, int|string $userId, array $command): bool
{
$user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$validation = ($this->records ?? new NotificationRecord())->validateMarkRead($command);
if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-read command.');
$stmt = $pdo->prepare('UPDATE notifications SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = :id AND user_id = :user');
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
return $stmt->rowCount() > 0;
}
}
+98 -1
View File
@@ -30,11 +30,75 @@ final class NotificationRecord
return [
'type' => $type,
'recipients' => $recipients,
'is_read' => $this->normalizeBoolean($record['is_read'] ?? $record['read'] ?? false),
'is_read' => $this->normalizeReadState($record),
'deduplication_key' => $key,
];
}
/** Validate the command used by a user to mark one notification read. */
public function validateMarkRead(array $command): array
{
$value = $command['notification_id'] ?? $command['id'] ?? null;
$notificationId = $this->positiveInteger($value);
$errors = $notificationId === null
? ['notification_id' => 'Notification ID must be a positive integer.']
: [];
return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors];
}
/** Return one normalized queue DTO for a single recipient. */
public function toQueueDto(array $record, string $recipient): array
{
$normalized = $this->normalize($record);
$recipient = strtolower(trim($recipient));
if (!in_array($recipient, $normalized['recipients'], true)) {
throw new \InvalidArgumentException('Recipient is not present in the notification.');
}
return [
'type' => $normalized['type'],
'recipient' => $recipient,
'title' => is_scalar($record['title'] ?? null) ? trim((string) $record['title']) : '',
'body' => is_scalar($record['body'] ?? null) && trim((string) $record['body']) !== '' ? trim((string) $record['body']) : null,
'is_read' => $normalized['is_read'],
'deduplication_key' => $normalized['deduplication_key'],
];
}
/** @return list<array<string, mixed>> one DTO per normalized recipient */
public function toQueueDtos(array $record): array
{
$normalized = $this->normalize($record);
return array_map(fn (string $recipient): array => $this->toQueueDto($record, $recipient), $normalized['recipients']);
}
/** Build the assignment notification payload before queueing it. */
public function assignmentCreated(array $event): array
{
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
if ($jobcardId === null) throw new \InvalidArgumentException('Jobcard ID must be a positive integer.');
$reference = $this->text($event['jobcard_reference'] ?? $jobcardId);
$technician = $this->text($event['technician_name'] ?? null);
return $this->eventPayload($event, 'assignment_created', 'Jobcard assigned', sprintf('Jobcard %s was assigned%s.', $reference, $technician ? ' to ' . $technician : ''), 'assignment:' . $jobcardId);
}
/** Build the jobcard status notification payload before queueing it. */
public function statusChanged(array $event): array
{
$jobcardId = $this->positiveInteger($event['jobcard_id'] ?? null);
$to = strtolower($this->text($event['to_status'] ?? null));
if ($jobcardId === null || $to === '') throw new \InvalidArgumentException('Jobcard ID and destination status are required.');
return $this->eventPayload($event, 'jobcard_status_changed', 'Jobcard status changed', sprintf('Jobcard %s status changed to %s.', $event['jobcard_reference'] ?? $jobcardId, $to), 'jobcard:' . $jobcardId . ':status:' . $to);
}
/** Build an SLA threshold notification payload before queueing it. */
public function slaThreshold(array $event): array
{
$clientId = $this->positiveInteger($event['client_id'] ?? null);
$threshold = strtolower($this->text($event['threshold'] ?? $event['level'] ?? null));
if ($clientId === null || $threshold === '') throw new \InvalidArgumentException('Client ID and SLA threshold are required.');
return $this->eventPayload($event, 'sla_threshold', 'SLA threshold reached', sprintf('Client %s has reached the %s SLA threshold.', $event['client_name'] ?? $clientId, $threshold), 'sla:' . $clientId . ':' . $threshold);
}
/** @return array<string, mixed> */
public function validate(array $record): array
{
@@ -91,4 +155,37 @@ final class NotificationRecord
};
return $value;
}
private function normalizeReadState(array $record): mixed
{
if (array_key_exists('read_at', $record)) {
if ($record['read_at'] === null) return false;
return is_scalar($record['read_at']) ? trim((string) $record['read_at']) !== '' : $record['read_at'];
}
$value = $record['is_read'] ?? $record['read'] ?? false;
if (is_string($value) && strtolower(trim($value)) === 'read') return true;
if (is_string($value) && strtolower(trim($value)) === 'unread') return false;
return $this->normalizeBoolean($value);
}
private function positiveInteger(mixed $value): ?int
{
if (is_int($value) && $value > 0) return $value;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$trimmed = trim($value);
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
}
return null;
}
private function text(mixed $value): string
{
return is_scalar($value) ? trim((string) $value) : '';
}
private function eventPayload(array $event, string $type, string $title, string $body, string $deduplicationKey): array
{
return [...$this->normalize([...$event, 'type' => $type, 'deduplication_key' => $deduplicationKey]), 'title' => $title, 'body' => $body];
}
}
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientHistoryReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientHistoryReport implements ReportQuery
/** @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)));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, 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)) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? '')));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalHistory($row) : $mapper->clientHistory($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reporting {
require_once __DIR__ . '/ReportFilters.php';
/** Deterministic, storage-agnostic filtering for a client's status history. */
final class ClientHistoryService
{
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function filter(array $rows, array|\ReportFilters|null $criteria = null): array
{
$filters = $criteria instanceof \ReportFilters ? $criteria : \ReportFilters::fromArray($criteria ?? []);
$result = [];
foreach ($rows as $row) {
if (!is_array($row) || !$filters->matches($row)) continue;
$result[] = $row;
}
usort($result, static fn (array $a, array $b): int => strcmp((string)($a['changed_at'] ?? $a['created_at'] ?? ''), (string)($b['changed_at'] ?? $b['created_at'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
return $result;
}
/** @return list<array<string,mixed>> */
public function forClient(array $rows, int|string $clientId, array $criteria = []): array
{
if (!is_int($clientId) && (!is_string($clientId) || preg_match('/^[1-9]\d*$/', trim($clientId)) !== 1)) throw new \InvalidArgumentException('Client ID must be a positive integer.');
$criteria['client_id'] = (int)$clientId;
return $this->filter($rows, $criteria);
}
/** @return list<array<string,mixed>> */
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
/** @return list<array<string,mixed>> */
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
}
}
namespace {
if (!class_exists('ClientHistoryService', false)) class_alias('App\\Domain\\Reporting\\ClientHistoryService', 'ClientHistoryService');
}
+7 -3
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class ClientJobcardReport implements ReportQuery
{
@@ -10,9 +11,12 @@ final class ClientJobcardReport implements ReportQuery
/** @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'] ?? '')));
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $selected = [];
foreach ($rows as $row) if ($filters->matches($row)) $selected[] = $row;
usort($selected, 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'] ?? '')) ?: ((int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0)));
$result = [];
foreach ($selected as $row) $result[] = $audience === ReportAudience::INTERNAL ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row);
return $result;
}
public function query(array $rows, string $audience = 'client'): array { return $this->build($rows, $audience); }
+15
View File
@@ -44,6 +44,21 @@ final class CsvExporter
return $withBom ? self::UTF8_BOM . $csv : $csv;
}
/** Serialize an allow-listed report projection without exposing associative keys. */
public function exportRecords(array $records, bool $withBom = false): string
{
if ($records === []) return $withBom ? self::UTF8_BOM : '';
$headers = array_keys($records[0]);
$rows = [];
foreach ($records as $record) {
if (!is_array($record) || array_keys($record) !== $headers) {
throw new InvalidArgumentException('CSV report records must share the same ordered fields.');
}
$rows[] = array_values($record);
}
return $this->export($headers, $rows, $withBom);
}
/**
* @param list<mixed> $fields
*/
@@ -7,6 +7,12 @@ final class PrintReportRenderer
/** @param list<string> $headers @param list<list<mixed>> $rows */
public function render(string $title, array $headers, array $rows): string
{
$headerCount = count($headers);
foreach ($rows as $number => $row) {
if (!is_array($row) || count($row) !== $headerCount) {
throw new InvalidArgumentException(sprintf('Print report row %d must contain %d fields.', $number + 1, $headerCount));
}
}
$head = implode('', array_map(fn(mixed $value): string => '<th>' . $this->escape($value) . '</th>', $headers));
$body = '';
foreach ($rows as $row) {
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
final class ReportAudience
{
public const CLIENT = 'client';
public const INTERNAL = 'internal';
public static function validate(string $audience): string
{
if (!in_array($audience, [self::CLIENT, self::INTERNAL], true)) {
throw new InvalidArgumentException('Report audience must be client or internal.');
}
return $audience;
}
}
+5 -1
View File
@@ -25,9 +25,13 @@ final class ReportDataMapper
/** @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); }
public function clientActivity(array $record): array { $safe = $this->clientFacing($record); $ordered = []; foreach (['client_id','client_name','hours','sla_hours'] as $field) if (array_key_exists($field, $safe)) $ordered[$field] = $safe[$field]; return $ordered; }
/** @return array<string,mixed> */
public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); }
/** @return array<string,mixed> */
public function clientSla(array $record): array { return $this->allow($record, ['client_id','client_name','allocated_hours','used_hours','remaining_hours','usage_percentage','status']); }
/** @return array<string,mixed> */
public function internalSla(array $record): array { return $this->clientSla($record); }
private function allow(array $record, array $fields): array
{
+6 -2
View File
@@ -36,7 +36,7 @@ final class ReportFilters
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'] ?? '');
$date = (string)($row['work_date'] ?? $row['created_at'] ?? $row['changed_at'] ?? $row['start_date'] ?? '');
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;
@@ -55,7 +55,11 @@ final class ReportFilters
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;
if (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1) {
$trimmed = trim($value);
$integer = filter_var($trimmed, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $integer === false || (string)$integer !== $trimmed ? null : $integer;
}
return null;
}
private static function text(mixed $value): ?string { return is_scalar($value) && trim((string)$value) !== '' ? trim((string)$value) : null; }
+30 -12
View File
@@ -1,29 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportAudience.php';
final class SlaReport
{
public function __construct(
private readonly ?ReportFilters $filters = null,
private readonly ?ReportDataMapper $mapper = null,
) {}
/** @param list<array<string, mixed>> $agreements
* @return list<array<string, mixed>>
*/
public function rows(array $agreements): array
public function rows(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
ReportAudience::validate($audience);
$filters = $this->filters ?? new ReportFilters();
$mapper = $this->mapper ?? new ReportDataMapper();
$rows = [];
foreach ($agreements as $agreement) {
if (!$filters->matches($agreement)) continue;
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
$used = 0.0;
foreach ((array)($agreement['hours'] ?? []) as $hours) {
$used += max(0.0, (float)$hours);
}
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
$used = round($used, 2);
$remaining = round(max(0.0, $allocated - $used), 2);
$percentage = $allocated > 0
? round(($used / $allocated) * 100, 2)
: ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated
? 'exceeded'
: ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$rows[] = [
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
$record = [
'client_id' => (int)($agreement['client_id'] ?? 0),
'client_name' => (string)($agreement['client_name'] ?? ''),
'allocated_hours' => $allocated,
@@ -32,8 +39,19 @@ final class SlaReport
'usage_percentage' => $percentage,
'status' => $status,
];
$rows[] = $audience === ReportAudience::CLIENT ? $mapper->clientSla($record) : $mapper->internalSla($record);
}
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
usort($rows, static fn(array $a, array $b): int => strcmp((string)$a['client_name'], (string)$b['client_name']) ?: ((int)$a['client_id'] <=> (int)$b['client_id']));
return $rows;
}
public function build(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
public function query(array $agreements, string $audience = ReportAudience::INTERNAL): array
{
return $this->rows($agreements, $audience);
}
}
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/ReportFilters.php';
require_once __DIR__ . '/ReportDataMapper.php';
require_once __DIR__ . '/ReportQuery.php';
require_once __DIR__ . '/ReportAudience.php';
final class TechnicianActivityReport implements ReportQuery
{
@@ -10,10 +11,13 @@ final class TechnicianActivityReport implements ReportQuery
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
public function build(array $rows, string $audience = 'internal'): array
{
ReportAudience::validate($audience);
$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);
$key = $audience === 'client'
? 'client:' . (string)(int)($row['client_id'] ?? 0)
: (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;
@@ -21,7 +25,7 @@ final class TechnicianActivityReport implements ReportQuery
$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)));
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)) ?: strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)($a['client_id'] ?? 0) <=> (int)($b['client_id'] ?? 0)));
return $result;
}
public function query(array $rows, string $audience = 'internal'): array { return $this->build($rows, $audience); }
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
require_once __DIR__ . '/PermissionMatrix.php';
require_once __DIR__ . '/RoleRecord.php';
/** PDO-independent contracts for custom-role permission assignment. */
final class RolePermissionService
{
public function __construct(
private readonly ?RoleRecord $roles = null,
private readonly ?PermissionMatrix $permissions = null,
) {
}
/** @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>} */
public function validate(array $role, array $selected, array $available = []): array
{
return $this->validateAssignment($role, $selected, $available);
}
/**
* Validate and normalize a role's selected permissions. When an available
* list is supplied, selections outside that list are rejected rather than
* silently discarded.
*
* @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>}
*/
public function validateAssignment(array $role, array $selected, array $available = []): array
{
$roleId = $this->positiveId($role['id'] ?? $role['role_id'] ?? null);
$normalized = ($this->permissions ?? new PermissionMatrix())->normalize($selected);
$errors = [];
if ($roleId === null) $errors['role_id'] = 'Role ID must be a positive integer.';
if (($this->roles ?? new RoleRecord())->isAdministrator($role)) {
$errors['role'] = 'The protected Administrator role permissions cannot be changed.';
}
if ($available !== []) {
$allowed = ($this->permissions ?? new PermissionMatrix())->normalize($available);
$unknown = array_values(array_diff($normalized, $allowed));
if ($unknown !== []) {
$errors['permissions'] = 'Unknown permissions cannot be assigned: ' . implode(', ', $unknown) . '.';
}
}
return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** Alias matching controller command terminology. */
public function validateForAssignment(array $role, array $selected, array $available = []): array
{
return $this->validateAssignment($role, $selected, $available);
}
/**
* Validate a role edit and its optional permission set in one safe result.
* Administrator cannot be renamed or have permissions changed.
*
* @return array<string, mixed>
*/
public function validateForEdit(int $id, array $input, array $available = []): array
{
$record = $this->roles ?? new RoleRecord();
$result = $record->validate($input);
$result['id'] = $id;
if ($id < 1) $result['errors']['id'] = 'Role ID must be a positive integer.';
$current = ['id' => $id, 'name' => $id === 1 ? 'Administrator' : ($input['current_name'] ?? ($input['name'] ?? null))];
if (array_key_exists('current_name', $input)) {
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
}
if (array_key_exists('permissions', $input)) {
$assignment = $this->validateAssignment(['id' => $id, 'name' => $current['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
$result['permissions'] = $assignment['permissions'];
$result['errors'] = [...$result['errors'], ...$assignment['errors']];
}
$result['valid'] = $result['errors'] === [];
return $result;
}
public function canAssignPermissions(array $role): bool
{
return !($this->roles ?? new RoleRecord())->isAdministrator($role);
}
public function assertCanAssignPermissions(array $role): void
{
($this->roles ?? new RoleRecord())->assertCanChangePermissions($role);
}
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;
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
require_once __DIR__ . '/PasswordPolicy.php';
require_once __DIR__ . '/UserRecord.php';
/**
* PDO-independent validation contracts for user administration actions.
* Persistence and authorization middleware remain the caller's responsibility.
*/
final class UserAdminService
{
public function __construct(
private readonly ?UserRecord $users = null,
private readonly ?PasswordPolicy $passwordPolicy = null,
) {
}
/** @return array<string, mixed> */
public function validate(array $input, array $existingUsers = [], ?int $currentId = null): array
{
$result = ($this->users ?? new UserRecord())->validate($input);
$email = $result['email'] ?? '';
if ($currentId !== null && is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $currentId)) {
$result['errors']['email'] = 'Email address is already in use.';
}
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array<string, mixed> */
public function validateForEdit(int $id, array $input, array $existingUsers = []): array
{
$errors = [];
if ($id < 1) {
$errors['id'] = 'User ID must be a positive integer.';
}
$result = ($this->users ?? new UserRecord())->validate($input);
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
if ($this->isProtectedAdministrator($existing) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== (int)($existing['role_id'] ?? 1)) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
$email = $result['email'] ?? '';
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
$result['errors']['email'] = 'Email address is already in use.';
}
$result['id'] = $id;
$result['errors'] = [...$errors, ...$result['errors']];
$result['valid'] = $result['errors'] === [];
return $result;
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function validateDeactivate(array $user): array
{
return $this->validateTransition($user, true, false, 'deactivated');
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function validateReactivate(array $user): array
{
return $this->validateTransition($user, false, true, 'reactivated');
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function deactivate(array $user): array
{
return $this->validateDeactivate($user);
}
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
public function reactivate(array $user): array
{
return $this->validateReactivate($user);
}
/** Validate reset input without ever returning the plaintext password. */
/** @return array{valid: bool, errors: array<string, string>} */
public function validatePasswordReset(array $user, mixed $password = null): array
{
$payload = $password === null && array_key_exists('password', $user);
if ($payload) $password = $user['password'];
$errors = [];
if (!$payload && $this->positiveId($user['id'] ?? null) === null) {
$errors['id'] = 'User ID must be a positive integer.';
}
$check = ($this->passwordPolicy ?? new PasswordPolicy())->validateReset($password);
if (!$check['valid']) {
$errors['password'] = 'Password requires: ' . implode(', ', $check['errors']) . '.';
}
return ['valid' => $errors === [], 'errors' => $errors];
}
/** Aliases useful to controllers accepting a reset command payload. */
/** @return array{valid: bool, errors: array<string, string>} */
public function validateReset(array $user, mixed $password = null): array
{
return $this->validatePasswordReset($user, $password);
}
/** @return array{valid: bool, errors: array<string, string>} */
public function validateResetPassword(array $user, mixed $password = null): array
{
return $this->validatePasswordReset($user, $password);
}
/** @return array<string, mixed> */
public function display(array $user): array
{
return ($this->users ?? new UserRecord())->display($user);
}
/** @return array<string, mixed> */
public function toDisplay(array $user): array
{
return $this->display($user);
}
public function isProtectedAdministrator(array $user): bool
{
if (isset($user['role_id']) && (int)$user['role_id'] === 1) return true;
$role = $user['role_name'] ?? $user['role'] ?? null;
return is_scalar($role) && strtolower(trim((string) $role)) === 'administrator';
}
private function validateTransition(array $user, bool $from, bool $to, string $action): array
{
$id = $this->positiveId($user['id'] ?? null);
$active = $this->asBool($user['is_active'] ?? $user['active'] ?? null);
$errors = [];
if ($id === null) {
$errors['id'] = 'User ID must be a positive integer.';
}
if ($this->isProtectedAdministrator($user)) {
$errors['role'] = 'The protected Administrator account cannot be deactivated.';
} elseif ($active !== $from) {
$errors['is_active'] = "Only {$this->stateName($from)} users can be {$action}.";
}
return ['valid' => $errors === [], 'id' => $id ?? 0, 'is_active' => $to, 'errors' => $errors];
}
private function hasDuplicateEmail(string $email, array $rows, int $currentId): bool
{
foreach ($rows as $row) {
if (!is_array($row) || $this->positiveId($row['id'] ?? null) === $currentId) continue;
$other = $row['email'] ?? null;
if (is_scalar($other) && strtolower(trim((string) $other)) === $email) return true;
}
return false;
}
private function stateName(bool $active): string
{
return $active ? 'active' : 'inactive';
}
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
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) {
$value = strtolower(trim($value));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true;
if (in_array($value, ['0', 'false', 'no', 'off'], true)) return false;
}
return null;
}
}