diff --git a/app/Domain/Jobcard/AssignmentValidator.php b/app/Domain/Jobcard/AssignmentValidator.php new file mode 100644 index 0000000..4b9b8fc --- /dev/null +++ b/app/Domain/Jobcard/AssignmentValidator.php @@ -0,0 +1,58 @@ +, errors: array} + */ + public function validate(array $payload): array + { + $errors = []; + $jobcardId = $this->positiveInteger($payload['jobcard_id'] ?? null); + if ($jobcardId === null) { + $errors['jobcard_id'] = 'Jobcard ID must be a positive integer.'; + } + + $userIds = []; + $assignments = $payload['user_ids'] ?? null; + if (!is_array($assignments) || $assignments === []) { + $errors['user_ids'] = 'At least one technician ID is required.'; + } else { + foreach ($assignments as $userId) { + $normalized = $this->positiveInteger($userId); + if ($normalized === null) { + $errors['user_ids'] = 'Technician IDs must be positive integers.'; + continue; + } + if (!in_array($normalized, $userIds, true)) { + $userIds[] = $normalized; + } + } + } + + return [ + 'valid' => $errors === [], + 'jobcard_id' => $jobcardId, + 'user_ids' => $userIds, + 'errors' => $errors, + ]; + } + + private function positiveInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) { + $integer = filter_var($value, FILTER_VALIDATE_INT); + return $integer !== false ? $integer : null; + } + return null; + } +} diff --git a/app/Domain/Jobcard/TimeEntryCommand.php b/app/Domain/Jobcard/TimeEntryCommand.php new file mode 100644 index 0000000..c575c7e --- /dev/null +++ b/app/Domain/Jobcard/TimeEntryCommand.php @@ -0,0 +1,93 @@ +} + */ + public function validate(array $command): array + { + $jobcardId = $this->positiveInteger($command['jobcard_id'] ?? null); + $technicianId = $this->positiveInteger($command['technician_id'] ?? null); + $workDate = $this->text($command['work_date'] ?? null) ?? ''; + $startTime = $this->text($command['start_time'] ?? null); + $endTime = $this->text($command['end_time'] ?? null); + $notes = $this->text($command['notes'] ?? null); + $slaFlag = $this->boolean($command['counts_toward_sla'] ?? true); + $manualHours = $command['hours'] ?? null; + if ($manualHours === '') $manualHours = null; + + $entry = ($this->timeEntries ?? new TimeEntryValidator())->validate([ + 'work_date' => $workDate, + 'start_time' => $startTime, + 'end_time' => $endTime, + 'hours' => $manualHours, + ]); + $errors = $entry['errors']; + if ($jobcardId === null) $errors['jobcard_id'] = 'Jobcard ID must be a positive integer.'; + if ($technicianId === null) $errors['technician_id'] = 'Technician ID must be a positive integer.'; + if ($manualHours !== null && ($startTime !== null || $endTime !== null)) $errors['time'] = 'Manual hours and start/end times cannot both be supplied.'; + if ($notes !== null && mb_strlen($notes) > self::MAX_NOTES_LENGTH) $errors['notes'] = 'Notes are too long.'; + if ($slaFlag === null) $errors['counts_toward_sla'] = 'SLA flag must be boolean.'; + + return [ + 'valid' => $errors === [], + 'jobcard_id' => $jobcardId, + 'technician_id' => $technicianId, + 'work_date' => $workDate, + 'start_time' => $startTime, + 'end_time' => $endTime, + 'hours' => $entry['hours'], + 'notes' => $notes, + 'counts_toward_sla' => $slaFlag ?? false, + 'errors' => $errors, + ]; + } + + private function positiveInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value > 0 ? $value : null; + } + if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) { + $integer = filter_var($value, FILTER_VALIDATE_INT); + return $integer !== false ? $integer : null; + } + return null; + } + + private function boolean(mixed $value): ?bool + { + if (is_bool($value)) return $value; + if ($value === 1 || $value === 0) return $value === 1; + if (is_string($value)) { + return match (strtolower(trim($value))) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off', '' => false, + default => null, + }; + } + return null; + } + + private function text(mixed $value): ?string + { + if (!is_scalar($value)) return null; + $text = trim((string) $value); + return $text === '' ? null : $text; + } +} diff --git a/app/Domain/SLA/SlaAgreement.php b/app/Domain/SLA/SlaAgreement.php new file mode 100644 index 0000000..c3f3e35 --- /dev/null +++ b/app/Domain/SLA/SlaAgreement.php @@ -0,0 +1,174 @@ + */ + private const DISPLAY_FIELDS = [ + 'id', + 'client_id', + 'enabled', + 'agreement_type', + 'allocated_hours', + 'period_type', + 'start_date', + 'end_date', + 'rollover_enabled', + 'notes', + ]; + + /** @return array{client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */ + public function normalize(array $record): array + { + return [ + 'client_id' => $this->integer($record['client_id'] ?? null), + 'enabled' => $this->boolean($record['enabled'] ?? true, true), + 'agreement_type' => $this->text($record['agreement_type'] ?? null), + 'allocated_hours' => $this->number($record['allocated_hours'] ?? 0), + 'period_type' => strtolower($this->text($record['period_type'] ?? null) ?? 'monthly'), + 'start_date' => $this->text($record['start_date'] ?? null), + 'end_date' => $this->text($record['end_date'] ?? null), + 'rollover_enabled' => $this->boolean($record['rollover_enabled'] ?? false, false), + 'notes' => $this->text($record['notes'] ?? null), + ]; + } + + /** @return array{valid: bool, errors: array, client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */ + public function validate(array $record): array + { + $normalized = $this->normalize($record); + $errors = []; + + if (!$this->positiveInteger($record['client_id'] ?? null)) { + $errors['client_id'] = 'Client ID must be a positive integer.'; + } + if (!$this->validBoolean($record['enabled'] ?? true)) { + $errors['enabled'] = 'Enabled must be a boolean value.'; + } + if (!$this->nullableScalar($record['agreement_type'] ?? null)) { + $errors['agreement_type'] = 'Agreement type must be text.'; + } elseif ($normalized['agreement_type'] !== null && $this->length($normalized['agreement_type']) > 120) { + $errors['agreement_type'] = 'Agreement type must be 120 characters or fewer.'; + } + if (!is_numeric($record['allocated_hours'] ?? 0) || !is_finite((float) ($record['allocated_hours'] ?? 0))) { + $errors['allocated_hours'] = 'Allocated hours must be a finite number.'; + } elseif ($normalized['allocated_hours'] < 0) { + $errors['allocated_hours'] = 'Allocated hours must not be negative.'; + } + if (!$this->nullableScalar($record['period_type'] ?? null) + || !in_array($normalized['period_type'], ['monthly', 'annual', 'custom'], true)) { + $errors['period_type'] = 'Period type must be monthly, annual, or custom.'; + } + if (!$this->nullableScalar($record['start_date'] ?? null)) { + $errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.'; + } elseif ($normalized['start_date'] !== null && !$this->validDate($normalized['start_date'])) { + $errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.'; + } + if (!$this->nullableScalar($record['end_date'] ?? null)) { + $errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.'; + } elseif ($normalized['end_date'] !== null && !$this->validDate($normalized['end_date'])) { + $errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.'; + } + if (!isset($errors['start_date']) + && !isset($errors['end_date']) + && $normalized['start_date'] !== null + && $normalized['end_date'] !== null + && $normalized['start_date'] > $normalized['end_date']) { + $errors['end_date'] = 'End date must not be before start date.'; + } + if (!$this->validBoolean($record['rollover_enabled'] ?? false)) { + $errors['rollover_enabled'] = 'Rollover enabled must be a boolean value.'; + } elseif ($normalized['rollover_enabled']) { + $errors['rollover_enabled'] = 'Rollover cannot be enabled until rollover rules are configured.'; + } + if (!$this->nullableScalar($record['notes'] ?? null)) { + $errors['notes'] = 'Notes must be text.'; + } + + return [...$normalized, 'valid' => $errors === [], 'errors' => $errors]; + } + + /** @return array */ + public function display(array $record): array + { + $safe = []; + foreach (self::DISPLAY_FIELDS as $field) { + if (array_key_exists($field, $record)) { + $safe[$field] = $record[$field]; + } + } + return $safe; + } + + /** @return array */ + public function toDisplay(array $record): array + { + return $this->display($record); + } + + private function integer(mixed $value): int + { + $text = is_int($value) || is_string($value) ? trim((string) $value) : ''; + return preg_match('/^\d+$/', $text) === 1 ? (int) $text : 0; + } + + private function positiveInteger(mixed $value): bool + { + if (is_int($value)) return $value > 0; + if (!is_string($value)) return false; + $value = trim($value); + return preg_match('/^\d+$/', $value) === 1 && (int) $value > 0; + } + + private function number(mixed $value): float + { + return is_numeric($value) && is_finite((float) $value) ? (float) $value : 0.0; + } + + private function boolean(mixed $value, bool $default): bool + { + if (is_bool($value)) return $value; + if ($value === 1 || $value === 0) return $value === 1; + if (is_string($value)) { + $value = strtolower(trim($value)); + if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true; + if (in_array($value, ['0', 'false', 'no', 'off', ''], true)) return false; + } + return $default; + } + + private function validBoolean(mixed $value): bool + { + if (is_bool($value) || $value === 0 || $value === 1) return true; + if (!is_string($value)) return false; + return in_array(strtolower(trim($value)), ['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off', ''], true); + } + + private function nullableScalar(mixed $value): bool + { + return $value === null || is_scalar($value); + } + + private function validDate(string $value): bool + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = \DateTimeImmutable::getLastErrors(); + return $date !== false + && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)) + && $date->format('Y-m-d') === $value; + } + + private function length(string $value): int + { + return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + } + + private function text(mixed $value): ?string + { + if ($value === null) return null; + $text = trim(is_scalar($value) ? (string) $value : ''); + return $text === '' ? null : $text; + } +} diff --git a/app/Domain/User/PasswordPolicy.php b/app/Domain/User/PasswordPolicy.php new file mode 100644 index 0000000..745b0f6 --- /dev/null +++ b/app/Domain/User/PasswordPolicy.php @@ -0,0 +1,72 @@ +} */ + public function validate(mixed $password): array + { + $errors = []; + if (!is_string($password)) { + return ['valid' => false, 'errors' => ['password must be a string']]; + } + + if (mb_strlen($password) < self::MINIMUM_LENGTH) { + $errors[] = 'minimum length'; + } + if (preg_match('/\p{Lu}/u', $password) !== 1) { + $errors[] = 'uppercase letter'; + } + if (preg_match('/\p{Ll}/u', $password) !== 1) { + $errors[] = 'lowercase letter'; + } + if (preg_match('/\p{N}/u', $password) !== 1) { + $errors[] = 'number'; + } + if (preg_match('/[^\p{L}\p{N}\s]/u', $password) !== 1) { + $errors[] = 'symbol'; + } + if ($this->isCommonPlaceholder($password)) { + $errors[] = 'common placeholder'; + } + + return ['valid' => $errors === [], 'errors' => $errors]; + } + + /** @return array{valid: bool, errors: list} */ + public function validateInitial(mixed $password): array + { + return $this->validate($password); + } + + /** @return array{valid: bool, errors: list} */ + public function validateReset(mixed $password): array + { + return $this->validate($password); + } + + public function isValid(mixed $password): bool + { + return $this->validate($password)['valid']; + } + + private function isCommonPlaceholder(string $password): bool + { + $canonical = strtolower($password); + $canonical = strtr($canonical, ['@' => 'a', '$' => 's', '0' => 'o']); + $canonical = preg_replace('/[^a-z0-9]/', '', $canonical) ?? ''; + + return preg_match( + '/^(?:password|changeme|welcome|admin|administrator|temporary|temppassword|qwerty|letmein)[0-9]*$/', + $canonical, + ) === 1; + } +} diff --git a/app/Domain/User/UserRecord.php b/app/Domain/User/UserRecord.php new file mode 100644 index 0000000..7fd7fe8 --- /dev/null +++ b/app/Domain/User/UserRecord.php @@ -0,0 +1,133 @@ + */ + private const DISPLAY_FIELDS = [ + 'id', + 'name', + 'email', + 'role_id', + 'role_name', + 'is_active', + 'last_login_at', + 'created_at', + 'updated_at', + ]; + + private readonly PasswordPolicy $passwordPolicy; + + public function __construct(?PasswordPolicy $passwordPolicy = null) + { + $this->passwordPolicy = $passwordPolicy ?? new PasswordPolicy(); + } + + /** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed} */ + public function normalize(array $record): array + { + return [ + 'name' => trim(is_scalar($record['name'] ?? null) ? (string) $record['name'] : ''), + 'email' => strtolower(trim(is_scalar($record['email'] ?? null) ? (string) $record['email'] : '')), + 'role_id' => $this->normalizeRoleId($record['role_id'] ?? null), + 'is_active' => $this->normalizeActive($record['is_active'] ?? $record['active'] ?? true), + ]; + } + + /** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array} */ + public function validate(array $record): array + { + $normalized = $this->normalize($record); + $errors = []; + + if ($normalized['name'] === '') { + $errors['name'] = 'User name is required.'; + } elseif (mb_strlen($normalized['name']) > 120) { + $errors['name'] = 'User name must be 120 characters or fewer.'; + } + + if ($normalized['email'] === '') { + $errors['email'] = 'Email address is required.'; + } elseif (mb_strlen($normalized['email']) > 190) { + $errors['email'] = 'Email address must be 190 characters or fewer.'; + } elseif (filter_var($normalized['email'], FILTER_VALIDATE_EMAIL) === false) { + $errors['email'] = 'Email address must be valid.'; + } + + if (!is_int($normalized['role_id']) || $normalized['role_id'] < 1) { + $errors['role_id'] = 'Role must be a positive integer.'; + } + + if (!is_bool($normalized['is_active'])) { + $errors['is_active'] = 'Active flag must be boolean.'; + } + + return [...$normalized, 'valid' => $errors === [], 'errors' => $errors]; + } + + /** + * Validates a new user and its initial password without returning the + * plaintext password in the result. + * + * @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array} + */ + public function validateForCreate(array $record): array + { + $result = $this->validate($record); + $password = $this->passwordPolicy->validateInitial($record['password'] ?? null); + if (!$password['valid']) { + $result['errors']['password'] = 'Password requires: ' . implode(', ', $password['errors']) . '.'; + $result['valid'] = false; + } + return $result; + } + + /** @return array */ + public function display(array $record): array + { + $safe = []; + foreach (self::DISPLAY_FIELDS as $field) { + if (array_key_exists($field, $record)) { + $safe[$field] = $record[$field]; + } + } + return $safe; + } + + /** @return array */ + public function toDisplay(array $record): array + { + return $this->display($record); + } + + private function normalizeRoleId(mixed $value): int|string|null + { + if (is_int($value)) { + return $value; + } + if (is_string($value) && preg_match('/^[0-9]+$/', trim($value)) === 1) { + return (int) trim($value); + } + return is_scalar($value) || $value === null ? $value : null; + } + + private function normalizeActive(mixed $value): mixed + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (is_string($value)) { + return match (strtolower(trim($value))) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off' => false, + default => $value, + }; + } + return $value; + } +} diff --git a/bin/install.php b/bin/install.php index b5edc32..28e74e1 100644 --- a/bin/install.php +++ b/bin/install.php @@ -8,12 +8,25 @@ if (PHP_SAPI !== 'cli') { require_once __DIR__ . '/../config/bootstrap.php'; +function apply_existing_database_upgrades(PDO $pdo): void +{ + $pdo->exec("CREATE TABLE IF NOT EXISTS jobcard_status_history (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, jobcard_id BIGINT UNSIGNED NOT NULL, from_status VARCHAR(60) NOT NULL, to_status VARCHAR(60) NOT NULL, changed_by BIGINT UNSIGNED NULL, changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE, FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL, INDEX status_history_jobcard_idx (jobcard_id, changed_at)) ENGINE=InnoDB"); + $pdo->exec("CREATE TABLE IF NOT EXISTS jobcard_sequences (sequence_year SMALLINT UNSIGNED PRIMARY KEY, next_sequence INT UNSIGNED NOT NULL) ENGINE=InnoDB"); + $index = $pdo->query("SHOW INDEX FROM sla_agreements WHERE Key_name = 'sla_client_unique'")->fetch(); + if (!$index) { + $duplicates = (int)$pdo->query('SELECT COUNT(*) FROM (SELECT client_id FROM sla_agreements GROUP BY client_id HAVING COUNT(*) > 1) duplicate_clients')->fetchColumn(); + if ($duplicates > 0) throw new RuntimeException('Multiple SLA agreements exist for one or more clients; resolve duplicates before rerunning the installer.'); + $pdo->exec('ALTER TABLE sla_agreements ADD UNIQUE KEY sla_client_unique (client_id)'); + } +} + try { $schemaPath = __DIR__ . '/../database/schema.sql'; if (!is_readable($schemaPath)) throw new RuntimeException('database/schema.sql is missing or unreadable'); $schema = file_get_contents($schemaPath); if ($schema === false || trim($schema) === '') throw new RuntimeException('database/schema.sql could not be read'); db()->exec($schema); + apply_existing_database_upgrades(db()); ensure_initial_administrator(); fwrite(STDOUT, "Database schema installed and initial Administrator verified.\n"); } catch (Throwable $exception) { diff --git a/config/bootstrap.php b/config/bootstrap.php index 04b7799..35f42b8 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -1,6 +1,8 @@ query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return; $email = strtolower(trim(env_required('ADMIN_EMAIL'))); $password = env_required('ADMIN_PASSWORD'); - if (strlen($password) < 12) throw new RuntimeException('ADMIN_PASSWORD must be at least 12 characters'); + $passwordResult = (new \App\Domain\User\PasswordPolicy())->validateInitial($password); + if (!$passwordResult['valid']) throw new RuntimeException('ADMIN_PASSWORD does not meet the password policy'); $roleId = (int)db()->query("SELECT id FROM roles WHERE name = 'Administrator'")->fetchColumn(); if ($roleId < 1) throw new RuntimeException('Administrator role is missing from the database'); $stmt = db()->prepare('INSERT INTO users (role_id, email, name, password_hash) VALUES (:role, :email, :name, :hash)'); @@ -115,6 +118,16 @@ function require_permission(string $permission): void if (!can($permission)) { http_response_code(403); exit('Forbidden'); } } +function can_access_jobcard(int $jobcardId): bool +{ + $user = current_user(); + if (!$user) return false; + if ($user['role_name'] !== 'Technician') return can('jobcards.view'); + $stmt = db()->prepare('SELECT 1 FROM jobcard_assignments WHERE jobcard_id = :jobcard AND user_id = :user LIMIT 1'); + $stmt->execute(['jobcard' => $jobcardId, 'user' => $user['id']]); + return (bool)$stmt->fetchColumn(); +} + function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void { $user = current_user(); diff --git a/database/schema.sql b/database/schema.sql index 5927165..b2ddb05 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS sla_agreements ( created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, - INDEX sla_client_idx (client_id) + UNIQUE KEY sla_client_unique (client_id) ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS jobcards ( @@ -121,6 +121,18 @@ CREATE TABLE IF NOT EXISTS jobcard_assignments ( FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS jobcard_status_history ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + jobcard_id BIGINT UNSIGNED NOT NULL, + from_status VARCHAR(60) NOT NULL, + to_status VARCHAR(60) NOT NULL, + changed_by BIGINT UNSIGNED NULL, + changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (jobcard_id) REFERENCES jobcards(id) ON DELETE CASCADE, + FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX status_history_jobcard_idx (jobcard_id, changed_at) +) ENGINE=InnoDB; + CREATE TABLE IF NOT EXISTS time_entries ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, jobcard_id BIGINT UNSIGNED NOT NULL, @@ -166,6 +178,11 @@ INSERT IGNORE INTO permissions (name, description) VALUES ('clients.manage', 'Create and edit client records'), ('jobcards.view', 'View jobcards'), ('jobcards.manage', 'Create and update jobcards'), + ('jobcards.assign', 'Assign technicians to jobcards'), + ('jobcards.internal_notes', 'View and edit internal jobcard notes'), + ('time_entries.record', 'Record technician time entries'), + ('sla.view', 'View client SLA agreements and usage'), + ('sla.manage', 'Configure client SLA agreements'), ('reports.view', 'View reports'), ('reports.export', 'Export reports'), ('users.manage', 'Manage users'), @@ -178,10 +195,10 @@ SELECT r.id, p.id FROM roles r CROSS JOIN permissions p WHERE r.name = 'Administ INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN -('dashboard.view','clients.view','jobcards.view','reports.view','reports.export') +('dashboard.view','clients.view','jobcards.view','reports.view','reports.export','sla.view') WHERE r.name = 'Accounts'; INSERT IGNORE INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN -('dashboard.view','clients.view','jobcards.view','jobcards.manage') +('dashboard.view','clients.view','jobcards.view','jobcards.manage','time_entries.record') WHERE r.name = 'Technician'; diff --git a/public/index.php b/public/index.php index 369bd4b..195e80a 100644 --- a/public/index.php +++ b/public/index.php @@ -7,6 +7,13 @@ require_once __DIR__ . '/../app/Domain/Client/ClientContactValidator.php'; require_once __DIR__ . '/../app/Domain/Client/ClientRecord.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/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/Reporting/CsvExporter.php'; ini_set('session.use_strict_mode', '1'); @@ -73,10 +80,132 @@ if ($route === 'login') { $user = require_login(); if ($route === 'dashboard') { require_permission('dashboard.view'); - render_header('Dashboard'); ?>

Dashboard

Your operational overview.

New jobcards
0
Open jobcards
0
Hours this week
0.0
SLA warnings
0

Foundation ready

Authentication, role-aware navigation, CSRF protection, password hashing and audit logging are active. Client and jobcard modules will populate this dashboard in the next increments.

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(); + 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->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->execute(['user' => $user['id']]); + $slaRows = $slaRowsStmt->fetchAll(); + } + $slaClassifier = new \App\Domain\SLA\SlaThresholdClassifier(); + $slaWarnings = 0; + foreach ($slaRows as $slaRow) if (in_array($slaClassifier->classify((float)$slaRow['used_hours'], (float)$slaRow['allocated_hours']), ['warning', 'critical', 'exceeded'], true)) $slaWarnings++; + render_header('Dashboard'); ?>

Dashboard

Your operational overview.

New jobcards
Open jobcards
Hours this week
SLA warnings

Operations

Use Jobcards to manage assignments, status, technician notes and time entries. SLA threshold metrics will activate after period and rollover rules are configured.

'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; +if ($route === 'jobcard') { + require_permission('jobcards.view'); + $jobcardId = filter_var(scalar_input($_GET['id'] ?? null), FILTER_VALIDATE_INT); + if (!$jobcardId) { http_response_code(400); exit('Invalid jobcard'); } + $jobcardStmt = db()->prepare('SELECT j.*, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id WHERE j.id = :id'); + $jobcardStmt->execute(['id' => $jobcardId]); + $jobcard = $jobcardStmt->fetch(); + if (!$jobcard) { http_response_code(404); exit('Jobcard not found'); } + if (!can_access_jobcard($jobcardId)) { http_response_code(404); exit('Jobcard not found'); } + $actionErrors = []; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = scalar_input($_POST['action'] ?? null); + if ($action === 'status') { + require_permission('jobcards.manage'); + $to = scalar_input($_POST['status'] ?? null); + $pdo = db(); + try { + $pdo->beginTransaction(); + $lockedStmt = $pdo->prepare('SELECT status, completed_at, closed_at FROM jobcards WHERE id = :id FOR UPDATE'); + $lockedStmt->execute(['id' => $jobcardId]); + $locked = $lockedStmt->fetch(); + if (!$locked) throw new RuntimeException('Jobcard no longer exists.'); + $now = date('Y-m-d H:i:s'); + $completedAt = $to === 'completed' ? $now : ($locked['completed_at'] ?: null); + $closedAt = $to === 'closed' ? $now : ($locked['closed_at'] ?: null); + if ($to === 'closed' && $completedAt === null) $completedAt = $now; + $transition = (new \App\Domain\Jobcard\JobcardWorkflow())->validateTransition($locked['status'], $to, $completedAt, $closedAt); + if (!$transition['valid']) { + $pdo->rollBack(); + $actionErrors = array_values($transition['errors']); + } else { + $pdo->prepare('UPDATE jobcards SET status = :status, completed_at = :completed, closed_at = :closed WHERE id = :id')->execute(['status' => $to, 'completed' => $completedAt, 'closed' => $closedAt, 'id' => $jobcardId]); + $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(); + header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; + } + } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Status update failed.'; } + } elseif ($action === 'notes') { + require_permission('jobcards.manage'); + $technicianNotes = trim(scalar_input($_POST['technician_notes'] ?? null)); + $internalNotes = can('jobcards.internal_notes') ? trim(scalar_input($_POST['internal_notes'] ?? null)) : (string)($jobcard['internal_notes'] ?? ''); + if (mb_strlen($technicianNotes) > 50000 || mb_strlen($internalNotes) > 50000) $actionErrors[] = 'Notes are too long.'; + if (!$actionErrors) { + db()->prepare('UPDATE jobcards SET technician_notes = :technician, internal_notes = :internal WHERE id = :id')->execute(['technician' => $technicianNotes ?: null, 'internal' => $internalNotes ?: null, 'id' => $jobcardId]); + audit('jobcard_notes_updated', 'jobcard', $jobcardId); + header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; + } + } elseif ($action === 'assign') { + require_permission('jobcards.assign'); + $assignment = (new \App\Domain\Jobcard\AssignmentValidator())->validate(['jobcard_id' => $jobcardId, 'user_ids' => [$_POST['technician_id'] ?? null]]); + $actionErrors = array_values($assignment['errors']); + $technicianId = $assignment['user_ids'][0] ?? null; + $technicianCheck = db()->prepare("SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1 AND r.name = 'Technician'"); + $technicianCheck->execute(['id' => $technicianId]); + if (!$technicianCheck->fetchColumn()) $actionErrors[] = 'Select an active technician.'; + if (!$actionErrors) { + $pdo = db(); + try { + $pdo->beginTransaction(); + $pdo->prepare('DELETE FROM jobcard_assignments WHERE jobcard_id = :jobcard')->execute(['jobcard' => $jobcardId]); + $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(); + header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; + } catch (Throwable $exception) { if ($pdo->inTransaction()) $pdo->rollBack(); $actionErrors[] = 'Assignment update failed.'; } + } + } elseif ($action === 'time') { + require_permission('time_entries.record'); + $technicianId = $user['role_name'] === 'Technician' ? (int)$user['id'] : filter_var(scalar_input($_POST['technician_id'] ?? null), FILTER_VALIDATE_INT); + $technicianCheck = db()->prepare("SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = :id AND u.is_active = 1 AND r.name = 'Technician'"); + $technicianCheck->execute(['id' => $technicianId]); + $timeInput = [...$_POST, 'jobcard_id' => $jobcardId, 'technician_id' => $technicianId, 'counts_toward_sla' => isset($_POST['counts_toward_sla']) ? '1' : '0']; + $time = (new \App\Domain\Jobcard\TimeEntryCommand())->validate($timeInput); + $actionErrors = array_values($time['errors']); + if (!$technicianCheck->fetchColumn()) $actionErrors[] = 'Time must be attributed to an active technician.'; + if (!$actionErrors) { + db()->prepare('INSERT INTO time_entries (jobcard_id, technician_id, work_date, start_time, end_time, hours, notes, counts_toward_sla, created_by) VALUES (:jobcard, :technician, :work_date, :start_time, :end_time, :hours, :notes, :sla, :created_by)')->execute(['jobcard' => $jobcardId, 'technician' => $time['technician_id'], 'work_date' => $time['work_date'], 'start_time' => $time['start_time'], 'end_time' => $time['end_time'], 'hours' => $time['hours'], 'notes' => $time['notes'], 'sla' => $time['counts_toward_sla'] ? 1 : 0, 'created_by' => $user['id']]); + audit('time_entry_created', 'jobcard', $jobcardId, ['hours' => $time['hours']]); + header('Location: /?route=jobcard&id=' . $jobcardId . '&updated=1'); exit; + } + } + } + $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(); + $totalHours = array_sum(array_map(static fn (array $entry): float => (float)$entry['hours'], $timeEntries)); + render_header('Jobcard ' . $jobcard['reference_no']); + echo '
← Back to jobcards

