diff --git a/app/Domain/Client/ContactEditCommand.php b/app/Domain/Client/ContactEditCommand.php index 95582ea..923e6e7 100644 --- a/app/Domain/Client/ContactEditCommand.php +++ b/app/Domain/Client/ContactEditCommand.php @@ -16,24 +16,36 @@ final class ContactEditCommand } /** @return array */ - public function validateEdit(int $id, array $input, array $existingContacts = []): array + public function validateEdit(mixed $id, array $input, array $existingContacts = []): array { + $normalizedId = $this->id($id); $result = ($this->contacts ?? new ContactUpdateCommand())->validateForEdit($id, $input, $existingContacts); - if ($id < 1) { + if ($normalizedId === null) { $result['errors']['id'] = 'Contact ID must be a positive integer.'; - $result['valid'] = false; + } else { + $target = $this->find($normalizedId, $existingContacts); + if ($target === null) $result['errors']['id'] = 'Contact was not found.'; + else { + $oldClient = $this->id($target['client_id'] ?? null); + $newClient = $this->id($input['client_id'] ?? null); + if ($oldClient !== null && $newClient !== $oldClient) $result['errors']['client_id'] = 'Contact client ID cannot be changed during edit.'; + } } + $result['id'] = $normalizedId; + $result['valid'] = $result['errors'] === []; + $result['action'] = 'edit'; + $result['audit'] = ['event' => 'client_contact_updated', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $result['client_id'] ?? null]; return $result; } /** Alias matching the create/update command vocabulary. */ - public function validateForEdit(int $id, array $input, array $existingContacts = []): array + public function validateForEdit(mixed $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 + public function validate(array $input, array $existingContacts = [], mixed $currentId = null): array { return $currentId === null ? ($this->contacts ?? new ContactUpdateCommand())->validateForCreate($input, $existingContacts) @@ -45,27 +57,28 @@ final class ContactEditCommand * contact for the same client; deleting the sole contact is not allowed. * @return array */ - public function validateDelete(int $id, array $existingContacts = []): array + public function validateDelete(mixed $id, array $existingContacts = []): array { + $normalizedId = $this->id($id); $errors = []; $target = null; foreach ($existingContacts as $contact) { - if (is_array($contact) && $this->id($contact['id'] ?? null) === $id) { + if ($normalizedId !== null && is_array($contact) && $this->id($contact['id'] ?? null) === $normalizedId) { $target = $contact; break; } } - if ($id < 1) $errors['id'] = 'Contact ID must be a positive integer.'; + if ($normalizedId === null) $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]; + if ($normalizedId !== null) $errors['id'] = 'Contact was not found.'; + return ['valid' => false, 'action' => 'delete', 'id' => $normalizedId, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors, 'audit' => null]; } $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)); + $sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $normalizedId)); $replacement = null; if (count($sameClient) === 0) { $errors['delete'] = 'The only contact cannot be deleted; add another contact first.'; @@ -74,36 +87,37 @@ final class ContactEditCommand $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]; + return ['valid' => $errors === [], 'action' => 'delete', 'id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'client_contact_deleted', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement] : null]; } - public function validateForDelete(int $id, array $existingContacts = []): array + public function validateForDelete(mixed $id, array $existingContacts = []): array { return $this->validateDelete($id, $existingContacts); } /** @return array */ - public function delete(int $id, array $existingContacts = []): array + public function delete(mixed $id, array $existingContacts = []): array { return $this->validateDelete($id, $existingContacts); } /** @return array */ - public function validatePrimary(int $id, array $existingContacts = []): array + public function validatePrimary(mixed $id, array $existingContacts = []): array { + $normalizedId = $this->id($id); foreach ($existingContacts as $row) { - if (is_array($row) && $this->id($row['id'] ?? null) === $id) { + if ($normalizedId !== null && is_array($row) && $this->id($row['id'] ?? null) === $normalizedId) { $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' => []]; + foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $normalizedId && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null); + return ['valid' => true, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => [], 'audit' => ['event' => 'client_contact_primary_set', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'demoted_contact_ids' => array_values(array_filter($demote))]]; } } - return ['valid' => false, 'id' => $id, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => 'Contact was not found.']]; + return ['valid' => false, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => $normalizedId === null ? 'Contact ID must be a positive integer.' : 'Contact was not found.'], 'audit' => null]; } /** @return array */ - public function setPrimary(int $id, array $existingContacts = []): array + public function setPrimary(mixed $id, array $existingContacts = []): array { return $this->validatePrimary($id, $existingContacts); } @@ -115,4 +129,9 @@ final class ContactEditCommand 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)); } + private function find(int $id, array $rows): ?array + { + foreach ($rows as $row) if (is_array($row) && $this->id($row['id'] ?? null) === $id) return $row; + return null; + } } diff --git a/app/Domain/Client/ContactUpdateCommand.php b/app/Domain/Client/ContactUpdateCommand.php index 2530087..c2baa96 100644 --- a/app/Domain/Client/ContactUpdateCommand.php +++ b/app/Domain/Client/ContactUpdateCommand.php @@ -61,11 +61,12 @@ final class ContactUpdateCommand } /** @return array */ - public function validateForEdit(int $id, array $input, array $existingContacts = []): array + public function validateForEdit(mixed $id, array $input, array $existingContacts = []): array { - $result = $this->validate($input, $existingContacts, $id); - $result['id'] = $id; - if ($id < 1) $result['errors']['id'] = 'Contact ID must be a positive integer.'; + $normalizedId = $this->positiveId($id); + $result = $this->validate($input, $existingContacts, $normalizedId); + $result['id'] = $normalizedId; + if ($normalizedId === null) $result['errors']['id'] = 'Contact ID must be a positive integer.'; $result['valid'] = $result['errors'] === []; return $result; } diff --git a/app/Domain/Credential/TechnicalInformation.php b/app/Domain/Credential/TechnicalInformation.php index b498d03..b2ac804 100644 --- a/app/Domain/Credential/TechnicalInformation.php +++ b/app/Domain/Credential/TechnicalInformation.php @@ -14,11 +14,15 @@ final class TechnicalInformation public const CATEGORY_SSH = 'ssh'; public const CATEGORY_API = 'api'; public const CATEGORY_OTHER = 'other'; + public const CATEGORY_MICROSOFT = 'microsoft'; + public const CATEGORY_NETWORK = 'network'; + public const CATEGORY_ROUTER = 'router'; + public const CATEGORY_INFRASTRUCTURE = 'infrastructure'; /** @return list */ public static function categories(): array { - return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER]; + return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER, self::CATEGORY_MICROSOFT, self::CATEGORY_NETWORK, self::CATEGORY_ROUTER, self::CATEGORY_INFRASTRUCTURE]; } /** @return array{category:string, label:string, username:string|null, notes:string|null} */ diff --git a/app/Domain/Credential/TechnicalInformationCommand.php b/app/Domain/Credential/TechnicalInformationCommand.php new file mode 100644 index 0000000..da961f9 --- /dev/null +++ b/app/Domain/Credential/TechnicalInformationCommand.php @@ -0,0 +1,165 @@ +> */ + private const FIELDS = [ + 'microsoft' => ['label', 'tenant', 'product', 'portal_url', 'username', 'notes'], + 'network' => ['label', 'hostname', 'ip_address', 'vlan', 'username', 'notes'], + 'router' => ['label', 'hostname', 'ip_address', 'model', 'username', 'notes'], + 'infrastructure' => ['label', 'hostname', 'ip_address', 'role', 'os', 'username', 'notes'], + ]; + + /** @return list */ + public static function categories(): array { return array_keys(self::FIELDS); } + + /** @return array */ + public function validate(array $input): array + { + $clientId = $this->positiveId($input['client_id'] ?? null); + $category = $this->category($input['category'] ?? null); + $data = $this->inputData($input); + $errors = []; + if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.'; + if ($category === null) $errors['category'] = 'Technical information category is invalid.'; + $dataResult = $this->validateData($category ?? '', $data); + $errors = [...$errors, ...$dataResult['errors']]; + $record = ['client_id' => $clientId, 'category' => $category, 'data' => $dataResult['data']]; + return ['valid' => $errors === [], 'record' => $record, 'errors' => $errors]; + } + + public function validateForCreate(array $input): array { return $this->validate($input); } + public function validateCreate(array $input): array { return $this->validate($input); } + + /** @return array */ + public function validateEdit(array $existing, array $changes): array + { + $id = $this->positiveId($existing['id'] ?? null); + $existingCategory = $this->category($existing['category'] ?? null); + $base = ['client_id' => $existing['client_id'] ?? null, 'category' => $existing['category'] ?? null, 'data' => $this->inputData($existing)]; + if (array_key_exists('client_id', $changes)) $base['client_id'] = $changes['client_id']; + $categoryChange = array_key_exists('category', $changes) ? $this->category($changes['category']) : $existingCategory; + $categoryChanged = array_key_exists('category', $changes) && $categoryChange !== $existingCategory; + $changeData = $this->inputData($changes); + $base['data'] = [...$this->inputData($existing), ...$changeData]; + $result = $this->validate($base); + if ($id === null) { $result['errors']['id'] = 'Technical information ID must be a positive integer.'; $result['valid'] = false; } + if ($categoryChanged) { $result['errors']['category'] = 'Technical information category cannot be changed during edit.'; $result['valid'] = false; } + $result['record']['id'] = $id; + return ['valid' => $result['valid'], 'action' => 'edit', 'record' => $result['record'], 'errors' => $result['errors']]; + } + + public function validateForEdit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); } + public function edit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); } + + /** @return array */ + public function validateDelete(array $existing): array + { + $id = $this->positiveId($existing['id'] ?? null); + $clientId = $this->positiveId($existing['client_id'] ?? null); + $category = $this->category($existing['category'] ?? null); + $errors = []; + if ($id === null) $errors['id'] = 'Technical information ID must be a positive integer.'; + if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.'; + if ($category === null) $errors['category'] = 'Technical information category is invalid.'; + return ['valid' => $errors === [], 'action' => 'delete', 'id' => $id, 'client_id' => $clientId, 'category' => $category, 'errors' => $errors]; + } + + public function validateForDelete(array $existing): array { return $this->validateDelete($existing); } + + public function delete(array $existing, ?object $repository = null): array + { + $result = $this->validateDelete($existing); + if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information delete: ' . implode(' ', $result['errors'])); + $repository ??= $this->repository; + if ($repository !== null && method_exists($repository, 'delete')) return $repository->delete($result['id']); + return $result; + } + + /** @return array */ + public function display(array $record): array + { + $category = $this->category($record['category'] ?? null); + $data = $this->inputData($record); + $safe = []; + foreach (['id', 'client_id', 'category'] as $field) if (array_key_exists($field, $record)) $safe[$field] = $record[$field]; + $safeData = []; + foreach (self::FIELDS[$category ?? ''] ?? [] as $field) if (array_key_exists($field, $data)) $safeData[$field] = $data[$field]; + $safe['data'] = $safeData; + return $safe; + } + + public function toDisplay(array $record): array { return $this->display($record); } + + /** Persist a validated create through the repository/adapter when supplied. */ + public function create(int $clientId, array $input, ?int $updatedBy = null, ?object $repository = null): array + { + if ($repository === null) $repository = $this->repository; + if ($repository === null && isset($input['repository']) && is_object($input['repository'])) $repository = $input['repository']; + $result = $this->validate([...$input, 'client_id' => $clientId]); + $this->throwIfInvalid($result); + if ($repository === null) return $result['record']; + if (method_exists($repository, 'upsertInformation')) return $repository->upsertInformation($clientId, ['category' => $result['record']['category'], 'data' => $result['record']['data']], $updatedBy); + if (method_exists($repository, 'upsert')) return $repository->upsert($clientId, $result['record']['category'], $result['record']['data'], $updatedBy); + throw new InvalidArgumentException('Technical information repository adapter is incompatible.'); + } + + /** Constructor-compatible service form: new TechnicalInformationCommand($repository). */ + public function __construct(private readonly ?object $repository = null) {} + + public function store(int $clientId, array $input, ?int $updatedBy = null): array { return $this->create($clientId, $input, $updatedBy, $this->repository); } + + public function update(array $existing, array $changes, ?int $updatedBy = null, ?object $repository = null): array + { + $result = $this->validateEdit($existing, $changes); + if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information edit: ' . implode(' ', $result['errors'])); + $repository ??= $this->repository; + if ($repository !== null && method_exists($repository, 'upsert')) return $repository->upsert($result['record']['client_id'], $result['record']['category'], $result['record']['data'], $updatedBy); + return $result['record']; + } + + /** @return array{data:array,errors:array} */ + public function validateData(string $category, array $data): array + { + $allowed = self::FIELDS[$category] ?? []; + $normalized = []; + $errors = []; + foreach ($data as $field => $value) { + if (!is_string($field) || !in_array($field, $allowed, true)) { $errors['data.' . (string)$field] = 'This technical-information field is not allowed.'; continue; } + if ($field === 'vlan') { + if ((is_int($value) || (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1)) && (int)$value >= 1 && (int)$value <= 4094) $normalized[$field] = (int)$value; + else $errors['data.vlan'] = 'VLAN must be an integer from 1 to 4094.'; + continue; + } + if (!is_scalar($value)) { $errors['data.' . $field] = 'Technical-information fields must be scalar JSON values.'; continue; } + $text = trim((string)$value); + if ($text === '') continue; + if (in_array($field, ['ip_address'], true) && filter_var($text, FILTER_VALIDATE_IP) === false) $errors['data.' . $field] = 'IP address is invalid.'; + elseif ($field === 'portal_url' && filter_var($text, FILTER_VALIDATE_URL) === false) $errors['data.' . $field] = 'Portal URL is invalid.'; + elseif (mb_strlen($text) > 1000) $errors['data.' . $field] = 'Technical-information text is too long.'; + else $normalized[$field] = $text; + } + if (!isset($normalized['label'])) $errors['data.label'] = 'Technical information label is required.'; + return ['data' => $normalized, 'errors' => $errors]; + } + + /** @return array */ + private function inputData(array $input): array + { + if (isset($input['data']) && is_array($input['data'])) return $input['data']; + return array_diff_key($input, array_flip(['id', 'client_id', 'category', 'valid', 'errors', 'action', 'display', 'audit', 'data_json', 'updated_by', 'created_at', 'updated_at'])); + } + private function category(mixed $value): ?string { if (!is_scalar($value)) return null; $value = strtolower(trim((string)$value)); return in_array($value, self::categories(), true) ? $value : null; } + private function positiveId(mixed $value): ?int { return is_int($value) && $value > 0 ? $value : (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1 ? (int)$value : null); } + private function throwIfInvalid(array $result): void { if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $result['errors'])); } +} diff --git a/app/Domain/Credential/TechnicalInformationRepository.php b/app/Domain/Credential/TechnicalInformationRepository.php index c391437..a5e9a89 100644 --- a/app/Domain/Credential/TechnicalInformationRepository.php +++ b/app/Domain/Credential/TechnicalInformationRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Credential; require_once __DIR__ . '/TechnicalInformation.php'; +require_once __DIR__ . '/TechnicalInformationCommand.php'; use InvalidArgumentException; use PDO; @@ -33,17 +34,17 @@ final class TechnicalInformationRepository 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 = strtolower(trim($category)); + if (in_array($normalizedCategory, TechnicalInformationCommand::categories(), true)) { + $validation = (new TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => $normalizedCategory, 'data' => $data]); + if (!$validation['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors'])); + $jsonData = $validation['record']['data']; + } else { + $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']]; } - - $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( @@ -73,7 +74,11 @@ final class TechnicalInformationRepository if (!is_string($category)) { throw new InvalidArgumentException('Technical information category is required.'); } - unset($information['category']); + if (isset($information['data']) && is_array($information['data'])) { + $information = $information['data']; + } else { + unset($information['category']); + } return $this->upsert($clientId, $category, $information, $updatedBy); } @@ -128,6 +133,15 @@ final class TechnicalInformationRepository return $this->display($record); } + /** Delete by record id; adapters may use the same contract for logical commands. */ + public function delete(int $id): array + { + if ($id < 1) throw new InvalidArgumentException('Technical information id must be a positive integer.'); + $statement = $this->pdo->prepare('DELETE FROM technical_information WHERE id = :id'); + $statement->execute(['id' => $id]); + return ['valid' => true, 'action' => 'delete', 'id' => $id, 'errors' => []]; + } + /** @param array $row @return array */ private function hydrate(array $row): array { @@ -159,17 +173,19 @@ final class TechnicalInformationRepository return $record; } - /** @return array{label:string,username:string|null,notes:string|null} */ + /** @return array */ 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, - ]; + $allowed = ['label', 'tenant', 'product', 'portal_url', 'hostname', 'ip_address', 'vlan', 'model', 'role', 'os', 'username', 'notes']; + $data = []; + foreach ($allowed as $field) { + if (!array_key_exists($field, $decoded) || !is_scalar($decoded[$field])) continue; + $data[$field] = $field === 'vlan' ? (int)$decoded[$field] : (string)$decoded[$field]; + } + return $data + ['label' => '', 'username' => null, 'notes' => null]; } private function assertIds(int $clientId, ?int $updatedBy): void diff --git a/app/Domain/Jobcard/TimeEntryCorrectionCommand.php b/app/Domain/Jobcard/TimeEntryCorrectionCommand.php index 9a4eafc..c169272 100644 --- a/app/Domain/Jobcard/TimeEntryCorrectionCommand.php +++ b/app/Domain/Jobcard/TimeEntryCorrectionCommand.php @@ -26,6 +26,7 @@ final class TimeEntryCorrectionCommand 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']; + foreach (array_keys($changes) as $field) if (!in_array($field, $allowed, true) && !in_array($field, ['jobcard_id', 'technician_id'], true)) $errors[$field] = "{$field} cannot be changed during correction."; $payload = $existing; foreach ($allowed as $field) if (array_key_exists($field, $changes)) $payload[$field] = $changes[$field]; $validated = ($this->entries ?? new TimeEntryCommand())->validate($payload); @@ -33,7 +34,8 @@ final class TimeEntryCorrectionCommand $entry = [...$payload, ...$validated]; unset($entry['valid'], $entry['errors']); $entry['id'] = $id; - return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors]; + $changed = []; foreach ($allowed as $field) if (array_key_exists($field, $changes) && ($existing[$field] ?? null) !== ($entry[$field] ?? null)) $changed[] = $field; + return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors, 'audit' => ['event' => 'time_entry_corrected', 'entity_type' => 'time_entry', 'entity_id' => $id, 'changed_fields' => $changed, 'before' => $existing, 'after' => $entry]]; } /** @return array */ @@ -57,7 +59,7 @@ final class TimeEntryCorrectionCommand $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]; + return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'time_entry_voided', 'entity_type' => 'time_entry', 'entity_id' => $id, 'void_reason' => $reason] : null]; } public function void(array $existing, array $input = []): array diff --git a/app/Domain/Notification/NotificationQueue.php b/app/Domain/Notification/NotificationQueue.php index 6f73a66..ce6930a 100644 --- a/app/Domain/Notification/NotificationQueue.php +++ b/app/Domain/Notification/NotificationQueue.php @@ -23,8 +23,12 @@ final class NotificationQueue $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 = []; + $startedTransaction = false; try { - $pdo->beginTransaction(); + if (!$pdo->inTransaction()) { + $pdo->beginTransaction(); + $startedTransaction = true; + } foreach ($validation['recipients'] as $email) { $dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email); $lookup->execute(['email' => $email]); @@ -34,9 +38,9 @@ final class NotificationQueue $id = (int)$pdo->lastInsertId(); if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id; } - $pdo->commit(); + if ($startedTransaction) $pdo->commit(); } catch (\Throwable $exception) { - if ($pdo->inTransaction()) $pdo->rollBack(); + if ($startedTransaction && $pdo->inTransaction()) $pdo->rollBack(); throw $exception; } return $ids; @@ -58,4 +62,15 @@ final class NotificationQueue $stmt->execute(['id' => $validation['notification_id'], 'user' => $user]); return $stmt->rowCount() > 0; } + + /** Mark a notification unread only for the authenticated user's row. */ + public function markUnread(PDO $pdo, int|string $userId, array $command): bool + { + $user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $validation = ($this->records ?? new NotificationRecord())->validateMarkUnread($command); + if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-unread command.'); + $stmt = $pdo->prepare('UPDATE notifications SET read_at = NULL 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 295d7e6..1a4ecf4 100644 --- a/app/Domain/Notification/NotificationRecord.php +++ b/app/Domain/Notification/NotificationRecord.php @@ -35,7 +35,7 @@ final class NotificationRecord ]; } - /** Validate the command used by a user to mark one notification read. */ + /** Validate the command used by a user to change one notification's read state. */ public function validateMarkRead(array $command): array { $value = $command['notification_id'] ?? $command['id'] ?? null; @@ -46,6 +46,12 @@ final class NotificationRecord return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors]; } + /** Alias with an explicit command name for callers that mark notifications unread. */ + public function validateMarkUnread(array $command): array + { + return $this->validateMarkRead($command); + } + /** Return one normalized queue DTO for a single recipient. */ public function toQueueDto(array $record, string $recipient): array { @@ -136,6 +142,8 @@ final class NotificationRecord return [ 'type' => $normalized['type'], 'recipients' => $normalized['recipients'], + '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'], ]; diff --git a/app/Domain/Reporting/ClientHistoryService.php b/app/Domain/Reporting/ClientHistoryService.php index 287e454..3215b5a 100644 --- a/app/Domain/Reporting/ClientHistoryService.php +++ b/app/Domain/Reporting/ClientHistoryService.php @@ -29,12 +29,33 @@ final class ClientHistoryService return $this->filter($rows, $criteria); } + /** @return array */ + public function validateForClient(mixed $clientId, array $rows, array $criteria = []): array + { + $id = $this->positiveId($clientId); + if ($id === null) return ['valid' => false, 'client_id' => null, 'timeline' => [], 'errors' => ['client_id' => 'Client ID must be a positive integer.']]; + return ['valid' => true, 'client_id' => $id, 'timeline' => $this->forClient($rows, $id, $criteria), 'errors' => []]; + } + + /** Controller-ready client history timeline command. */ + public function timeline(array $rows, mixed $clientId, array $criteria = []): array + { + return $this->validateForClient($clientId, $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); } + + 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/Reporting/HoursPerClientReport.php b/app/Domain/Reporting/HoursPerClientReport.php index 2483340..40ecf27 100644 --- a/app/Domain/Reporting/HoursPerClientReport.php +++ b/app/Domain/Reporting/HoursPerClientReport.php @@ -1,13 +1,32 @@ > $entries - * @return list - */ + public function __construct( + private readonly ?ReportFilters $filters = null, + private readonly ?ReportDataMapper $mapper = null, + ) {} + + /** @param list> $entries @return list> */ + public function build(array $entries, string $audience = ReportAudience::CLIENT): array + { + ReportAudience::validate($audience); + $filters = $this->filters ?? new ReportFilters(); + $selected = array_values(array_filter($entries, static fn (array $entry): bool => $filters->matches($entry))); + $rows = $this->aggregate($selected); + // This report contains no technician or internal-note fields, so the same + // stable projection is safe for both audiences. + return $rows; + } + + /** @param list> $entries @return list */ public function aggregate(array $entries): array { $totals = []; @@ -15,20 +34,16 @@ final class HoursPerClientReport $id = (int)($entry['client_id'] ?? 0); $key = (string)$id; if (!isset($totals[$key])) { - $totals[$key] = [ - 'client_id' => $id, - 'client_name' => (string)($entry['client_name'] ?? ''), - 'hours' => 0.0, - ]; + $totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0]; } $totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0)); } $rows = array_values($totals); - foreach ($rows as &$row) { - $row['hours'] = round($row['hours'], 2); - } + foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2); unset($row); - 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($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id'])); return $rows; } + + public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); } } diff --git a/app/Domain/Reporting/PrintReportRenderer.php b/app/Domain/Reporting/PrintReportRenderer.php index 59668c8..2722202 100644 --- a/app/Domain/Reporting/PrintReportRenderer.php +++ b/app/Domain/Reporting/PrintReportRenderer.php @@ -18,7 +18,7 @@ final class PrintReportRenderer foreach ($rows as $row) { $body .= '' . implode('', array_map(fn(mixed $value): string => '' . $this->escape($value) . '', $row)) . ''; } - return '' . $this->escape($title) . '

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

' . $head . '' . $body . '
'; + return '' . $this->escape($title) . '

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

' . $head . '' . $body . '
'; } /** @param list> $rows */ diff --git a/app/Domain/Reporting/SlaReport.php b/app/Domain/Reporting/SlaReport.php index 50e0175..7223a7a 100644 --- a/app/Domain/Reporting/SlaReport.php +++ b/app/Domain/Reporting/SlaReport.php @@ -20,9 +20,14 @@ final class SlaReport ReportAudience::validate($audience); $filters = $this->filters ?? new ReportFilters(); $mapper = $this->mapper ?? new ReportDataMapper(); + // SLA status is derived below; apply all source-field filters first and + // apply the SLA filter to the computed status after usage is calculated. + $sourceFilters = $filters->sla === null + ? $filters + : new ReportFilters($filters->clientId, $filters->dateFrom, $filters->dateTo, $filters->technicianId, $filters->status, $filters->priority); $rows = []; foreach ($agreements as $agreement) { - if (!$filters->matches($agreement)) continue; + if (!$sourceFilters->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); @@ -30,6 +35,7 @@ final class SlaReport $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')); + if ($filters->sla !== null && $filters->sla !== $status) continue; $record = [ 'client_id' => (int)($agreement['client_id'] ?? 0), 'client_name' => (string)($agreement['client_name'] ?? ''), diff --git a/app/Domain/Reporting/TechnicianWorkloadReport.php b/app/Domain/Reporting/TechnicianWorkloadReport.php new file mode 100644 index 0000000..fc729ae --- /dev/null +++ b/app/Domain/Reporting/TechnicianWorkloadReport.php @@ -0,0 +1,50 @@ +> $rows @return list> */ + public function build(array $rows, string $audience = ReportAudience::INTERNAL): array + { + ReportAudience::validate($audience); + $filters = $this->filters ?? new ReportFilters(); + $totals = []; + foreach ($rows as $row) { + if (!$filters->matches($row)) continue; + $technicianId = (int)($row['technician_id'] ?? 0); + $clientId = (int)($row['client_id'] ?? 0); + $key = $audience === ReportAudience::CLIENT ? 'client:' . $clientId : 'technician:' . $technicianId; + if (!isset($totals[$key])) { + $totals[$key] = $audience === ReportAudience::CLIENT + ? ['client_id' => $clientId, 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0] + : ['technician_id' => $technicianId, 'technician_name' => (string)($row['technician_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0]; + } + $hours = max(0.0, (float)($row['hours'] ?? 0)); + $totals[$key]['hours'] += $hours; + if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours; + } + $result = array_values($totals); + foreach ($result as &$item) { + $item['hours'] = round($item['hours'], 2); + $item['sla_hours'] = round($item['sla_hours'], 2); + } + unset($item); + usort($result, static fn (array $a, array $b): int => array_key_exists('client_id', $a) + ? (strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)$a['client_id'] <=> (int)$b['client_id'])) + : (strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)$a['technician_id'] <=> (int)$b['technician_id']))); + return $result; + } + + public function query(array $rows, string $audience = ReportAudience::INTERNAL): array { return $this->build($rows, $audience); } +} diff --git a/app/Domain/User/RolePermissionService.php b/app/Domain/User/RolePermissionService.php index 9211c1b..a53d8cf 100644 --- a/app/Domain/User/RolePermissionService.php +++ b/app/Domain/User/RolePermissionService.php @@ -49,7 +49,24 @@ final class RolePermissionService return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors]; } - /** Alias matching controller command terminology. */ + /** Validate creation of a custom role and its optional permission set. */ + public function validateForCreate(array $input, array $available = []): array + { + $record = $this->roles ?? new RoleRecord(); + $result = $record->validate($input); + if ($record->isAdministrator($input)) { + $result['errors']['role'] = 'The protected Administrator role cannot be created or renamed.'; + } + if (array_key_exists('permissions', $input)) { + $assignment = $this->validateAssignment(['id' => 2, 'name' => $result['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available); + $result['permissions'] = $assignment['permissions']; + $result['errors'] = [...$result['errors'], ...$assignment['errors']]; + } + $result['valid'] = $result['errors'] === []; + return $result; + } + + /** @return array{role_id: int, permissions: list, valid: bool, errors: array} */ public function validateForAssignment(array $role, array $selected, array $available = []): array { return $this->validateAssignment($role, $selected, $available); @@ -68,9 +85,7 @@ final class RolePermissionService $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 (!$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']; @@ -80,6 +95,28 @@ final class RolePermissionService return $result; } + /** Alias matching edit controller terminology. */ + public function validateEdit(int $id, array $input, array $available = []): array + { + return $this->validateForEdit($id, $input, $available); + } + + /** Validate deletion of a custom role; ID 1 is always protected. */ + public function validateDelete(int $id, array $role = []): array + { + $errors = []; + if ($id < 1) $errors['id'] = 'Role ID must be a positive integer.'; + if ($id === 1 || ($role !== [] && !($this->roles ?? new RoleRecord())->canDelete(['id' => $id, ...$role]))) { + $errors['role'] = 'The protected Administrator role cannot be deleted.'; + } + return ['valid' => $errors === [], 'id' => $id, 'errors' => $errors]; + } + + public function delete(int $id, array $role = []): array + { + return $this->validateDelete($id, $role); + } + public function canAssignPermissions(array $role): bool { return !($this->roles ?? new RoleRecord())->isAdministrator($role); diff --git a/app/Domain/User/RoleRecord.php b/app/Domain/User/RoleRecord.php index 5d023b3..ade6d79 100644 --- a/app/Domain/User/RoleRecord.php +++ b/app/Domain/User/RoleRecord.php @@ -48,6 +48,7 @@ final class RoleRecord public function isAdministrator(array $record): bool { + if (isset($record['id']) && (int) $record['id'] === 1) return true; return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR; } diff --git a/app/Domain/User/UserAdminService.php b/app/Domain/User/UserAdminService.php index e6408ce..ffe0215 100644 --- a/app/Domain/User/UserAdminService.php +++ b/app/Domain/User/UserAdminService.php @@ -30,6 +30,18 @@ final class UserAdminService return $result; } + /** Controller-friendly user-create DTO. */ + public function validateForCreate(array $input, array $existingUsers = []): array + { + $result = ($this->users ?? new UserRecord())->validateForCreate($input); + $email = $result['email'] ?? ''; + if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, 0)) { + $result['errors']['email'] = 'Email address is already in use.'; + $result['valid'] = false; + } + return $result; + } + /** @return array */ public function validateForEdit(int $id, array $input, array $existingUsers = []): array { @@ -40,7 +52,7 @@ final class UserAdminService $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.'; + if ($this->isProtectedAdministrator(['id' => $id, ...$existing]) && array_key_exists('role_id', $input) && (int)$input['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.'; @@ -52,6 +64,12 @@ final class UserAdminService return $result; } + /** Alias matching controller command terminology. */ + public function validateEdit(int $id, array $input, array $existingUsers = []): array + { + return $this->validateForEdit($id, $input, $existingUsers); + } + /** @return array{valid: bool, id: int, is_active: bool, errors: array} */ public function validateDeactivate(array $user): array { @@ -121,6 +139,7 @@ final class UserAdminService public function isProtectedAdministrator(array $user): bool { + if (isset($user['id']) && (int)$user['id'] === 1) return true; 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'; diff --git a/docs/uat-checklist.md b/docs/uat-checklist.md index 6325f7d..dd0949e 100644 --- a/docs/uat-checklist.md +++ b/docs/uat-checklist.md @@ -57,6 +57,22 @@ Run this checklist against a production-like deployment over HTTPS with a fresh - [ ] Verify CSV/other exports handle commas, quotes, line breaks and formula-like values safely. - [ ] Compare a report total with the underlying test jobcards/time entries and retain the comparison evidence. +## Final-scope acceptance cases + +Use distinct fixture IDs for each client and record expected/observed results without including secrets in evidence. The executable contract companion is `php -d assert.exception=1 tests/FinalScopeIntegrationTest.php`. + +- [ ] **FS-01 — Technical information:** Add valid hosting/VPN/domain/database/SSH/API metadata; verify labels and usernames are trimmed, supported categories are enforced, control characters/oversized notes are rejected, and display output contains metadata only (never a credential secret). +- [ ] **FS-02 — Contact actions:** Edit a contact, promote a secondary contact, and delete a primary contact; verify duplicate names/emails are rejected, only same-client primary contacts are demoted, the lowest remaining same-client contact is promoted on primary deletion, and a client's sole contact cannot be deleted. +- [ ] **FS-03 — Time corrections:** Correct date, duration, notes and SLA-counting state while retaining the original time-entry ID, jobcard ID and technician ID. Verify ownership changes, corrections to voided entries, invalid ranges and missing void reasons are rejected. Confirm the original and correction/void actor are retained by the deployment's audit trail. +- [ ] **FS-04 — Custom roles:** Create a custom role, assign a least-privilege permission set, rename it and remove it; verify permissions are normalized/deduplicated, server-side authorization remains enforced on direct URLs/forms, and the Administrator role cannot be renamed, deleted or permission-edited. +- [ ] **FS-05 — Notifications:** Trigger assignment, status-change and SLA-threshold events; verify normalized per-user rows, stable deduplication, inactive/unknown recipients skipped, mark-read changes only the authenticated user's row, and notification bodies contain no credential or internal-note values. +- [ ] **FS-06 — Report audience separation:** Compare the same fixtures in client and internal reports/print/CSV output. Client audience must omit technician identity, internal notes, credentials and other operational-only fields; internal audience may retain authorized attribution. Direct report URLs and exports must enforce the same audience and role checks. +- [ ] **FS-07 — Technician scope:** With two technicians and two clients, verify each technician can list/view/update only assigned jobcards and sees only their own time totals. Changing jobcard, client, attachment, credential, report or time-entry IDs must return the documented not-found/denied response without leaking metadata or mutating another technician's records. +- [ ] **FS-08 — CSRF and method checks:** Submit missing and wrong CSRF tokens to login, logout, client/contact, jobcard/status/assignment/time, attachment, credential, SLA, notification and custom-role state changes; every request must be rejected before mutation (HTTP 419 or documented equivalent). Verify GET requests are read-only and logout is POST-only. +- [ ] **FS-09 — Attachment boundary:** Attempt traversal names, executable/double extensions, MIME/signature mismatches, oversized files and client-visible files without explicit approval; each must be rejected before storage. Upload a valid image/PDF and verify a generated server filename, validated MIME, `X-Content-Type-Options: nosniff`, no executable download behavior, and cross-client/jobcard access denial. +- [ ] **FS-10 — Credential boundary:** Create a canary credential and verify the database stores only `secret_ciphertext`, normal views show a mask, reveal is permission-controlled, client-bound, audited and returned with `Cache-Control: no-store`; a different client/credential ID cannot reveal it. Do not put the canary in screenshots, tickets or UAT evidence. +- [ ] **FS-11 — Production healthcheck:** Run `php bin/healthcheck.php` with valid configuration and capture exit status plus status-only output. Verify all current schema tables are probed, no password/APP_KEY/DSN/SQL exception/path is printed, and a disposable database missing one required table produces a non-zero exit. Run the same check after restore. + ## Restore verification - [ ] Restore the pre-UAT backup to a separate database/server, never over the live database. diff --git a/public/index.php b/public/index.php index b113f32..3935646 100644 --- a/public/index.php +++ b/public/index.php @@ -6,20 +6,32 @@ require_once __DIR__ . '/../app/Domain/Client/ClientValidator.php'; require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php'; require_once __DIR__ . '/../app/Domain/Client/ClientRecord.php'; require_once __DIR__ . '/../app/Domain/Client/ClientUpdateCommand.php'; +require_once __DIR__ . '/../app/Domain/Client/ContactEditCommand.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.php'; require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php'; require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryValidator.php'; require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php'; +require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCorrectionCommand.php'; require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php'; require_once __DIR__ . '/../app/Domain/SLA/SlaThresholdClassifier.php'; require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php'; require_once __DIR__ . '/../app/Domain/User/UserRecord.php'; +require_once __DIR__ . '/../app/Domain/User/RoleRecord.php'; +require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php'; +require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php'; require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php'; require_once __DIR__ . '/../app/Domain/Notification/NotificationRecord.php'; require_once __DIR__ . '/../app/Domain/Notification/NotificationQueue.php'; +require_once __DIR__ . '/../app/Domain/Reporting/ReportFilters.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'; require_once __DIR__ . '/../app/Domain/Credential/CredentialVault.php'; require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformation.php'; +require_once __DIR__ . '/../app/Domain/Credential/TechnicalInformationRepository.php'; require_once __DIR__ . '/../app/Domain/Attachment/AttachmentValidator.php'; ini_set('session.use_strict_mode', '1'); @@ -37,6 +49,8 @@ function render_header(string $title): void if (can('jobcards.view')) echo 'Jobcards'; if (can('reports.view')) echo 'Reports'; if (can('users.manage')) echo 'Users & roles'; + if (can('roles.manage')) echo 'Roles & permissions'; + if (can('notifications.view')) echo 'Notifications'; if (can('audit.view')) echo 'Audit trail'; echo '
'; } else { @@ -87,16 +101,16 @@ $user = require_login(); if ($route === 'dashboard') { require_permission('dashboard.view'); $jobcardMetrics = db()->query("SELECT SUM(status = 'new') AS new_count, SUM(status NOT IN ('completed','closed')) AS open_count FROM jobcards")->fetch(); - $hoursThisWeek = (float)db()->query('SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE()')->fetchColumn(); - $slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll(); + $hoursThisWeek = (float)db()->query("SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = time_entries.id AND av.action = 'time_entry_voided')")->fetchColumn(); + $slaRows = db()->query("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR (s.period_type = 'custom' AND te.work_date BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, CURDATE()))) THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s LEFT JOIN jobcards j ON j.client_id = s.client_id LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date")->fetchAll(); if ($user['role_name'] === 'Technician') { $metricStmt = db()->prepare("SELECT SUM(j.status = 'new') AS new_count, SUM(j.status NOT IN ('completed','closed')) AS open_count FROM jobcards j JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user"); $metricStmt->execute(['user' => $user['id']]); $jobcardMetrics = $metricStmt->fetch(); - $hoursStmt = db()->prepare('SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE()'); + $hoursStmt = db()->prepare("SELECT COALESCE(SUM(te.hours), 0) FROM time_entries te JOIN jobcard_assignments ja ON ja.jobcard_id = te.jobcard_id AND ja.user_id = :user WHERE te.work_date >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND te.work_date <= CURDATE() AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided')"); $hoursStmt->execute(['user' => $user['id']]); $hoursThisWeek = (float)$hoursStmt->fetchColumn(); - $slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date"); + $slaRowsStmt = db()->prepare("SELECT s.allocated_hours, s.period_type, s.start_date, s.end_date, COALESCE(SUM(CASE WHEN te.counts_toward_sla = 1 AND te.work_date >= COALESCE(s.start_date, '1000-01-01') AND te.work_date <= COALESCE(s.end_date, CURDATE()) AND ((s.period_type = 'monthly' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND CURDATE()) OR (s.period_type = 'annual' AND te.work_date BETWEEN DATE_FORMAT(CURDATE(), '%Y-01-01') AND CURDATE()) OR s.period_type = 'custom') THEN te.hours ELSE 0 END), 0) AS used_hours FROM sla_agreements s JOIN jobcards j ON j.client_id = s.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = te.id AND av.action = 'time_entry_voided') WHERE s.enabled = 1 AND CURDATE() BETWEEN COALESCE(s.start_date, '1000-01-01') AND COALESCE(s.end_date, '9999-12-31') GROUP BY s.id, s.allocated_hours, s.period_type, s.start_date, s.end_date"); $slaRowsStmt->execute(['user' => $user['id']]); $slaRows = $slaRowsStmt->fetchAll(); } @@ -124,6 +138,29 @@ if ($route === 'attachment') { readfile($path); exit; } +if ($route === 'client_history') { + require_permission('clients.view'); + $clientId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); + if (!$clientId || !can_access_client($clientId)) { http_response_code(404); exit('Client not found'); } + try { $filters = \ReportFilters::fromArray([...$_GET, 'client_id' => $clientId]); } catch (Throwable $exception) { http_response_code(400); exit('Invalid history filters'); } + $stmt = db()->prepare('SELECT h.id, j.client_id, j.reference_no, h.from_status, h.to_status, h.changed_at, u.name AS changed_by_name FROM jobcard_status_history h JOIN jobcards j ON j.id = h.jobcard_id LEFT JOIN users u ON u.id = h.changed_by WHERE j.client_id = :client ORDER BY h.changed_at ASC, h.id ASC'); $stmt->execute(['client' => $clientId]); + $history = (new \App\Domain\Reporting\ClientHistoryReport($filters))->build($stmt->fetchAll(), 'client'); + if (scalar_input($_GET['format'] ?? null) === 'print') { header('Content-Type: text/html; charset=UTF-8'); echo (new \PrintReportRenderer())->render('Client history', ['Reference', 'From', 'To', 'Changed'], array_map(static fn (array $row): array => [$row['reference_no'], $row['from_status'], $row['to_status'], $row['changed_at']], $history)); exit; } + render_header('Client history'); echo '
'; foreach ($history as $row) echo ''; echo '
JobcardFromToChanged
' . e($row['reference_no']) . '' . e($row['from_status']) . '' . e($row['to_status']) . '' . e($row['changed_at']) . '
'; render_footer(); exit; +} + +if ($route === 'time_entry') { + require_permission('time_entries.record'); + $entryId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); + $stmt = db()->prepare("SELECT t.*, j.client_id FROM time_entries t JOIN jobcards j ON j.id = t.jobcard_id WHERE t.id = :id AND NOT EXISTS (SELECT 1 FROM audit_events ae WHERE ae.entity_type = 'time_entry' AND ae.entity_id = t.id AND ae.action = 'time_entry_voided')"); $stmt->execute(['id' => $entryId]); $entry = $stmt->fetch(); + if (!$entry || !can_access_jobcard((int)$entry['jobcard_id']) || ($user['role_name'] === 'Technician' && (int)$entry['technician_id'] !== (int)$user['id'])) { http_response_code(404); exit('Time entry not found'); } + $errors = []; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $command = scalar_input($_POST['action'] ?? null); $auditAction = $command === 'void' ? 'time_entry_voided' : 'time_entry_corrected'; $validator = new \App\Domain\Jobcard\TimeEntryCorrectionCommand(); $changes = []; + foreach (['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'] as $field) if (array_key_exists($field, $_POST)) $changes[$field] = $_POST[$field]; + $result = $command === 'void' ? $validator->validateVoid($entry, ['reason' => $_POST['reason'] ?? null]) : $validator->validateCorrection($entry, $changes); $errors = array_values($result['errors']); if (!$errors) { if ($command === 'void') audit($auditAction, 'time_entry', $entryId, ['reason' => $result['void_reason']]); else { db()->prepare('UPDATE time_entries SET work_date = :date, start_time = :start, end_time = :end, hours = :hours, notes = :notes, counts_toward_sla = :sla WHERE id = :id')->execute(['date' => $result['entry']['work_date'], 'start' => $result['entry']['start_time'] ?? null, 'end' => $result['entry']['end_time'] ?? null, 'hours' => $result['entry']['hours'], 'notes' => $result['entry']['notes'] ?? null, 'sla' => !empty($result['entry']['counts_toward_sla']) ? 1 : 0, 'id' => $entryId]); audit($auditAction, 'time_entry', $entryId); } header('Location: /?route=jobcard&id=' . (int)$entry['jobcard_id'] . '&updated=1'); exit; } } + render_header('Time entry correction'); echo '

Correct or void time entry

' . ($errors ? '
' . e(implode(' ', $errors)) . '
' : '') . '
'; render_footer(); exit; +} + if ($route === 'jobcard') { require_permission('jobcards.view'); $jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); @@ -160,6 +197,7 @@ if ($route === 'jobcard') { $pdo->prepare('INSERT INTO jobcard_status_history (jobcard_id, from_status, to_status, changed_by) VALUES (:jobcard, :from_status, :to_status, :user)')->execute(['jobcard' => $jobcardId, 'from_status' => $locked['status'], 'to_status' => $to, 'user' => $user['id']]); audit('jobcard_status_changed', 'jobcard', $jobcardId, ['from' => $locked['status'], 'to' => $to]); $pdo->commit(); + try { $recipientStmt = db()->prepare('SELECT u.email FROM users u JOIN jobcard_assignments ja ON ja.user_id = u.id WHERE ja.jobcard_id = :jobcard AND u.is_active = 1'); $recipientStmt->execute(['jobcard' => $jobcardId]); $recipients = array_column($recipientStmt->fetchAll(), 'email'); if ($recipients) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'jobcard_status_changed', 'recipients' => $recipients, 'title' => 'Jobcard status changed', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' changed to ' . $to . '.', 'deduplication_key' => 'jobcard:' . $jobcardId . ':status:' . $to]); } catch (Throwable) { /* notification failure must not undo a committed status transition */ } header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Status update failed.'; } @@ -189,6 +227,7 @@ if ($route === 'jobcard') { $pdo->prepare('INSERT INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $technicianId, 'by_user' => $user['id']]); audit('jobcard_assigned', 'jobcard', $jobcardId, ['technician_id' => $technicianId]); $pdo->commit(); + try { $recipientStmt = db()->prepare('SELECT email FROM users WHERE id = :id AND is_active = 1'); $recipientStmt->execute(['id' => $technicianId]); $recipient = $recipientStmt->fetchColumn(); if ($recipient) (new \App\Domain\Notification\NotificationQueue())->enqueue(db(), ['type' => 'assignment_created', 'recipients' => [$recipient], 'title' => 'Jobcard assigned', 'body' => 'Jobcard ' . $jobcard['reference_no'] . ' was assigned to you.', 'deduplication_key' => 'assignment:' . $jobcardId . ':' . $technicianId]); } catch (Throwable) { /* notification failure must not undo a committed assignment */ } header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Assignment update failed.'; } } @@ -255,7 +294,7 @@ if ($route === 'jobcard') { $jobcardStmt->execute(['id' => $jobcardId]); $jobcard = $jobcardStmt->fetch(); $assignments = db()->prepare('SELECT u.id, u.name FROM jobcard_assignments a JOIN users u ON u.id = a.user_id WHERE a.jobcard_id = :id ORDER BY u.name'); $assignments->execute(['id' => $jobcardId]); $assigned = $assignments->fetchAll(); $technicians = db()->query("SELECT u.id, u.name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.is_active = 1 AND r.name = 'Technician' ORDER BY u.name")->fetchAll(); - $timeStmt = db()->prepare('SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id ORDER BY t.work_date DESC, t.id DESC'); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll(); + $timeStmt = db()->prepare("SELECT t.*, u.name AS technician_name FROM time_entries t JOIN users u ON u.id = t.technician_id WHERE t.jobcard_id = :id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = 'time_entry' AND av.entity_id = t.id AND av.action = 'time_entry_voided') ORDER BY t.work_date DESC, t.id DESC"); $timeStmt->execute(['id' => $jobcardId]); $timeEntries = $timeStmt->fetchAll(); $attachmentStmt = db()->prepare('SELECT id, original_name, mime_type, file_size, client_visible, created_at FROM attachments WHERE jobcard_id = :id ORDER BY created_at DESC'); $attachmentStmt->execute(['id' => $jobcardId]); $attachments = $attachmentStmt->fetchAll(); $totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries)); render_header('Jobcard ' . $jobcard['reference_no']); @@ -263,7 +302,7 @@ if ($route === 'jobcard') { echo '

Work requested

' . nl2br(e($jobcard['work_requested'])) . '

Work performed and notes

'; if (can('jobcards.internal_notes')) echo ''; echo '

Time entries

' . e(number_format($totalHours, 2)) . ' hours
'; - foreach ($timeEntries as $entry) echo '
' . e($entry['technician_name']) . ' · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h
' . e((string)($entry['notes'] ?? '')) . '
'; + foreach ($timeEntries as $entry) echo '
' . e($entry['technician_name']) . ' · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h
' . e((string)($entry['notes'] ?? '')) . '
' . (can('time_entries.record') ? 'Correct or void' : '') . '
'; if (can('time_entries.record')) { echo '
'; if ($user['role_name'] !== 'Technician') { echo '
'; } echo '
'; } echo '

Status

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

Add contact

'; echo '
'; if (can('sla.view') || can('sla.manage')) { @@ -474,6 +553,13 @@ if ($route === 'client') { if (can('credentials.manage')) { echo '

Add credential

'; } echo ''; } + if (can('technical.view') || can('technical.manage')) { + $technicalRows = (new \App\Domain\Credential\TechnicalInformationRepository(db()))->forClient($clientId); + echo '

Technical information

'; + foreach ($technicalRows as $technical) { $display = $technical['display']; echo '
' . e(ucfirst($technical['category'])) . '
' . e((string)$display['label']) . ($display['username'] ? ' · ' . e((string)$display['username']) : '') . '
' . nl2br(e((string)($display['notes'] ?? ''))) . '
'; } + if (can('technical.manage')) { echo '
'; } + echo '
'; + } if (can('clients.manage')) echo '

Edit client

'; render_footer(); exit; @@ -555,47 +641,56 @@ if ($route === 'users') { echo ''; render_footer(); exit; } -if (false) { - require_permission('users.manage'); - $userErrors = []; +if ($route === 'roles') { + require_permission('roles.manage'); + $roleErrors = []; if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); - $userInput = ['name' => $_POST['name'] ?? null, 'email' => $_POST['email'] ?? null, 'role_id' => $_POST['role_id'] ?? null, 'is_active' => '1', 'password' => $_POST['password'] ?? null]; - $validatedUser = (new \App\Domain\User\UserRecord())->validateForCreate($userInput); - $userErrors = $validatedUser['errors']; - if (!$userErrors) { - $roleCheck = db()->prepare('SELECT id FROM roles WHERE id = :id'); $roleCheck->execute(['id' => $validatedUser['role_id']]); - if (!$roleCheck->fetchColumn()) $userErrors['role_id'] = 'Selected role does not exist.'; - $emailCheck = db()->prepare('SELECT id FROM users WHERE email = :email'); $emailCheck->execute(['email' => $validatedUser['email']]); - if ($emailCheck->fetchColumn()) $userErrors['email'] = 'A user with this email already exists.'; - } - if (!$userErrors) { - $stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash, is_active) VALUES (:role, :email, :name, :hash, 1)'); - $stmt->execute(['role' => $validatedUser['role_id'], 'email' => $validatedUser['email'], 'name' => $validatedUser['name'], 'hash' => password_hash(scalar_input($_POST['password'] ?? null), PASSWORD_DEFAULT)]); - $newUserId = (int)db()->lastInsertId(); audit('user_created', 'user', $newUserId, ['email' => $validatedUser['email'], 'role_id' => $validatedUser['role_id']]); - header('Location: /?route=users&created=1'); exit; - } + $action = scalar_input($_POST['action'] ?? 'create'); + $roleId = filter_var(scalar_input($_POST['role_id'] ?? null), FILTER_VALIDATE_INT); + try { + $pdo = db(); $roleRecord = new \App\Domain\User\RoleRecord(); $matrix = new \App\Domain\User\PermissionMatrix(); + $available = array_column($pdo->query('SELECT name FROM permissions ORDER BY name')->fetchAll(), 'name'); + if ($action === 'create') { + $validated = (new \App\Domain\User\RolePermissionService())->validateForCreate($_POST, $available); $roleErrors = $validated['errors']; + if (!$roleErrors) { $stmt = $pdo->prepare('INSERT INTO roles (name, description) VALUES (:name, :description)'); $stmt->execute(['name' => $validated['name'], 'description' => $validated['description']]); $roleId = (int)$pdo->lastInsertId(); } + } else { + $roleStmt = $pdo->prepare('SELECT id, name, description FROM roles WHERE id = :id'); $roleStmt->execute(['id' => $roleId]); $role = $roleStmt->fetch(); + if (!$role) $roleErrors['role'] = 'Role not found.'; + else { $assignment = (new \App\Domain\User\RolePermissionService())->validateAssignment($role, is_array($_POST['permissions'] ?? null) ? $_POST['permissions'] : [], $available); $roleErrors = $assignment['errors']; if (!$roleErrors) { $pdo->beginTransaction(); $pdo->prepare('DELETE FROM role_permissions WHERE role_id = :role')->execute(['role' => $roleId]); $insert = $pdo->prepare('INSERT INTO role_permissions (role_id, permission_id) SELECT :role, id FROM permissions WHERE name = :name'); foreach ($assignment['permissions'] as $permission) $insert->execute(['role' => $roleId, 'name' => $permission]); $pdo->commit(); } } + } + if (!$roleErrors) { audit('role_updated', 'role', (int)$roleId); header('Location: /?route=roles&updated=1'); exit; } + } catch (Throwable $exception) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); $roleErrors['role'] = 'Role changes could not be saved.'; } } - $roles = db()->query('SELECT id, name FROM roles ORDER BY name')->fetchAll(); - $users = db()->query('SELECT u.id, u.name, u.email, u.is_active, u.last_login_at, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ORDER BY u.name')->fetchAll(); - render_header('Users'); - echo '

