diff --git a/app/Domain/Client/ContactEditCommand.php b/app/Domain/Client/ContactEditCommand.php new file mode 100644 index 0000000..95582ea --- /dev/null +++ b/app/Domain/Client/ContactEditCommand.php @@ -0,0 +1,118 @@ + */ + 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 */ + 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 + */ + 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 */ + public function delete(int $id, array $existingContacts = []): array + { + return $this->validateDelete($id, $existingContacts); + } + + /** @return array */ + 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 */ + 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)); } +} diff --git a/app/Domain/Credential/TechnicalInformationRepository.php b/app/Domain/Credential/TechnicalInformationRepository.php new file mode 100644 index 0000000..c391437 --- /dev/null +++ b/app/Domain/Credential/TechnicalInformationRepository.php @@ -0,0 +1,180 @@ +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,audit:array} + */ + 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|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> */ + 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 $record @return array */ + 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 $record @return array */ + public function toDisplay(array $record): array + { + return $this->display($record); + } + + /** @param array $row @return array */ + 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 $record @return array */ + 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.'); + } +} diff --git a/app/Domain/Jobcard/TimeEntryCorrectionCommand.php b/app/Domain/Jobcard/TimeEntryCorrectionCommand.php new file mode 100644 index 0000000..9a4eafc --- /dev/null +++ b/app/Domain/Jobcard/TimeEntryCorrectionCommand.php @@ -0,0 +1,85 @@ + */ + 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 */ + public function validate(array $existing, array $changes = []): array + { + return $this->validateCorrection($existing, $changes); + } + + /** @return array */ + public function validateForCorrection(array $existing, array $changes = []): array + { + return $this->validateCorrection($existing, $changes); + } + + /** @return array */ + 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 */ + 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)); + } +} diff --git a/app/Domain/Notification/NotificationQueue.php b/app/Domain/Notification/NotificationQueue.php index aa8314d..6f73a66 100644 --- a/app/Domain/Notification/NotificationQueue.php +++ b/app/Domain/Notification/NotificationQueue.php @@ -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; + } } diff --git a/app/Domain/Notification/NotificationRecord.php b/app/Domain/Notification/NotificationRecord.php index 919880d..295d7e6 100644 --- a/app/Domain/Notification/NotificationRecord.php +++ b/app/Domain/Notification/NotificationRecord.php @@ -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> 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 */ 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]; + } } diff --git a/app/Domain/Reporting/ClientHistoryReport.php b/app/Domain/Reporting/ClientHistoryReport.php index 7e1e151..b32f94e 100644 --- a/app/Domain/Reporting/ClientHistoryReport.php +++ b/app/Domain/Reporting/ClientHistoryReport.php @@ -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> $rows @return list> */ public function build(array $rows, string $audience = 'client'): array { - $filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = []; - foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalHistory($row) : $mapper->clientHistory($row); - usort($result, static fn(array $a,array $b): int => strcmp((string)($a['changed_at'] ?? ''), (string)($b['changed_at'] ?? '')) ?: ((int)($a['id'] ?? $a['jobcard_id'] ?? 0) <=> (int)($b['id'] ?? $b['jobcard_id'] ?? 0))); + 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); } diff --git a/app/Domain/Reporting/ClientHistoryService.php b/app/Domain/Reporting/ClientHistoryService.php new file mode 100644 index 0000000..287e454 --- /dev/null +++ b/app/Domain/Reporting/ClientHistoryService.php @@ -0,0 +1,43 @@ +> $rows @return list> */ + 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> */ + 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> */ + public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); } + /** @return list> */ + public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); } + /** @return list> */ + 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'); +} diff --git a/app/Domain/Reporting/ClientJobcardReport.php b/app/Domain/Reporting/ClientJobcardReport.php index 808dc3c..c23bb85 100644 --- a/app/Domain/Reporting/ClientJobcardReport.php +++ b/app/Domain/Reporting/ClientJobcardReport.php @@ -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> $rows @return list> */ public function build(array $rows, string $audience = 'client'): array { - $filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); $result = []; - foreach ($rows as $row) if ($filters->matches($row)) $result[] = $audience === 'internal' ? $mapper->internalJobcard($row) : $mapper->clientJobcard($row); - usort($result, static fn(array $a,array $b): int => strcmp((string)($a['created_at'] ?? ''), (string)($b['created_at'] ?? '')) ?: strcmp((string)($a['reference_no'] ?? ''), (string)($b['reference_no'] ?? ''))); + 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); } diff --git a/app/Domain/Reporting/CsvExporter.php b/app/Domain/Reporting/CsvExporter.php index bf8fec2..f7c6d1e 100644 --- a/app/Domain/Reporting/CsvExporter.php +++ b/app/Domain/Reporting/CsvExporter.php @@ -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 $fields */ diff --git a/app/Domain/Reporting/PrintReportRenderer.php b/app/Domain/Reporting/PrintReportRenderer.php index 892de01..59668c8 100644 --- a/app/Domain/Reporting/PrintReportRenderer.php +++ b/app/Domain/Reporting/PrintReportRenderer.php @@ -7,6 +7,12 @@ final class PrintReportRenderer /** @param list $headers @param list> $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 => '' . $this->escape($value) . '', $headers)); $body = ''; foreach ($rows as $row) { diff --git a/app/Domain/Reporting/ReportAudience.php b/app/Domain/Reporting/ReportAudience.php new file mode 100644 index 0000000..3818feb --- /dev/null +++ b/app/Domain/Reporting/ReportAudience.php @@ -0,0 +1,16 @@ + */ public function internalHistory(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } /** @return array */ - public function clientActivity(array $record): array { return $this->clientFacing($record); } + 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 */ public function internalActivity(array $record): array { return $this->allow($record, self::INTERNAL_FIELDS); } + /** @return array */ + 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 */ + public function internalSla(array $record): array { return $this->clientSla($record); } private function allow(array $record, array $fields): array { diff --git a/app/Domain/Reporting/ReportFilters.php b/app/Domain/Reporting/ReportFilters.php index fd12c97..c984190 100644 --- a/app/Domain/Reporting/ReportFilters.php +++ b/app/Domain/Reporting/ReportFilters.php @@ -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; } diff --git a/app/Domain/Reporting/SlaReport.php b/app/Domain/Reporting/SlaReport.php index 73cfa86..50e0175 100644 --- a/app/Domain/Reporting/SlaReport.php +++ b/app/Domain/Reporting/SlaReport.php @@ -1,29 +1,36 @@ > $agreements * @return list> */ - 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); + } } diff --git a/app/Domain/Reporting/TechnicianActivityReport.php b/app/Domain/Reporting/TechnicianActivityReport.php index 235e7b7..9d42380 100644 --- a/app/Domain/Reporting/TechnicianActivityReport.php +++ b/app/Domain/Reporting/TechnicianActivityReport.php @@ -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> $rows @return list> */ 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); } diff --git a/app/Domain/User/RolePermissionService.php b/app/Domain/User/RolePermissionService.php new file mode 100644 index 0000000..9211c1b --- /dev/null +++ b/app/Domain/User/RolePermissionService.php @@ -0,0 +1,102 @@ +, valid: bool, errors: array} */ + 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, valid: bool, errors: array} + */ + 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 + */ + 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; + } +} diff --git a/app/Domain/User/UserAdminService.php b/app/Domain/User/UserAdminService.php new file mode 100644 index 0000000..e6408ce --- /dev/null +++ b/app/Domain/User/UserAdminService.php @@ -0,0 +1,181 @@ + */ + 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 */ + 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} */ + public function validateDeactivate(array $user): array + { + return $this->validateTransition($user, true, false, 'deactivated'); + } + + /** @return array{valid: bool, id: int, is_active: bool, errors: array} */ + public function validateReactivate(array $user): array + { + return $this->validateTransition($user, false, true, 'reactivated'); + } + + /** @return array{valid: bool, id: int, is_active: bool, errors: array} */ + public function deactivate(array $user): array + { + return $this->validateDeactivate($user); + } + + /** @return array{valid: bool, id: int, is_active: bool, errors: array} */ + 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} */ + 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} */ + public function validateReset(array $user, mixed $password = null): array + { + return $this->validatePasswordReset($user, $password); + } + + /** @return array{valid: bool, errors: array} */ + public function validateResetPassword(array $user, mixed $password = null): array + { + return $this->validatePasswordReset($user, $password); + } + + /** @return array */ + public function display(array $user): array + { + return ($this->users ?? new UserRecord())->display($user); + } + + /** @return array */ + 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; + } +} diff --git a/docs/uat-checklist.md b/docs/uat-checklist.md index 52a72c1..6325f7d 100644 --- a/docs/uat-checklist.md +++ b/docs/uat-checklist.md @@ -67,6 +67,19 @@ Run this checklist against a production-like deployment over HTTPS with a fresh - [ ] Record restore duration, backup timestamp, row/data spot checks and any missing items. - [ ] Confirm the live system was not modified by restore testing and securely remove the temporary restored copy when approved. +## Explicit security acceptance cases + +Record the request URL/route, authenticated role, test fixture IDs, expected response, observed response, and evidence for each case. Use separate Technician accounts and at least two clients/jobcards so an ID change cannot accidentally target the same tenant. + +- [ ] **SEC-01 — Technician cross-client IDOR (read):** Technician A requests Technician B's jobcard URL and an attachment URL belonging to that jobcard. Both requests return the same not-found/denied behavior as an unknown ID, and no client name, jobcard details, attachment bytes or metadata are disclosed. +- [ ] **SEC-02 — Technician cross-client IDOR (write):** Technician A submits status, notes, assignment or time-entry payloads with Technician B's jobcard ID. CSRF-valid requests are still denied by authorization, and the target jobcard, assignments and time entries remain unchanged. +- [ ] **SEC-03 — Own-technician time isolation:** Create one assigned jobcard with time recorded by Technician A and Technician B. Technician A's report/UI/export contains only A's hours; it does not include B's hours or another client's totals. Repeat with a direct report URL and CSV export. +- [ ] **SEC-04 — Credential canonical storage:** Create a credential containing a unique canary secret. The database row has `secret_ciphertext` and no plaintext `secret` field/value; normal views show a mask; only an authorized reveal returns the secret once, with no-store headers and an audit event. A different client's credential ID cannot be revealed. +- [ ] **SEC-05 — Attachment safe metadata:** Attempt traversal names (`../x.pdf`), executable/double extensions (`invoice.php.jpg`), MIME mismatches, oversized files, and client-visible without explicit approval. Each is rejected before storage. A valid image/PDF is stored under a generated server name, served with its validated MIME and `X-Content-Type-Options: nosniff`, and remains inaccessible through a different jobcard/client ID. +- [ ] **SEC-06 — Healthcheck schema contract:** Run `php bin/healthcheck.php` with valid configuration and confirm every current schema table is probed, output contains statuses only, and no password, APP_KEY, database DSN, SQL exception, or secret value is printed. Remove/rename one required table in a disposable database and confirm a non-zero failure. +- [ ] **SEC-07 — CSRF route assumptions:** For login, logout, client create/edit/contact, jobcard create/update, time, assignment, attachment, credential and SLA POSTs, submit with a missing token and a wrong token. Every request is rejected before mutation (HTTP 419 or the documented equivalent); the valid-token control succeeds. GET requests do not mutate state. +- [ ] **SEC-08 — Migration coverage and restore:** Restore a pre-current-schema backup to an isolated database, run `database/upgrade.sql` once and a second time, and confirm feature tables/columns, permissions and the one-SLA-per-client constraint are present. Resolve/record duplicate SLA rows before the unique constraint step; rerun healthcheck and verify representative clients, jobcards, time entries, credentials and attachments. + ## Sign-off - Environment/version: ______________________________ diff --git a/tests/DomainCommandServiceTest.php b/tests/DomainCommandServiceTest.php new file mode 100644 index 0000000..79e26f5 --- /dev/null +++ b/tests/DomainCommandServiceTest.php @@ -0,0 +1,51 @@ + 1, 'client_id' => 7, 'name' => 'Jane', 'email' => 'jane@example.test', 'is_primary' => true], + ['id' => 2, 'client_id' => 7, 'name' => 'John', 'email' => 'john@example.test', 'is_primary' => false], +]; +$contactCommand = new ContactEditCommand(); +$edit = $contactCommand->validateEdit(2, ['client_id' => 7, 'name' => ' John Smith ', 'is_primary' => 'yes'], $contacts); +domain_command_assert_same(true, $edit['valid'], 'A contact edit should validate.'); +domain_command_assert_same([1], $edit['replace_primary_contact_ids'], 'Promoting an edited contact should demote the old primary.'); +$delete = $contactCommand->validateDelete(1, $contacts); +domain_command_assert_same(true, $delete['valid'], 'A primary contact may be deleted when a replacement exists.'); +domain_command_assert_same(2, $delete['replacement_primary_contact_id'], 'Deleting the primary should nominate a replacement.'); +$last = $contactCommand->validateDelete(2, [['id' => 2, 'client_id' => 7, 'is_primary' => true]]); +domain_command_assert_same(false, $last['valid'], 'Deleting the only contact must be rejected.'); + +$history = new ClientHistoryService(); +$rows = [ + ['id' => 2, 'client_id' => 7, 'changed_at' => '2026-09-03', 'to_status' => 'closed'], + ['id' => 1, 'client_id' => 8, 'changed_at' => '2026-09-02', 'to_status' => 'open'], + ['id' => 3, 'client_id' => 7, 'changed_at' => '2026-10-01', 'to_status' => 'open'], +]; +domain_command_assert_same([2], array_column($history->filter($rows, ['client_id' => 7, 'date_to' => '2026-09-30']), 'id'), 'History filtering should apply client and date bounds.'); + +$correction = new TimeEntryCorrectionCommand(); +$existing = ['id' => 9, 'jobcard_id' => 12, 'technician_id' => 4, 'work_date' => '2026-09-01', 'hours' => 2, 'notes' => 'old', 'counts_toward_sla' => true, 'voided' => false]; +$fixed = $correction->validateCorrection($existing, ['hours' => '3.25', 'notes' => ' corrected ']); +domain_command_assert_same(true, $fixed['valid'], 'A time correction should validate against the existing entry.'); +domain_command_assert_same(3.25, $fixed['entry']['hours'], 'A time correction should normalize replacement hours.'); +domain_command_assert_same(12, $fixed['entry']['jobcard_id'], 'A correction must retain the existing jobcard.'); +$void = $correction->validateVoid($existing, ['reason' => 'Duplicate entry']); +domain_command_assert_same(true, $void['valid'], 'Voiding should require and retain a reason.'); +domain_command_assert_same('void', $void['action'], 'Voiding should be an explicit logical action.'); +$voided = $correction->validateVoid([...$existing, 'voided' => true], ['reason' => 'again']); +domain_command_assert_same(false, $voided['valid'], 'An already voided entry cannot be voided twice.'); + +printf("Domain command/service tests: 10 passed\n"); diff --git a/tests/NotificationDomainTest.php b/tests/NotificationDomainTest.php new file mode 100644 index 0000000..0fe580c --- /dev/null +++ b/tests/NotificationDomainTest.php @@ -0,0 +1,44 @@ +normalize(['type' => 'assignment_created', 'recipient' => 'A@EXAMPLE.COM', 'read' => 'unread', 'dedup_key' => ' Assignment:7 '])['is_read'], 'Unread aliases should normalize to false.'); +notification_assert_same(true, $records->normalize(['type' => 'assignment_created', 'recipient' => 'a@example.com', 'read' => 'read', 'dedup_key' => 'assignment:7'])['is_read'], 'Read aliases should normalize to true.'); +notification_assert_same(true, $records->validateMarkRead(['notification_id' => '12'])['valid'], 'A positive notification ID should be accepted for marking read.'); +notification_assert_same(12, $records->validateMarkRead(['id' => '12'])['notification_id'], 'Mark-read IDs should normalize to integers.'); +notification_assert_same(false, $records->validateMarkRead(['notification_id' => '0'])['valid'], 'Zero is not a valid notification ID.'); + +$assignment = $records->assignmentCreated([ + 'jobcard_id' => '42', 'jobcard_reference' => 'JC-2026-000042', + 'recipients' => ['Tech@Example.com'], 'technician_name' => ' Sam ', +]); +notification_assert_same('assignment_created', $assignment['type'], 'Assignment factory should set its event type.'); +notification_assert_same('assignment:42', $assignment['deduplication_key'], 'Assignment factory should use a stable deduplication key.'); +notification_assert_same(['tech@example.com'], $assignment['recipients'], 'Factories should normalize recipients.'); + +$status = $records->statusChanged(['jobcard_id' => 42, 'from_status' => 'new', 'to_status' => 'assigned', 'recipients' => ['ops@example.com']]); +notification_assert_same('jobcard_status_changed', $status['type'], 'Status factory should set its event type.'); +notification_assert_same('jobcard:42:status:assigned', $status['deduplication_key'], 'Status factory should key by jobcard and destination status.'); + +$sla = $records->slaThreshold(['client_id' => 9, 'threshold' => 'critical', 'used_hours' => 9, 'allocated_hours' => 10, 'recipients' => ['ops@example.com']]); +notification_assert_same('sla_threshold', $sla['type'], 'SLA factory should set its event type.'); +notification_assert_same('sla:9:critical', $sla['deduplication_key'], 'SLA factory should key by client and threshold.'); + +$mapped = (new NotificationQueue())->mapForUser(['type' => 'assignment_created', 'recipients' => ['a@example.com', 'b@example.com'], 'title' => 'Assigned', 'deduplication_key' => 'assignment:42'], 'b@example.com'); +notification_assert_same(['type' => 'assignment_created', 'recipient' => 'b@example.com', 'title' => 'Assigned', 'body' => null, 'is_read' => false, 'deduplication_key' => 'assignment:42'], $mapped, 'Queue mapping should produce one per-user DTO.'); +notification_assert_same(2, count($records->toQueueDtos(['type' => 'assignment_created', 'recipients' => ['a@example.com', 'b@example.com'], 'title' => 'Assigned', 'deduplication_key' => 'assignment:42'])), 'Queue DTO mapping should produce one DTO per recipient.'); + +printf("Notification domain tests: 11 passed\n"); diff --git a/tests/ReportWorkflowTest.php b/tests/ReportWorkflowTest.php index 8774105..9fdc6ee 100644 --- a/tests/ReportWorkflowTest.php +++ b/tests/ReportWorkflowTest.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/../app/Domain/Reporting/ReportDataMapper.php'; require_once __DIR__ . '/../app/Domain/Reporting/ClientJobcardReport.php'; require_once __DIR__ . '/../app/Domain/Reporting/ClientHistoryReport.php'; require_once __DIR__ . '/../app/Domain/Reporting/TechnicianActivityReport.php'; +require_once __DIR__ . '/../app/Domain/Reporting/SlaReport.php'; require_once __DIR__ . '/../app/Domain/Reporting/PrintReportRenderer.php'; $filters = ReportFilters::fromArray([ @@ -47,9 +48,38 @@ if ($activity !== [['technician_id' => 4, 'technician_name' => 'Tess', 'client_i throw new RuntimeException('Technician activity must aggregate hours deterministically.'); } +$crossClientActivity = (new TechnicianActivityReport())->build([ + ['id' => 20, 'technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 8, 'client_name' => 'Beta', 'work_date' => '2026-09-02', 'hours' => 2, 'counts_toward_sla' => true, 'credentials' => 'secret'], + ['id' => 10, 'technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1, 'counts_toward_sla' => true, 'internal_notes' => 'secret'], +], 'internal'); +if ($crossClientActivity !== [ + ['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 1.0, 'sla_hours' => 1.0], + ['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 8, 'client_name' => 'Beta', 'hours' => 2.0, 'sla_hours' => 2.0], +]) { + throw new RuntimeException('Technician activity must preserve client attribution and sort by technician then client.'); +} +$clientActivity = (new TechnicianActivityReport())->build([ + ['technician_id' => 4, 'technician_name' => 'Tess', 'client_id' => 7, 'client_name' => 'Acme', 'work_date' => '2026-09-02', 'hours' => 1, 'counts_toward_sla' => true, 'password' => 'secret'], +], 'client'); +if ($clientActivity !== [['client_id' => 7, 'client_name' => 'Acme', 'hours' => 1.0, 'sla_hours' => 1.0]]) { + throw new RuntimeException('Client technician activity projection must exclude technician and secret fields.'); +} + +$filteredSla = (new SlaReport(ReportFilters::fromArray(['client_id' => 7, 'date_from' => '2026-09-01', 'date_to' => '2026-09-30'])))->build([ + ['client_id' => 8, 'client_name' => 'Beta', 'allocated_hours' => 10, 'hours' => [1], 'start_date' => '2026-09-01'], + ['client_id' => 7, 'client_name' => 'Acme', 'allocated_hours' => 10, 'hours' => [2], 'start_date' => '2026-09-01', 'credentials' => 'secret'], +], 'client'); +if ($filteredSla !== [['client_id' => 7, 'client_name' => 'Acme', 'allocated_hours' => 10.0, 'used_hours' => 2.0, 'remaining_hours' => 8.0, 'usage_percentage' => 20.0, 'status' => 'within_limit']]) { + throw new RuntimeException('SLA report must apply filters and expose a safe audience projection.'); +} + $html = (new PrintReportRenderer())->render('Client Jobcards', ['Reference', 'Status'], [['JC-2', 'open']]); if (!str_contains($html, '@media print') || !str_contains($html, 'Reference') || !str_contains($html, 'JC-2') || str_contains($html, '']]); +if (str_contains($escapedHtml, '