' . e($jobcard['reference_no']) . '

' . e($jobcard['client_name']) . '

' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '
' . (isset($_GET['updated']) ? '
Jobcard updated.
' : '') . ($actionErrors ? '
' . e(implode(' ', $actionErrors)) . '
' : ''); + 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'] ?? '')) . '
'; + if (can('time_entries.record')) { echo '
'; if ($user['role_name'] !== 'Technician') { echo '
'; } echo '
'; } + echo '

Status

Assigned technicians

'; + if (!$assigned) echo '

No technicians assigned.

'; foreach ($assigned as $assignment) echo '
' . e($assignment['name']) . '
'; + if (can('jobcards.assign')) { echo '
'; } + echo '
'; + render_footer(); exit; +} + if ($route === 'jobcards') { require_permission('jobcards.view'); $errors = []; @@ -107,6 +236,7 @@ if ($route === 'jobcards') { $stmt = $pdo->prepare('INSERT INTO jobcards (reference_no, client_id, created_by, priority, status, work_requested) VALUES (:reference, :client, :created_by, :priority, \'new\', :requested)'); $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]); $jobcardId = (int)$pdo->lastInsertId(); + if ($user['role_name'] === 'Technician') $pdo->prepare('INSERT IGNORE INTO jobcard_assignments (jobcard_id, user_id, assigned_by) VALUES (:jobcard, :user, :by_user)')->execute(['jobcard' => $jobcardId, 'user' => $user['id'], 'by_user' => $user['id']]); audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]); $pdo->commit(); header('Location: /?route=jobcards&created=1'); exit; @@ -117,7 +247,13 @@ if ($route === 'jobcards') { } } $clients = db()->query("SELECT id, name FROM clients WHERE status = 'active' ORDER BY name")->fetchAll(); - $jobcards = db()->query('SELECT j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id ORDER BY j.created_at DESC LIMIT 100')->fetchAll(); + if ($user['role_name'] === 'Technician') { + $jobcardList = db()->prepare('SELECT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id JOIN jobcard_assignments ja ON ja.jobcard_id = j.id AND ja.user_id = :user ORDER BY j.created_at DESC LIMIT 100'); + $jobcardList->execute(['user' => $user['id']]); + $jobcards = $jobcardList->fetchAll(); + } else { + $jobcards = db()->query('SELECT j.id, j.reference_no, j.priority, j.status, j.work_requested, j.created_at, c.name AS client_name FROM jobcards j JOIN clients c ON c.id = j.client_id ORDER BY j.created_at DESC LIMIT 100')->fetchAll(); + } render_header('Jobcards'); echo '