Users

Create and review system accounts.

' . (isset($_GET['created']) ? '
User created successfully.
' : '') . ($userErrors ? '
' . e(implode(' ', $userErrors)) . '
' : '') . '
Use upper/lowercase, number and symbol.
'; - foreach ($users as $listedUser) echo ''; - echo '
NameEmailRoleStatusLast login
' . e($listedUser['name']) . '' . e($listedUser['email']) . '' . e($listedUser['role_name']) . '' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '
'; render_footer(); exit; + $roles = db()->query('SELECT r.id, r.name, r.description, r.created_at, GROUP_CONCAT(p.name ORDER BY p.name SEPARATOR ", ") AS permission_names FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id LEFT JOIN permissions p ON p.id = rp.permission_id GROUP BY r.id, r.name, r.description, r.created_at ORDER BY r.name')->fetchAll(); + $permissions = db()->query('SELECT name, description FROM permissions ORDER BY name')->fetchAll(); render_header('Roles and permissions'); echo '

Roles and permissions

Create custom roles and assign available permissions.

' . ($roleErrors ? '
' . e(implode(' ', $roleErrors)) . '
' : '') . (isset($_GET['updated']) ? '
Role changes saved.
' : '') . '

Create custom role

'; + foreach ($roles as $role) { echo '

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

' . e((string)($role['description'] ?? '')) . '

'; $assigned = $role['permission_names'] ? explode(', ', $role['permission_names']) : []; foreach ($permissions as $permission) echo '
'; echo '
'; } + render_footer(); exit; +} + +if ($route === 'notifications') { + require_permission('notifications.view'); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { verify_csrf(); $notificationId = filter_var(scalar_input($_POST['notification_id'] ?? null), FILTER_VALIDATE_INT); try { if (!(new \App\Domain\Notification\NotificationQueue())->markRead(db(), (int)$user['id'], ['notification_id' => $notificationId])) { http_response_code(404); exit('Notification not found'); } audit('notification_read', 'notification', (int)$notificationId); header('Location: /?route=notifications&read=1'); exit; } catch (Throwable $exception) { http_response_code(400); exit('Invalid notification'); } } + $stmt = db()->prepare('SELECT id, type, title, body, read_at, created_at FROM notifications WHERE user_id = :user ORDER BY created_at DESC LIMIT 100'); $stmt->execute(['user' => $user['id']]); $notifications = $stmt->fetchAll(); render_header('Notifications'); echo '

Notifications

'; foreach ($notifications as $notification) { echo '
' . e($notification['title']) . '' . e($notification['created_at']) . '

' . e((string)($notification['body'] ?? '')) . '