Jobcards

Track requested work and operational status.

'; if (can('jobcards.manage')) echo ''; @@ -127,7 +263,7 @@ if ($route === 'jobcards') { if (can('jobcards.manage')) { echo '

Create jobcard

'; } echo '
'; if (!$jobcards) echo ''; - foreach ($jobcards as $jobcard) echo ''; + foreach ($jobcards as $jobcard) echo ''; echo '
ReferenceClientPriorityStatusWork requestedCreated
No jobcards found.
' . e($jobcard['reference_no']) . '' . e($jobcard['client_name']) . '' . e(ucfirst($jobcard['priority'])) . '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '' . e($jobcard['work_requested']) . '' . e($jobcard['created_at']) . '
' . e($jobcard['reference_no']) . '' . e($jobcard['client_name']) . '' . e(ucfirst($jobcard['priority'])) . '' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '' . e($jobcard['work_requested']) . '' . e($jobcard['created_at']) . '
'; render_footer(); exit; } @@ -142,40 +278,66 @@ if ($route === 'client') { if (!$client) { http_response_code(404); exit('Client not found'); } $contactErrors = []; $contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false]; + $slaErrors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - require_permission('clients.manage'); verify_csrf(); - $contact = validate_client_contact($_POST); - $contactOld = $contact; - $contactErrors = $contact['errors']; - if ($contactErrors === []) { - $pdo = db(); - try { - $pdo->beginTransaction(); - if ($contact['is_primary']) { - $pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]); + $clientAction = scalar_input($_POST['action'] ?? null, 'contact'); + if ($clientAction === 'sla') { + require_permission('sla.manage'); + $sla = (new \App\Domain\SLA\SlaAgreement())->validate([...$_POST, 'client_id' => $clientId, 'enabled' => isset($_POST['enabled']) ? '1' : '0', 'rollover_enabled' => isset($_POST['rollover_enabled']) ? '1' : '0']); + $slaErrors = array_values($sla['errors']); + if (!$slaErrors) { + db()->prepare('INSERT INTO sla_agreements (client_id, enabled, agreement_type, allocated_hours, period_type, start_date, end_date, rollover_enabled, notes) VALUES (:client, :enabled, :type, :hours, :period, :start_date, :end_date, :rollover, :notes) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), agreement_type = VALUES(agreement_type), allocated_hours = VALUES(allocated_hours), period_type = VALUES(period_type), start_date = VALUES(start_date), end_date = VALUES(end_date), rollover_enabled = VALUES(rollover_enabled), notes = VALUES(notes)')->execute(['client' => $clientId, 'enabled' => $sla['enabled'] ? 1 : 0, 'type' => $sla['agreement_type'], 'hours' => $sla['allocated_hours'], 'period' => $sla['period_type'], 'start_date' => $sla['start_date'], 'end_date' => $sla['end_date'], 'rollover' => $sla['rollover_enabled'] ? 1 : 0, 'notes' => $sla['notes']]); + audit('sla_agreement_updated', 'client', $clientId); + header('Location: /?route=client&id=' . $clientId . '&sla_updated=1'); exit; + } + } else { + require_permission('clients.manage'); + $contact = validate_client_contact($_POST); + $contactOld = $contact; + $contactErrors = $contact['errors']; + if ($contactErrors === []) { + $pdo = db(); + try { + $pdo->beginTransaction(); + if ($contact['is_primary']) { + $pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['client' => $clientId]); + } + $contactInsert = $pdo->prepare('INSERT INTO client_contacts (client_id, name, email, phone, is_primary) VALUES (:client, :name, :email, :phone, :primary)'); + $contactInsert->execute(['client' => $clientId, 'name' => $contact['name'], 'email' => $contact['email'], 'phone' => $contact['phone'], 'primary' => $contact['is_primary'] ? 1 : 0]); + $contactId = (int)$pdo->lastInsertId(); + audit('client_contact_created', 'client_contact', $contactId, ['client_id' => $clientId]); + $pdo->commit(); + header('Location: /?route=client&id=' . $clientId . '&contact_created=1'); exit; + } catch (Throwable $exception) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $contactErrors[] = 'The contact could not be created. Please try again.'; } - $contactInsert = $pdo->prepare('INSERT INTO client_contacts (client_id, name, email, phone, is_primary) VALUES (:client, :name, :email, :phone, :primary)'); - $contactInsert->execute(['client' => $clientId, 'name' => $contact['name'], 'email' => $contact['email'], 'phone' => $contact['phone'], 'primary' => $contact['is_primary'] ? 1 : 0]); - $contactId = (int)$pdo->lastInsertId(); - audit('client_contact_created', 'client_contact', $contactId, ['client_id' => $clientId]); - $pdo->commit(); - header('Location: /?route=client&id=' . $clientId . '&contact_created=1'); exit; - } catch (Throwable $exception) { - if ($pdo->inTransaction()) $pdo->rollBack(); - $contactErrors[] = 'The contact could not be created. Please try again.'; } } } $contactsStmt = db()->prepare('SELECT name, email, phone, is_primary, notes FROM client_contacts WHERE client_id = :id ORDER BY is_primary DESC, name'); $contactsStmt->execute(['id' => $clientId]); $contacts = $contactsStmt->fetchAll(); + $slaAgreement = null; + if (can('sla.view') || can('sla.manage')) { + $slaStmt = db()->prepare('SELECT * FROM sla_agreements WHERE client_id = :client LIMIT 1'); + $slaStmt->execute(['client' => $clientId]); + $slaAgreement = $slaStmt->fetch() ?: null; + } render_header('Client details'); - echo '
← Back to clients

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

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '
' . (isset($_GET['contact_created']) ? '
Contact added successfully.
' : '') . ($contactErrors ? '
' . e(implode(' ', $contactErrors)) . '
' : '') . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; + echo '
← Back to clients

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