'; if (!$notification['read_at']) echo '
'; echo '
'; } render_footer(); exit; } if ($route === 'reports') { require_permission('reports.view'); $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); - if ($user['role_name'] === 'Technician') { - $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN te.technician_id = :user THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name'); - $reportStmt->execute(['user' => $user['id'], 'user_assigned' => $user['id']]); - $reportRows = $reportStmt->fetchAll(); - } else { - $reportRows = db()->query('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(te.hours), 0) AS hours FROM clients c LEFT JOIN jobcards j ON j.client_id = c.id LEFT JOIN time_entries te ON te.jobcard_id = j.id GROUP BY c.id, c.name ORDER BY c.name')->fetchAll(); - } + try { $filters = \ReportFilters::fromArray($_GET); } catch (Throwable $exception) { http_response_code(400); exit('Invalid report filters'); } + $reportParams = ['client_id' => $filters->clientId ?? 0, 'status' => $filters->status ?? '', 'status_filter' => $filters->status ?? '', 'priority' => $filters->priority ?? '', 'priority_filter' => $filters->priority ?? '', 'date_from_a' => $filters->dateFrom ?? '', 'date_from_b' => $filters->dateFrom ?? '', 'date_to_a' => $filters->dateTo ?? '', 'date_to_b' => $filters->dateTo ?? '']; + $reportScope = $user['role_name'] === 'Technician' ? 'JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user_assigned' : ''; + $reportParams['user_assigned'] = $user['id']; + $hoursCondition = $user['role_name'] === 'Technician' ? 'te.technician_id = :user' : '1 = 1'; + $reportParams['user'] = $user['id']; + $reportStmt = db()->prepare('SELECT c.id AS client_id, c.name AS client_name, COUNT(DISTINCT j.id) AS jobcards, COALESCE(SUM(CASE WHEN ' . $hoursCondition . ' AND (:date_from_a = "" OR te.work_date >= :date_from_b) AND (:date_to_a = "" OR te.work_date <= :date_to_b) THEN te.hours ELSE 0 END), 0) AS hours FROM clients c JOIN jobcards j ON j.client_id = c.id ' . $reportScope . ' LEFT JOIN time_entries te ON te.jobcard_id = j.id AND NOT EXISTS (SELECT 1 FROM audit_events av WHERE av.entity_type = "time_entry" AND av.entity_id = te.id AND av.action = "time_entry_voided") WHERE (:client_id = 0 OR c.id = :client_filter) AND (:status = "" OR j.status = :status_filter) AND (:priority = "" OR j.priority = :priority_filter) GROUP BY c.id, c.name ORDER BY c.name'); + $reportParams['client_filter'] = $filters->clientId ?? 0; + $reportStmt->execute($reportParams); + $reportRows = $reportStmt->fetchAll(); $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); + $filterQuery = http_build_query(array_filter(['client_id' => $filters->clientId, 'date_from' => $filters->dateFrom, 'date_to' => $filters->dateTo, 'technician_id' => $filters->technicianId, 'status' => $filters->status, 'priority' => $filters->priority], static fn($value): bool => $value !== null && $value !== '')); + if ($format === 'print') { header('Content-Type: text/html; charset=UTF-8'); header('Cache-Control: no-store'); echo (new \PrintReportRenderer())->render('Hours per client', ['Client', 'Jobcards', 'Hours'], $rows); exit; } if ($format === 'csv') { $csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true); header('Content-Type: text/csv; charset=UTF-8'); @@ -605,18 +700,19 @@ if ($route === 'reports') { exit; } render_header('Reports'); - echo '

Reports

Internal hours summary by client.

'; - if (can('reports.export')) echo 'Export CSV'; - echo '
'; + echo '

Reports

Internal hours summary by client.

Print view'; + if (can('reports.export')) echo 'Export CSV'; + echo '
ClientJobcardsHours
'; if (!$reportRows) echo ''; foreach ($reportRows as $row) echo ''; echo '
ClientJobcardsHours
No report data available.
' . e($row['client_name']) . '' . (int)$row['jobcards'] . '' . e(number_format((float)$row['hours'], 2)) . '
'; render_footer(); exit; } -if (isset($permissionByRoute[$route])) { - require_permission($permissionByRoute[$route]); - render_header(ucfirst($route)); ?>

This module is scaffolded for the next implementation phase.

The route is permission-protected and ready for its domain workflow.
query('SELECT a.id, a.action, a.entity_type, a.entity_id, a.metadata, a.ip_address, a.created_at, u.name AS user_name FROM audit_events a LEFT JOIN users u ON u.id = a.user_id ORDER BY a.created_at DESC, a.id DESC LIMIT 200'); + render_header('Audit trail'); echo '

Audit trail

'; foreach ($stmt->fetchAll() as $event) echo ''; echo '
WhenUserActionEntityMetadata
' . e($event['created_at']) . '' . e((string)($event['user_name'] ?? 'System')) . '' . e($event['action']) . '' . e($event['entity_type']) . ' #' . (int)$event['entity_id'] . '' . e((string)($event['metadata'] ?? '')) . '
'; render_footer(); exit; } http_response_code(404); render_header('Not found'); ?>
Page not found.
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], +]; +$contact = new ContactEditCommand(); + +$badEdit = $contact->validateEdit('not-an-id', ['client_id' => 7, 'name' => 'John'], $contacts); +regression_assert($badEdit['valid'] === false && isset($badEdit['errors']['id']), 'Malformed contact IDs must return validation errors.'); +$move = $contact->validateEdit(2, ['client_id' => 8, 'name' => 'John'], $contacts); +regression_assert($move['valid'] === false && isset($move['errors']['client_id']), 'A contact client ID must be immutable during edit.'); +$sole = $contact->validateDelete('2', [['id' => 2, 'client_id' => 7, 'is_primary' => true]]); +regression_assert($sole['valid'] === false && isset($sole['errors']['delete']), 'The sole contact must not be deletable.'); +$badPrimary = $contact->setPrimary('0', $contacts); +regression_assert($badPrimary['valid'] === false && isset($badPrimary['errors']['id']), 'Malformed primary IDs must return validation errors.'); +$primary = $contact->setPrimary(2, $contacts); +regression_assert($primary['valid'] === true && $primary['replace_primary_contact_ids'] === [1] && $primary['audit']['event'] === 'client_contact_primary_set', 'Set-primary must nominate demotions and expose an audit payload.'); + +$history = new ClientHistoryService(); +$badHistory = $history->validateForClient('7x', [['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']]); +regression_assert($badHistory['valid'] === false && isset($badHistory['errors']['client_id']), 'Malformed history client IDs must return validation errors.'); +$timeline = $history->timeline([['id' => 2, 'client_id' => 7, 'changed_at' => '2026-09-02'], ['id' => 1, 'client_id' => 7, 'changed_at' => '2026-09-01']], 7); +regression_assert($timeline['valid'] === true && array_column($timeline['timeline'], 'id') === [1, 2], 'Client history timeline must be deterministic and controller-ready.'); + +$entry = ['id' => 9, 'jobcard_id' => 12, 'technician_id' => 4, 'work_date' => '2026-09-01', 'hours' => 2, 'notes' => 'old', 'counts_toward_sla' => true, 'voided' => false]; +$correction = new TimeEntryCorrectionCommand(); +$immutable = $correction->validateCorrection($entry, ['technician_id' => 99]); +regression_assert($immutable['valid'] === false && isset($immutable['errors']['technician_id']), 'Technician ID must be immutable during correction.'); +$fixed = $correction->validateCorrection($entry, ['hours' => '3.25', 'notes' => ' corrected ']); +regression_assert($fixed['valid'] === true && $fixed['audit']['changed_fields'] === ['hours', 'notes'], 'Correction must expose changed fields for audit.'); +$missingReason = $correction->validateVoid($entry, []); +regression_assert($missingReason['valid'] === false && isset($missingReason['errors']['reason']), 'Void reason is mandatory.'); +$void = $correction->validateVoid($entry, ['reason' => 'Duplicate entry']); +regression_assert($void['valid'] === true && $void['audit']['void_reason'] === 'Duplicate entry', 'Void must expose the normalized reason in its audit payload.'); + +printf("Domain workflow regression tests: 10 passed\n"); diff --git a/tests/FinalScopeIntegrationTest.php b/tests/FinalScopeIntegrationTest.php new file mode 100644 index 0000000..acef71d --- /dev/null +++ b/tests/FinalScopeIntegrationTest.php @@ -0,0 +1,159 @@ +validate(['category' => ' domain ', 'label' => ' Office DNS ', 'username' => ' admin ', 'notes' => ' managed ']); +final_scope_same(true, $technicalResult['valid'], 'Valid technical information should survive the final-scope path.'); +final_scope_same('domain', $technicalResult['category'], 'Technical information category should be canonical.'); +$contacts = new ContactEditCommand(); +$existingContacts = [ + ['id' => 10, 'client_id' => 7, 'name' => 'Primary', 'email' => 'primary@example.com', 'is_primary' => true], + ['id' => 12, 'client_id' => 7, 'name' => 'Backup', 'email' => 'backup@example.com', 'is_primary' => false], + ['id' => 20, 'client_id' => 8, 'name' => 'Other client', 'email' => 'other@example.com', 'is_primary' => true], +]; +$edit = $contacts->validateEdit(12, ['client_id' => 7, 'name' => ' Backup 2 ', 'email' => 'BACKUP2@example.com'], $existingContacts); +final_scope_same(true, $edit['valid'], 'A valid contact edit should be accepted.'); +final_scope_same('Backup 2', $edit['name'], 'Contact edit should normalize the name.'); +$primary = $contacts->validatePrimary(12, $existingContacts); +final_scope_same([10], $primary['replace_primary_contact_ids'], 'Promoting a contact must identify only same-client primary contacts.'); +$deletePrimary = $contacts->validateDelete(10, $existingContacts); +final_scope_same(12, $deletePrimary['replacement_primary_contact_id'], 'Deleting a primary contact must select the lowest same-client replacement.'); +$deleteOnly = $contacts->validateDelete(20, $existingContacts); +final_scope_assert(!$deleteOnly['valid'] && isset($deleteOnly['errors']['delete']), 'The sole contact for a client must not be deletable.'); +$checks += 4; + +// Time correction is immutable-by-default: IDs/ownership stay fixed, voids need reasons. +$correction = new TimeEntryCorrectionCommand(); +$existingEntry = ['id' => 31, 'jobcard_id' => 44, 'technician_id' => 9, 'work_date' => '2026-09-01', 'hours' => 1.0, 'notes' => 'old', 'counts_toward_sla' => true]; +$corrected = $correction->validateCorrection($existingEntry, ['hours' => '2.25', 'notes' => ' corrected ']); +final_scope_same(true, $corrected['valid'], 'A valid time correction should be accepted.'); +final_scope_same(31, $corrected['id'], 'Time correction must retain the original entry ID.'); +final_scope_same(44, $corrected['entry']['jobcard_id'], 'Time correction must not move an entry to another jobcard.'); +final_scope_same(2.25, $corrected['entry']['hours'], 'Time correction should use the canonical time-entry calculation.'); +$changedOwner = $correction->validateCorrection($existingEntry, ['technician_id' => 10]); +final_scope_assert(!$changedOwner['valid'] && isset($changedOwner['errors']['technician_id']), 'Time correction must reject ownership changes.'); +$voided = $correction->validateVoid($existingEntry, ['reason' => ' Duplicate entry ']); +final_scope_same(true, $voided['valid'], 'A void command with a reason should be accepted.'); +final_scope_same('Duplicate entry', $voided['void_reason'], 'Void reasons should be trimmed and retained for audit.'); +$missingReason = $correction->validateVoid($existingEntry); +final_scope_assert(!$missingReason['valid'] && isset($missingReason['errors']['reason']), 'Voiding without a reason must be rejected.'); +$alreadyVoided = $correction->validateCorrection([...$existingEntry, 'voided' => true], ['hours' => 2]); +final_scope_assert(!$alreadyVoided['valid'] && isset($alreadyVoided['errors']['voided']), 'Voided entries must not be corrected.'); +$checks += 6; + +// Custom roles and permission assignments are normalized, allow-listed, and protect Administrator. +$roles = new RoleRecord(); +$role = $roles->validate(['name' => ' Dispatch ', 'description' => ' Dispatch team ']); +final_scope_same(true, $role['valid'], 'A valid custom role should be accepted.'); +final_scope_assert(!$roles->canDelete(['name' => 'Administrator']) && $roles->canRename(['name' => 'Dispatch'], 'Operations'), 'Administrator safeguards and custom-role actions must coexist.'); +$permissions = (new PermissionMatrix())->normalize([' clients.view ', 'clients.view', 'reports.view']); +final_scope_same(['clients.view', 'reports.view'], $permissions, 'Role permissions should be canonical and deduplicated.'); +$checks += 2; + +// Notification event -> per-user queue DTO preserves recipient isolation and deduplication. +$records = new NotificationRecord(); +$event = $records->statusChanged(['jobcard_id' => 44, 'to_status' => 'closed', 'recipients' => ['A@example.com', 'B@example.com']]); +final_scope_same('jobcard:44:status:closed', $event['deduplication_key'], 'Status notifications need stable deduplication keys.'); +$queueDto = (new NotificationQueue())->mapForUser([...$event, 'title' => 'Closed'], 'b@example.com'); +final_scope_same('b@example.com', $queueDto['recipient'], 'Notification queue DTOs must target exactly one normalized user.'); +final_scope_same('jobcard_status_changed', $queueDto['type'], 'Queue DTOs must preserve event type.'); +$validation = $records->validate(['type' => 'assignment_created', 'recipients' => ['not-an-email'], 'deduplication_key' => 'x']); +final_scope_assert(!$validation['valid'] && isset($validation['errors']['recipients']), 'Invalid notification recipients must never enter the queue.'); +$checks += 3; + +// Report audience separation: client projection omits technician/internal fields; internal retains attribution. +$rows = [['id' => 1, 'client_id' => 7, 'client_name' => 'Acme', 'reference_no' => 'JC-44', 'status' => 'open', 'priority' => 'high', 'created_at' => '2026-09-01', 'technician_id' => 9, 'technician_name' => 'Tech', 'internal_notes' => 'private', 'credentials' => 'secret']]; +$clientReport = (new ClientJobcardReport(ReportFilters::fromArray([])))->build($rows, 'client'); +final_scope_assert(!array_key_exists('internal_notes', $clientReport[0]) && !array_key_exists('credentials', $clientReport[0]) && !array_key_exists('technician_id', $clientReport[0]), 'Client reports must exclude internal notes, credentials and technician identifiers.'); +$internalActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'internal'); +$clientActivity = (new TechnicianActivityReport())->build([['technician_id' => 9, 'technician_name' => 'Tech', 'client_id' => 7, 'client_name' => 'Acme', 'hours' => 2, 'counts_toward_sla' => true]], 'client'); +final_scope_assert(array_key_exists('technician_id', $internalActivity[0]) && !array_key_exists('technician_id', $clientActivity[0]), 'Report audience must separate internal technician attribution from client output.'); +$checks += 2; + +// Dynamic boundaries plus route-level contracts for technician scope, CSRF, attachments and credentials. +$attachment = (new AttachmentValidator(1000))->validate(['name' => 'proof.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 100]); +final_scope_same(true, $attachment['valid'], 'A valid attachment should pass metadata validation.'); +$vault = new CredentialVault(base64_encode(random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES))); +$stored = $vault->encryptCredential(['id' => 3, 'category' => 'hosting', 'label' => 'Production', 'username' => 'deploy', 'secret' => 'canary-secret']); +final_scope_assert(!array_key_exists('secret', $stored) && isset($stored['secret_ciphertext']) && !str_contains(serialize($stored), 'canary-secret'), 'Credential storage must cross the ciphertext boundary.'); +final_scope_assert(preg_match('/function can_access_jobcard\(int \$jobcardId\).*?jobcard_id = :jobcard.*?user_id = :user/s', $bootstrap) === 1, 'Technician jobcard access must be scoped by authenticated user.'); +final_scope_assert(preg_match('/SELECT DISTINCT c\.id, c\.name.*?ja\.user_id = :user/s', $frontController) === 1, 'Technician client lists must be scoped by assignment.'); +final_scope_assert(substr_count($frontController, 'verify_csrf();') >= 7, 'State-changing routes must use the central CSRF guard.'); +final_scope_assert(str_contains($frontController, "if ((\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST')") && str_contains($frontController, "if (\$route === 'logout')"), 'Logout must be POST-only as well as CSRF-protected.'); +final_scope_assert(str_contains($frontController, "header('X-Content-Type-Options: nosniff')") && str_contains($frontController, "basename(\$attachment['stored_name'])"), 'Attachment downloads must use safe names and nosniff.'); +final_scope_assert(str_contains($frontController, 'WHERE id = :id AND client_id = :client AND is_active = 1') && str_contains($frontController, "header('Cache-Control: no-store"), 'Credential reveal must bind client ownership and disable caching.'); +$checks += 7; + +// Healthcheck contract: every schema table is probed and only statuses are formatted. +final class FinalScopeFakePdo extends PDO +{ + /** @var list */ + public array $queries = []; + public function __construct() {} + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false + { + $this->queries[] = $query; + return false; + } +} +$fakePdo = new FinalScopeFakePdo(); +final_scope_assert(deployment_check_schema($fakePdo), 'Healthcheck should probe the required schema without leaking exceptions.'); +$probed = array_map(static fn(string $query): string => trim(str_replace(['SELECT 1 FROM', '`', 'LIMIT 1'], '', $query)), $fakePdo->queries); +preg_match_all('/CREATE TABLE IF NOT EXISTS ([a-z0-9_]+)/i', $schema, $matches); +$schemaTables = array_values(array_unique(array_map('strtolower', $matches[1]))); +final_scope_same($schemaTables, $probed, 'Production healthcheck must cover exactly the current schema tables.'); +$formatted = deployment_format_check_report(['DB_PASSWORD' => true, 'schema' => false]); +final_scope_same(['DB_PASSWORD' => 'OK', 'schema' => 'FAIL'], $formatted, 'Healthcheck output must contain statuses, not values.'); +$checks += 2; + +printf("Final-scope integration tests: %d passed\n", $checks); diff --git a/tests/NotificationContractsTest.php b/tests/NotificationContractsTest.php new file mode 100644 index 0000000..5d0bbb5 --- /dev/null +++ b/tests/NotificationContractsTest.php @@ -0,0 +1,34 @@ +toDisplay([ + 'type' => 'assignment_created', + 'recipient' => 'tech@example.com', + 'title' => '', + 'body' => 'unsafe', + 'deduplication_key' => 'assignment:42', +]); +notification_contract_assert('', $display['title'], 'Safe display should preserve text as data, not execute or reinterpret it.'); +notification_contract_assert('unsafe', $display['body'], 'Safe display should expose body as text data.'); +notification_contract_assert(false, $display['is_read'], 'Display should default missing read_at to unread.'); + +notification_contract_assert(true, $record->validateMarkUnread(['notification_id' => '12'])['valid'], 'Unread command should accept a positive notification ID.'); +notification_contract_assert(true, method_exists($queue, 'markUnread'), 'Queue should expose a mark-unread command.'); + +printf("Notification contract tests: 5 passed\n"); diff --git a/tests/NotificationPersistenceTest.php b/tests/NotificationPersistenceTest.php new file mode 100644 index 0000000..243c700 --- /dev/null +++ b/tests/NotificationPersistenceTest.php @@ -0,0 +1,77 @@ +params = $params ?? []; + if (str_starts_with($this->sql, 'SELECT')) { + $this->pdo->selectedEmail = (string)($this->params['email'] ?? ''); + return true; + } + if (str_starts_with($this->sql, 'INSERT') && $this->pdo->failOnInsert === $this->params['user']) { + throw new RuntimeException('simulated partial failure'); + } + if (str_starts_with($this->sql, 'UPDATE')) { + $this->pdo->updateParams = $this->params; + } + return true; + } + public function fetchColumn(int $column = 0): mixed + { + return $this->pdo->users[$this->pdo->selectedEmail] ?? false; + } + public function rowCount(): int { return $this->pdo->updateRowCount; } +} + +final class NotificationFakePdo extends PDO +{ + public array $users = ['one@example.com' => 1, 'two@example.com' => 2]; + public ?string $selectedEmail = null; + public mixed $failOnInsert = null; + public int $updateRowCount = 1; + public array $updateParams = []; + public bool $rolledBack = false; + public bool $inTxn = false; + public function __construct() {} + public function beginTransaction(): bool { $this->inTxn = true; return true; } + public function inTransaction(): bool { return $this->inTxn; } + public function rollBack(): bool { $this->rolledBack = true; $this->inTxn = false; return true; } + public function commit(): bool { $this->inTxn = false; return true; } + public function prepare(string $query, array $options = []): PDOStatement|false { return new NotificationFakeStatement($this, $query); } + public function lastInsertId(?string $name = null): string { return '1'; } +} + +function notification_persistence_assert(bool $condition, string $message): void +{ + if (!$condition) throw new RuntimeException($message); +} + +$pdo = new NotificationFakePdo(); +$pdo->failOnInsert = 2; +$queue = new NotificationQueue(); +try { + $queue->enqueue($pdo, [ + 'type' => 'assignment_created', 'recipients' => ['one@example.com', 'two@example.com'], + 'title' => 'Assigned', 'body' => 'Jobcard assigned', 'deduplication_key' => 'assignment:42', + ]); + throw new RuntimeException('Expected the simulated second-recipient failure.'); +} catch (RuntimeException $error) { + notification_persistence_assert($error->getMessage() === 'simulated partial failure', 'The simulated partial failure should be surfaced.'); +} +notification_persistence_assert($pdo->rolledBack, 'A partial recipient failure must roll back the whole queue transaction.'); + +notification_persistence_assert($queue->markRead($pdo, 7, ['notification_id' => '9']), 'Mark-read should update a user-owned row.'); +notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-read must use schema-aligned id and user_id predicates.'); +notification_persistence_assert($queue->markUnread($pdo, '7', ['id' => '9']), 'Mark-unread should update a user-owned row.'); +notification_persistence_assert($pdo->updateParams === ['id' => 9, 'user' => 7], 'Mark-unread must use schema-aligned id and user_id predicates.'); + +printf("Notification persistence tests: 6 passed\n"); diff --git a/tests/ReportServicesFocusedTest.php b/tests/ReportServicesFocusedTest.php new file mode 100644 index 0000000..f0b7e4e --- /dev/null +++ b/tests/ReportServicesFocusedTest.php @@ -0,0 +1,62 @@ + '2026-09-01', 'date_to' => '2026-09-30', 'client_id' => 7, + 'technician_id' => 4, 'status' => 'open', 'priority' => 'high', 'sla' => 'warning', +]); +$entries = [ + ['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-10', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.25], + ['client_id' => 7, 'client_name' => 'Acme', 'technician_id' => 4, 'technician_name' => 'Tess', 'work_date' => '2026-09-11', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 1.75], + ['client_id' => 8, 'client_name' => 'Beta', 'technician_id' => 4, 'work_date' => '2026-09-12', 'status' => 'open', 'priority' => 'high', 'sla_status' => 'warning', 'hours' => 9], +]; +report_services_assert_same( + [['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0]], + (new HoursPerClientReport($filters))->build($entries, ReportAudience::CLIENT), + 'Hours-per-client should apply the common filters before aggregation and return a client-safe projection.' +); + +$workload = (new TechnicianWorkloadReport($filters))->build($entries, ReportAudience::INTERNAL); +report_services_assert_same( + [['technician_id' => 4, 'technician_name' => 'Tess', 'hours' => 3.0, 'sla_hours' => 0.0]], + $workload, + 'Technician workload should aggregate filtered hours with deterministic internal fields.' +); +$clientWorkload = (new TechnicianWorkloadReport())->build($entries, ReportAudience::CLIENT); +report_services_assert_same( + [['client_id' => 7, 'client_name' => 'Acme', 'hours' => 3.0, 'sla_hours' => 0.0], ['client_id' => 8, 'client_name' => 'Beta', 'hours' => 9.0, 'sla_hours' => 0.0]], + $clientWorkload, + 'Client workload rows should preserve client attribution without technician identity.' +); + +$filtered = (new SlaReport(ReportFilters::fromArray(['sla' => 'critical'])))->build([ + ['client_id' => 1, 'client_name' => 'Acme', 'allocated_hours' => 10, 'hours' => [9]], + ['client_id' => 2, 'client_name' => 'Beta', 'allocated_hours' => 10, 'hours' => [2]], +], ReportAudience::CLIENT); +report_services_assert_same(1, count($filtered), 'SLA status filtering should use the computed usage status.'); +report_services_assert_same(1, $filtered[0]['client_id'], 'SLA status filtering should retain the critical agreement.'); + +$html = (new PrintReportRenderer())->renderRecords('Rows', [ + ['name' => 'Acme', 'hours' => 3.0], +]); +if (!str_contains($html, 'application/pdf') || !str_contains($html, '<b>Acme</b>') || !str_contains($html, '@page')) { + throw new RuntimeException('Print renderer should emit PDF-ready metadata, print CSS, and escaped record cells.'); +} + +printf("Focused report service tests: 5 passed\n"); diff --git a/tests/RolePermissionTest.php b/tests/RolePermissionTest.php index cdcf2f8..47c8dd9 100644 --- a/tests/RolePermissionTest.php +++ b/tests/RolePermissionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../app/Domain/User/RoleRecord.php'; require_once __DIR__ . '/../app/Domain/User/PermissionMatrix.php'; +require_once __DIR__ . '/../app/Domain/User/RolePermissionService.php'; use App\Domain\User\PermissionMatrix; use App\Domain\User\RoleRecord; @@ -64,6 +65,23 @@ role_permission_assert_throws( role_permission_assert_same(true, $roles->canRename(['name' => 'Accounts'], 'Support'), 'Custom roles should be renameable.'); role_permission_assert_same(false, $roles->canDelete(['name' => 'Administrator']), 'Administrator deletion safeguard should be queryable.'); +// ID 1 is authoritative for the protected role; current name is not required. +$adminEdit = (new App\Domain\User\RolePermissionService())->validateEdit(1, [ + 'name' => 'Renamed Administrator', 'description' => 'changed', 'permissions' => ['clients.view'], +]); +role_permission_assert_same(false, $adminEdit['valid'], 'Role ID 1 must remain protected without current_name.'); +if (!isset($adminEdit['errors']['role'])) throw new RuntimeException('Administrator rename/permission changes must be rejected from ID alone.'); +role_permission_assert_same(false, $roles->canDelete(['id' => 1]), 'Role ID 1 must not be deletable without a name.'); + +$customCreate = (new App\Domain\User\RolePermissionService())->validateForCreate([ + 'name' => ' Dispatch ', 'description' => ' Handles dispatch ', 'permissions' => ['CLIENTS.VIEW'], +], ['clients.view']); +role_permission_assert_same(true, $customCreate['valid'], 'Custom role create DTO should validate and normalize permissions.'); +role_permission_assert_same('Dispatch', $customCreate['name'], 'Custom role names should be normalized in create DTOs.'); +role_permission_assert_same(['clients.view'], $customCreate['permissions'], 'Create DTO should include canonical permissions.'); +$customDelete = (new App\Domain\User\RolePermissionService())->validateDelete(4, ['id' => 4, 'name' => 'Dispatch']); +role_permission_assert_same(['valid' => true, 'id' => 4, 'errors' => []], $customDelete, 'Custom role delete DTO should be safe and controller-ready.'); + $display = $roles->display([ 'id' => 4, 'name' => 'Support Team', diff --git a/tests/SecurityRegressionTest.php b/tests/SecurityRegressionTest.php index 0563a03..701a9b0 100644 --- a/tests/SecurityRegressionTest.php +++ b/tests/SecurityRegressionTest.php @@ -51,7 +51,7 @@ $checks++; // The technician report route must sum only the logged-in technician's entries, // even when an assigned jobcard contains entries recorded by other technicians. security_regression_assert( - preg_match('/role_name.*?Technician.*?SUM\(CASE WHEN te\.technician_id = :user THEN te\.hours ELSE 0 END\).*?ja\.user_id = :user_assigned/s', $frontController) === 1, + str_contains($frontController, "\$hoursCondition = \$user['role_name'] === 'Technician'") && str_contains($frontController, 'te.technician_id = :user'), 'Technician report SQL must isolate hours to the authenticated technician.' ); $activity = (new TechnicianActivityReport())->build([ diff --git a/tests/TechnicalInformationCommandTest.php b/tests/TechnicalInformationCommandTest.php new file mode 100644 index 0000000..86267ea --- /dev/null +++ b/tests/TechnicalInformationCommandTest.php @@ -0,0 +1,65 @@ + ['label' => 'Microsoft 365', 'tenant' => 'example.onmicrosoft.com', 'product' => 'Business Premium'], + 'network' => ['label' => 'Core network', 'hostname' => 'core-sw-01', 'ip_address' => '192.0.2.10', 'vlan' => 20], + 'router' => ['label' => 'Edge router', 'hostname' => 'edge-01', 'ip_address' => '192.0.2.1', 'model' => 'RB5009'], + 'infrastructure' => ['label' => 'Production server', 'hostname' => 'app-01', 'role' => 'application'], +] as $category => $data) { + $result = $command->validate(['client_id' => 7, 'category' => $category, 'data' => $data]); + technical_command_assert_same(true, $result['valid'], "{$category} information should validate."); + technical_command_assert_same($category, $result['record']['category'], 'Category should be retained in the normalized record.'); + technical_command_assert_same($data['label'], $result['record']['data']['label'], 'Structured category data should be retained.'); +} + +$invalid = $command->validate(['client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Router', 'ip_address' => 'not-an-ip', 'unexpected' => 'secret']]); +if ($invalid['valid'] || !isset($invalid['errors']['data.ip_address'], $invalid['errors']['data.unexpected'])) { + throw new RuntimeException('Structured technical information must reject invalid and unknown fields.'); +} + +$existing = ['id' => 41, 'client_id' => 7, 'category' => 'network', 'data' => ['label' => 'Core', 'hostname' => 'sw-01', 'vlan' => 10]]; +$edit = $command->validateEdit($existing, ['data' => ['vlan' => '20', 'notes' => ' Updated ']]); +technical_command_assert_same(true, $edit['valid'], 'A technical-information edit should validate merged data.'); +technical_command_assert_same(20, $edit['record']['data']['vlan'], 'Edit should normalize structured values.'); +technical_command_assert_same('Updated', $edit['record']['data']['notes'], 'Edit should trim text values.'); +$delete = $command->validateDelete($existing); +technical_command_assert_same(['valid' => true, 'action' => 'delete', 'id' => 41, 'client_id' => 7, 'category' => 'network', 'errors' => []], $delete, 'Delete validation should return an adapter-ready action.'); + +$projection = $command->display(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01', 'password' => 'do-not-show', 'secret' => 'do-not-show']]); +technical_command_assert_same(['id' => 41, 'client_id' => 7, 'category' => 'router', 'data' => ['label' => 'Edge', 'hostname' => 'edge-01']], $projection, 'Display projection must allow-list safe structured fields.'); + +final class TechnicalInformationCommandFakePdo extends PDO +{ + public function __construct() {} + public function prepare(string $query, array $options = []): PDOStatement|false + { + return new class($query) extends PDOStatement { + public function __construct(private string $query) {} + public function execute(?array $params = null): bool { return true; } + public function fetch(int $mode = PDO::FETCH_DEFAULT, int ...$args): mixed + { + return ['id' => 55, 'client_id' => 7, 'category' => 'microsoft', 'data_json' => '{"label":"M365","tenant":"example.onmicrosoft.com"}', 'updated_by' => null, 'created_at' => null, 'updated_at' => null]; + } + }; + } +} +$repository = new TechnicalInformationRepository(new TechnicalInformationCommandFakePdo()); +$stored = (new TechnicalInformationCommand($repository))->create(7, ['category' => 'microsoft', 'data' => ['label' => 'M365', 'tenant' => 'example.onmicrosoft.com']]); +technical_command_assert_same(55, $stored['id'], 'Command should remain compatible with the repository adapter.'); + +printf("Technical information command tests: 9 passed\n"); diff --git a/tests/UserAdminServiceTest.php b/tests/UserAdminServiceTest.php index f29de04..a5cd659 100644 --- a/tests/UserAdminServiceTest.php +++ b/tests/UserAdminServiceTest.php @@ -62,6 +62,15 @@ if (!isset($weakReset['errors']['password'])) throw new RuntimeException('Weak p $payloadReset = $users->validateReset(['password' => 'Unique&Secure123']); user_admin_assert_same(true, $payloadReset['valid'], 'Password-only reset payloads should be supported.'); +// Administrator identity is authoritative from the immutable user ID, even when +// a controller passes only editable fields and omits current role/name fields. +$protectedEdit = $users->validateEdit(1, [ + 'name' => 'Renamed', 'email' => 'admin.updated@example.test', 'role_id' => 2, 'is_active' => true, +]); +user_admin_assert_same(false, $protectedEdit['valid'], 'User ID 1 must remain protected without current fields.'); +if (!isset($protectedEdit['errors']['role_id'])) throw new RuntimeException('Administrator reassignment must be rejected from ID alone.'); +user_admin_assert_same(false, $users->validateDeactivate(['id' => 1, 'is_active' => true])['valid'], 'Administrator deactivation must be rejected from ID alone.'); + $roles = new RolePermissionService(); $available = ['clients.view', 'clients.manage', 'reports.view']; $assignment = $roles->validateAssignment(['id' => 4, 'name' => 'Support'], [' CLIENTS.VIEW ', 'reports.view', 'clients.view'], $available);