Client profile and support contacts.

' . e(ucfirst($client['status'])) . '
' . (isset($_GET['contact_created']) ? '
Contact added successfully.
' : '') . (isset($_GET['sla_updated']) ? '
SLA agreement updated.
' : '') . ($contactErrors ? '
' . e(implode(' ', $contactErrors)) . '
' : '') . ($slaErrors ? '
' . e(implode(' ', $slaErrors)) . '
' : '') . '

Support information

Email
' . e((string)($client['support_email'] ?? '—')) . '
Phone
' . e((string)($client['support_phone'] ?? '—')) . '
Preferred method
' . e((string)($client['preferred_contact_method'] ?? '—')) . '
Address
' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '

Contacts

'; if (!$contacts) echo '

No contacts recorded.

'; foreach ($contacts as $contact) echo '
' . e($contact['name']) . ($contact['is_primary'] ? ' Primary' : '') . '
' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '
'; - if (can('clients.manage')) echo '

Add contact

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

Add contact

'; echo '
'; + if (can('sla.view') || can('sla.manage')) { + echo '

SLA agreement

'; + if (can('sla.manage')) { echo '
'; + } elseif ($slaAgreement) echo '
Type
' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '
Allocation
' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '
'; + else echo '

No SLA agreement configured.

'; + echo '
'; + } render_footer(); exit; } @@ -219,11 +381,77 @@ if ($route === 'clients') { exit; } +if ($route === 'users') { + require_permission('users.manage'); + $userErrors = []; + $userOld = ['name' => '', 'email' => '', 'role_id' => '']; + if ($_SERVER['REQUEST_METHOD'] === '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); + $userOld = $validatedUser; + $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; + } + } + $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 at least 12 characters with 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; +} + +if (false) { + require_permission('users.manage'); + $userErrors = []; + if ($_SERVER['REQUEST_METHOD'] === '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; + } + } + $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; +} + if ($route === 'reports') { require_permission('reports.view'); $format = scalar_input($_GET['format'] ?? null); if ($format === 'csv') require_permission('reports.export'); - $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(); + 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(te.hours), 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 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']]); + $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(); + } $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows); if ($format === 'csv') { $csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true); diff --git a/tests/AssignmentTimeEntryTest.php b/tests/AssignmentTimeEntryTest.php new file mode 100644 index 0000000..bc4e638 --- /dev/null +++ b/tests/AssignmentTimeEntryTest.php @@ -0,0 +1,144 @@ +validate([ + 'jobcard_id' => '12', + 'user_ids' => ['7', 9, '7'], +]); +assignment_time_entry_assert_same(true, $assignment['valid'], 'Valid assignment payloads should be accepted.'); +assignment_time_entry_assert_same(12, $assignment['jobcard_id'], 'Jobcard IDs should normalize to integers.'); +assignment_time_entry_assert_same([7, 9], $assignment['user_ids'], 'Technician IDs should normalize and de-duplicate.'); +assignment_time_entry_assert_same([], $assignment['errors'], 'Valid assignment payloads should not contain errors.'); + +$invalidJobcard = (new AssignmentValidator())->validate(['jobcard_id' => 0, 'user_ids' => [7]]); +assignment_time_entry_assert_same(false, $invalidJobcard['valid'], 'Jobcard IDs must be positive integers.'); +if (!isset($invalidJobcard['errors']['jobcard_id'])) { + throw new RuntimeException('Invalid jobcard IDs should produce a jobcard_id error.'); +} + +$invalidTechnicians = (new AssignmentValidator())->validate([ + 'jobcard_id' => 12, + 'user_ids' => [7, '0', true], +]); +assignment_time_entry_assert_same(false, $invalidTechnicians['valid'], 'Every technician ID must be a positive integer.'); +if (!isset($invalidTechnicians['errors']['user_ids'])) { + throw new RuntimeException('Invalid technician assignment payloads should produce a user_ids error.'); +} + +$missingTechnicians = (new AssignmentValidator())->validate(['jobcard_id' => 12]); +assignment_time_entry_assert_same(false, $missingTechnicians['valid'], 'At least one assigned technician is required.'); +if (!isset($missingTechnicians['errors']['user_ids'])) { + throw new RuntimeException('Missing technician assignments should produce a user_ids error.'); +} + +$timeEntry = (new TimeEntryCommand())->validate([ + 'jobcard_id' => '12', + 'technician_id' => '7', + 'work_date' => '2026-09-01', + 'start_time' => '09:00', + 'end_time' => '11:30', + 'notes' => ' Replaced cable ', + 'counts_toward_sla' => '0', +]); +assignment_time_entry_assert_same(true, $timeEntry['valid'], 'Valid time-entry commands should be accepted.'); +assignment_time_entry_assert_same(12, $timeEntry['jobcard_id'], 'Time-entry jobcard IDs should normalize to integers.'); +assignment_time_entry_assert_same(7, $timeEntry['technician_id'], 'Technician IDs should normalize to integers.'); +assignment_time_entry_assert_same(2.5, $timeEntry['hours'], 'Start/end times should calculate normalized hours.'); +assignment_time_entry_assert_same('Replaced cable', $timeEntry['notes'], 'Time-entry notes should be trimmed.'); +assignment_time_entry_assert_same(false, $timeEntry['counts_toward_sla'], 'Boolean-like SLA values should normalize to booleans.'); +assignment_time_entry_assert_same([], $timeEntry['errors'], 'Valid time-entry commands should not contain errors.'); + +$manualEntry = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-09-02', + 'hours' => '1.255', +]); +assignment_time_entry_assert_same(true, $manualEntry['valid'], 'Positive manual hours should be accepted without times.'); +assignment_time_entry_assert_same(1.26, $manualEntry['hours'], 'Manual hours should reuse time-entry rounding.'); +assignment_time_entry_assert_same(true, $manualEntry['counts_toward_sla'], 'SLA counting should default to true.'); + +$invalidIds = (new TimeEntryCommand())->validate([ + 'jobcard_id' => -1, + 'technician_id' => '0', + 'work_date' => '2026-09-02', + 'hours' => 1, +]); +assignment_time_entry_assert_same(false, $invalidIds['valid'], 'Time-entry IDs must be positive integers.'); +if (!isset($invalidIds['errors']['jobcard_id'], $invalidIds['errors']['technician_id'])) { + throw new RuntimeException('Invalid time-entry IDs should produce field errors.'); +} + +$ambiguousDuration = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-09-02', + 'hours' => 1, + 'start_time' => '09:00', + 'end_time' => '10:00', +]); +assignment_time_entry_assert_same(false, $ambiguousDuration['valid'], 'Manual hours and start/end times must be mutually exclusive.'); +if (!isset($ambiguousDuration['errors']['time'])) { + throw new RuntimeException('Ambiguous duration input should produce a time error.'); +} + +$invalidEntry = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-02-30', + 'start_time' => '11:00', + 'end_time' => '10:00', +]); +assignment_time_entry_assert_same(false, $invalidEntry['valid'], 'Invalid dates and time ranges should be rejected.'); +if (!isset($invalidEntry['errors']['work_date'], $invalidEntry['errors']['time'])) { + throw new RuntimeException('TimeEntryValidator errors should be retained by the command.'); +} + +$longNotes = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-09-02', + 'hours' => 1, + 'notes' => str_repeat('N', TimeEntryCommand::MAX_NOTES_LENGTH + 1), +]); +assignment_time_entry_assert_same(false, $longNotes['valid'], 'Overlong time-entry notes should be rejected.'); +if (!isset($longNotes['errors']['notes'])) { + throw new RuntimeException('Overlong notes should produce a notes error.'); +} + +$invalidSlaFlag = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-09-02', + 'hours' => 1, + 'counts_toward_sla' => 'sometimes', +]); +assignment_time_entry_assert_same(false, $invalidSlaFlag['valid'], 'Unknown SLA flag values should be rejected.'); +if (!isset($invalidSlaFlag['errors']['counts_toward_sla'])) { + throw new RuntimeException('Invalid SLA flags should produce a counts_toward_sla error.'); +} + +$falseSlaFlag = (new TimeEntryCommand())->validate([ + 'jobcard_id' => 12, + 'technician_id' => 7, + 'work_date' => '2026-09-02', + 'hours' => 1, + 'counts_toward_sla' => ' false ', +]); +assignment_time_entry_assert_same(false, $falseSlaFlag['counts_toward_sla'], 'Recognized false-like SLA flags should normalize to false.'); +assignment_time_entry_assert_same(true, $falseSlaFlag['valid'], 'Recognized false-like SLA flags should remain valid.'); + +printf("Assignment and time-entry tests: 12 passed\n"); diff --git a/tests/SlaAgreementTest.php b/tests/SlaAgreementTest.php new file mode 100644 index 0000000..3eb96a7 --- /dev/null +++ b/tests/SlaAgreementTest.php @@ -0,0 +1,133 @@ + 42, + 'enabled' => true, + 'agreement_type' => 'Premium Support', + 'allocated_hours' => 12.5, + 'period_type' => 'annual', + 'start_date' => '2026-01-01', + 'end_date' => '2026-12-31', + 'rollover_enabled' => false, + 'notes' => 'Priority client', +], $agreement->normalize([ + 'client_id' => ' 42 ', + 'enabled' => 'yes', + 'agreement_type' => ' Premium Support ', + 'allocated_hours' => '12.50', + 'period_type' => ' ANNUAL ', + 'start_date' => ' 2026-01-01 ', + 'end_date' => ' 2026-12-31 ', + 'rollover_enabled' => 'off', + 'notes' => ' Priority client ', +]), 'SLA agreement fields should normalize deterministically.'); + +$valid = $agreement->validate([ + 'client_id' => '7', + 'enabled' => '1', + 'allocated_hours' => '0', + 'period_type' => 'monthly', + 'rollover_enabled' => '0', +]); +sla_agreement_assert_same(true, $valid['valid'], 'A minimal SLA agreement should be valid.'); +sla_agreement_assert_same([], $valid['errors'], 'A valid SLA agreement should have no errors.'); +sla_agreement_assert_same(null, $valid['agreement_type'], 'Optional agreement type should normalize to null.'); +sla_agreement_assert_same(null, $valid['start_date'], 'Optional start date should normalize to null.'); +sla_agreement_assert_same(null, $valid['end_date'], 'Optional end date should normalize to null.'); +sla_agreement_assert_same(null, $valid['notes'], 'Optional notes should normalize to null.'); + +$invalid = $agreement->validate([ + 'client_id' => '4.2', + 'enabled' => 'sometimes', + 'agreement_type' => str_repeat('A', 121), + 'allocated_hours' => '-0.01', + 'period_type' => [], + 'start_date' => [], + 'end_date' => [], + 'rollover_enabled' => [], + 'notes' => [], +]); +foreach (['client_id', 'enabled', 'agreement_type', 'allocated_hours', 'period_type', 'start_date', 'end_date', 'rollover_enabled', 'notes'] as $field) { + if (!isset($invalid['errors'][$field])) { + throw new RuntimeException("Expected validation error for {$field}."); + } +} +sla_agreement_assert_same(false, $invalid['valid'], 'Invalid SLA agreement fields should report valid=false.'); + +$reversed = $agreement->validate([ + 'client_id' => 7, + 'allocated_hours' => 10, + 'start_date' => '2026-12-31', + 'end_date' => '2026-01-01', +]); +if (!isset($reversed['errors']['end_date'])) { + throw new RuntimeException('An end date before the start date must be rejected.'); +} + +$badStartOnly = $agreement->validate([ + 'client_id' => 7, + 'allocated_hours' => 'not-a-number', + 'start_date' => 'not-a-date', + 'end_date' => '2026-12-31', +]); +if (!isset($badStartOnly['errors']['allocated_hours'], $badStartOnly['errors']['start_date'])) { + throw new RuntimeException('Non-numeric allocation and malformed dates must be rejected.'); +} +if (isset($badStartOnly['errors']['end_date'])) { + throw new RuntimeException('A valid end date must not receive a range error when the start date is malformed.'); +} + +$equalDates = $agreement->validate([ + 'client_id' => 7, + 'start_date' => '2026-06-01', + 'end_date' => '2026-06-01', +]); +sla_agreement_assert_same(true, $equalDates['valid'], 'Equal start and end dates should be accepted.'); + +$display = $agreement->display([ + 'id' => 9, + 'client_id' => 7, + 'enabled' => true, + 'agreement_type' => 'Premium', + 'allocated_hours' => 12.5, + 'period_type' => 'monthly', + 'start_date' => '2026-01-01', + 'end_date' => '2026-12-31', + 'rollover_enabled' => false, + 'notes' => 'Visible note', + 'password_hash' => 'omit', + 'credentials' => 'omit', + 'internal_token' => 'omit', +]); +sla_agreement_assert_same([ + 'id' => 9, + 'client_id' => 7, + 'enabled' => true, + 'agreement_type' => 'Premium', + 'allocated_hours' => 12.5, + 'period_type' => 'monthly', + 'start_date' => '2026-01-01', + 'end_date' => '2026-12-31', + 'rollover_enabled' => false, + 'notes' => 'Visible note', +], $display, 'Display projection must be explicitly allow-listed.'); +sla_agreement_assert_same($display, $agreement->toDisplay([ + ...$display, + 'credentials' => 'omit', +]), 'toDisplay should provide the same safe projection.'); + +printf("SLA agreement tests: 7 passed\n"); diff --git a/tests/UserRecordTest.php b/tests/UserRecordTest.php new file mode 100644 index 0000000..5bf7cee --- /dev/null +++ b/tests/UserRecordTest.php @@ -0,0 +1,134 @@ +normalize([ + 'name' => ' Alice Example ', + 'email' => ' ALICE@EXAMPLE.TEST ', + 'role_id' => '2', + 'active' => 'yes', +]); +user_record_assert_same([ + 'name' => 'Alice Example', + 'email' => 'alice@example.test', + 'role_id' => 2, + 'is_active' => true, +], $normalized, 'User records should normalize accepted fields deterministically.'); + +$invalid = $service->validate([ + 'name' => ' ', + 'email' => 'not-an-email', + 'role_id' => 0, + 'is_active' => 'sometimes', +]); +user_record_assert_same(false, $invalid['valid'], 'Invalid user records should report valid=false.'); +foreach (['name', 'email', 'role_id', 'is_active'] as $field) { + if (!isset($invalid['errors'][$field])) { + throw new RuntimeException("Expected validation error for {$field}."); + } +} + +$valid = $service->validate([ + 'name' => ' Alice Example ', + 'email' => ' ALICE@EXAMPLE.TEST ', + 'role_id' => '3', + 'is_active' => 'off', +]); +user_record_assert_same(true, $valid['valid'], 'Valid user records should report valid=true.'); +user_record_assert_same([], $valid['errors'], 'Valid user records should contain no field errors.'); +user_record_assert_same(false, $valid['is_active'], 'False form values should normalize to false.'); + +$tooLong = $service->validate([ + 'name' => str_repeat('N', 121), + 'email' => str_repeat('e', 179) . '@example.test', + 'role_id' => 1, +]); +if (!isset($tooLong['errors']['name'], $tooLong['errors']['email'])) { + throw new RuntimeException('Expected schema-sized name and email limits to be enforced.'); +} + +$passwordPolicy = new PasswordPolicy(); +user_record_assert_same(true, $passwordPolicy->validate('Long&Strong123')['valid'], 'A password meeting every strength rule should pass.'); + +$weakPasswords = [ + 'Short1!' => 'minimum length', + 'alllowercase1!' => 'uppercase letter', + 'ALLUPPERCASE1!' => 'lowercase letter', + 'NoDigitsHere!' => 'number', + 'NoSymbolsHere1' => 'symbol', + 'Password123!' => 'common placeholder', + 'Ch@ngeMe123!' => 'common placeholder', +]; +foreach ($weakPasswords as $password => $expectedRule) { + $result = $passwordPolicy->validate($password); + if ($result['valid'] || !in_array($expectedRule, $result['errors'], true)) { + throw new RuntimeException("Expected password '{$password}' to fail the {$expectedRule} rule."); + } +} + +$initial = $service->validateForCreate([ + 'name' => 'New User', + 'email' => 'new.user@example.test', + 'role_id' => 1, + 'password' => 'Password123!', +]); +if ($initial['valid'] || !isset($initial['errors']['password'])) { + throw new RuntimeException('Initial passwords must satisfy the password policy.'); +} +if (array_key_exists('password', $initial)) { + throw new RuntimeException('Validation results must not return a plaintext password.'); +} + +$strongInitial = $service->validateForCreate([ + 'name' => 'New User', + 'email' => 'new.user@example.test', + 'role_id' => 1, + 'password' => 'Unique&Secure123', +]); +user_record_assert_same(true, $strongInitial['valid'], 'A strong initial password should pass user creation validation.'); +user_record_assert_same(false, $passwordPolicy->validateReset('Welcome123!')['valid'], 'Reset passwords must reject common placeholders.'); +user_record_assert_same(true, $passwordPolicy->validateReset('Another$Safe456')['valid'], 'Strong reset passwords should pass.'); + +$display = $service->display([ + 'id' => 42, + 'name' => 'Alice Example', + 'email' => 'alice@example.test', + 'role_id' => 2, + 'role_name' => 'Accounts', + 'is_active' => true, + 'last_login_at' => '2026-09-01 08:00:00', + 'password' => 'plaintext', + 'password_hash' => '$2y$secret', + 'reset_password' => 'reset-secret', + 'reset_token' => 'token-secret', + 'unknown' => 'omit', +]); +user_record_assert_same([ + 'id' => 42, + 'name' => 'Alice Example', + 'email' => 'alice@example.test', + 'role_id' => 2, + 'role_name' => 'Accounts', + 'is_active' => true, + 'last_login_at' => '2026-09-01 08:00:00', +], $display, 'Display data must be explicitly allow-listed and exclude all password material.'); +user_record_assert_same($display, $service->toDisplay([ + ...$display, + 'password_hash' => 'must-not-leak', +]), 'toDisplay should provide the same safe projection.'); + +printf("User record tests: 5 passed\n");