feat: complete jobcard operations and access controls

This commit is contained in:
Marco0300
2026-09-01 20:07:20 +02:00
parent 703c3ca67d
commit 168c15c6ae
12 changed files with 1241 additions and 29 deletions
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
/**
* Validates and normalizes the command used to replace a jobcard's technician assignments.
*/
final class AssignmentValidator
{
/**
* @return array{valid: bool, jobcard_id: ?int, user_ids: list<int>, errors: array<string, string>}
*/
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;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeEntryValidator.php';
/**
* Validates and normalizes a framework-free command for recording jobcard time.
*/
final class TimeEntryCommand
{
public const MAX_NOTES_LENGTH = 10000;
public function __construct(private readonly ?TimeEntryValidator $timeEntries = null)
{
}
/**
* @return array{valid: bool, jobcard_id: ?int, technician_id: ?int, work_date: string, start_time: ?string, end_time: ?string, hours: ?float, notes: ?string, counts_toward_sla: bool, errors: array<string, string>}
*/
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;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Domain\SLA;
final class SlaAgreement
{
/** @var list<string> */
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<string, string>, 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<string, mixed> */
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<string, mixed> */
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;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
/**
* Applies the same password rules to initial credentials and password resets.
* It validates only; callers remain responsible for hashing accepted passwords.
*/
final class PasswordPolicy
{
public const MINIMUM_LENGTH = 12;
/** @return array{valid: bool, errors: list<string>} */
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<string>} */
public function validateInitial(mixed $password): array
{
return $this->validate($password);
}
/** @return array{valid: bool, errors: list<string>} */
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;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
final class UserRecord
{
/** @var list<string> */
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<string, string>} */
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<string, string>}
*/
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<string, mixed> */
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<string, mixed> */
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;
}
}
+13
View File
@@ -8,12 +8,25 @@ if (PHP_SAPI !== 'cli') {
require_once __DIR__ . '/../config/bootstrap.php'; 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 { try {
$schemaPath = __DIR__ . '/../database/schema.sql'; $schemaPath = __DIR__ . '/../database/schema.sql';
if (!is_readable($schemaPath)) throw new RuntimeException('database/schema.sql is missing or unreadable'); if (!is_readable($schemaPath)) throw new RuntimeException('database/schema.sql is missing or unreadable');
$schema = file_get_contents($schemaPath); $schema = file_get_contents($schemaPath);
if ($schema === false || trim($schema) === '') throw new RuntimeException('database/schema.sql could not be read'); if ($schema === false || trim($schema) === '') throw new RuntimeException('database/schema.sql could not be read');
db()->exec($schema); db()->exec($schema);
apply_existing_database_upgrades(db());
ensure_initial_administrator(); ensure_initial_administrator();
fwrite(STDOUT, "Database schema installed and initial Administrator verified.\n"); fwrite(STDOUT, "Database schema installed and initial Administrator verified.\n");
} catch (Throwable $exception) { } catch (Throwable $exception) {
+14 -1
View File
@@ -1,6 +1,8 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once dirname(__DIR__) . '/app/Domain/User/PasswordPolicy.php';
function load_dotenv(string $path): void function load_dotenv(string $path): void
{ {
if (!is_file($path) || !is_readable($path)) return; if (!is_file($path) || !is_readable($path)) return;
@@ -72,7 +74,8 @@ function ensure_initial_administrator(): void
if ((int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return; if ((int)db()->query('SELECT COUNT(*) FROM users')->fetchColumn() !== 0) return;
$email = strtolower(trim(env_required('ADMIN_EMAIL'))); $email = strtolower(trim(env_required('ADMIN_EMAIL')));
$password = env_required('ADMIN_PASSWORD'); $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(); $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'); 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)'); $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'); } 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 function audit(string $action, string $entityType, ?int $entityId = null, array $metadata = []): void
{ {
$user = current_user(); $user = current_user();
+20 -3
View File
@@ -86,7 +86,7 @@ CREATE TABLE IF NOT EXISTS sla_agreements (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE, 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; ) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS jobcards ( 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 FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB; ) 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 ( CREATE TABLE IF NOT EXISTS time_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jobcard_id BIGINT UNSIGNED NOT NULL, jobcard_id BIGINT UNSIGNED NOT NULL,
@@ -166,6 +178,11 @@ INSERT IGNORE INTO permissions (name, description) VALUES
('clients.manage', 'Create and edit client records'), ('clients.manage', 'Create and edit client records'),
('jobcards.view', 'View jobcards'), ('jobcards.view', 'View jobcards'),
('jobcards.manage', 'Create and update 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.view', 'View reports'),
('reports.export', 'Export reports'), ('reports.export', 'Export reports'),
('users.manage', 'Manage users'), ('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) INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN 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'; WHERE r.name = 'Accounts';
INSERT IGNORE INTO role_permissions (role_id, permission_id) INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r JOIN permissions p ON p.name IN 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'; WHERE r.name = 'Technician';
+253 -25
View File
@@ -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/Client/ClientRecord.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php'; require_once __DIR__ . '/../app/Domain/Jobcard/JobcardReference.php';
require_once __DIR__ . '/../app/Domain/Jobcard/JobcardWorkflow.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'; require_once __DIR__ . '/../app/Domain/Reporting/CsvExporter.php';
ini_set('session.use_strict_mode', '1'); ini_set('session.use_strict_mode', '1');
@@ -73,10 +80,132 @@ if ($route === 'login') {
$user = require_login(); $user = require_login();
if ($route === 'dashboard') { if ($route === 'dashboard') {
require_permission('dashboard.view'); require_permission('dashboard.view');
render_header('Dashboard'); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Dashboard</h1><p class="text-muted mb-0">Your operational overview.</p></div><span class="badge text-bg-primary"><?= e($user['role_name']) ?></span></div><div class="row g-3"><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">New jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Open jobcards</div><div class="display-6 fw-semibold">0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Hours this week</div><div class="display-6 fw-semibold">0.0</div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">SLA warnings</div><div class="display-6 fw-semibold">0</div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Foundation ready</h2><p class="mb-0 text-muted">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.</p></div></div><?php render_footer(); exit; $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();
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'); ?><div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Dashboard</h1><p class="text-muted mb-0">Your operational overview.</p></div><span class="badge text-bg-primary"><?= e($user['role_name']) ?></span></div><div class="row g-3"><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">New jobcards</div><div class="display-6 fw-semibold"><?= (int)($jobcardMetrics['new_count'] ?? 0) ?></div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Open jobcards</div><div class="display-6 fw-semibold"><?= (int)($jobcardMetrics['open_count'] ?? 0) ?></div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">Hours this week</div><div class="display-6 fw-semibold"><?= e(number_format($hoursThisWeek, 2)) ?></div></div></div></div><div class="col-sm-6 col-xl-3"><div class="card metric-card"><div class="card-body"><div class="text-muted small">SLA warnings</div><div class="display-6 fw-semibold"><?= $slaWarnings ?></div></div></div></div></div><div class="card mt-4"><div class="card-body"><h2 class="h5">Operations</h2><p class="mb-0 text-muted">Use Jobcards to manage assignments, status, technician notes and time entries. SLA threshold metrics will activate after period and rollover rules are configured.</p></div></div><?php render_footer(); exit;
} }
$permissionByRoute = ['clients'=>'clients.view','jobcards'=>'jobcards.view','reports'=>'reports.view','users'=>'users.manage','audit'=>'audit.view']; $permissionByRoute = ['clients'=>'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 '<div class="d-flex justify-content-between align-items-start mb-4"><div><a href="/?route=jobcards" class="text-decoration-none">← Back to jobcards</a><h1 class="h3 mt-2 mb-1">' . e($jobcard['reference_no']) . '</h1><p class="text-muted mb-0">' . e($jobcard['client_name']) . '</p></div><span class="badge text-bg-primary">' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</span></div>' . (isset($_GET['updated']) ? '<div class="alert alert-success">Jobcard updated.</div>' : '') . ($actionErrors ? '<div class="alert alert-danger">' . e(implode(' ', $actionErrors)) . '</div>' : '');
echo '<div class="row g-4"><div class="col-lg-8"><div class="card mb-4"><div class="card-body"><h2 class="h5">Work requested</h2><p class="mb-0">' . nl2br(e($jobcard['work_requested'])) . '</p></div></div><div class="card mb-4"><div class="card-body"><h2 class="h5">Work performed and notes</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="notes"><label class="form-label">Technician notes / Work performed</label><textarea class="form-control mb-3" name="technician_notes" rows="5">' . e((string)($jobcard['technician_notes'] ?? '')) . '</textarea>';
if (can('jobcards.internal_notes')) echo '<label class="form-label">Internal notes</label><textarea class="form-control mb-3" name="internal_notes" rows="4">' . e((string)($jobcard['internal_notes'] ?? '')) . '</textarea>';
echo '<button class="btn btn-primary">Save notes</button></form></div></div><div class="card"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">Time entries</h2><strong>' . e(number_format($totalHours, 2)) . ' hours</strong></div>';
foreach ($timeEntries as $entry) echo '<div class="border-bottom py-2"><strong>' . e($entry['technician_name']) . '</strong> · ' . e($entry['work_date']) . ' · ' . e(number_format((float)$entry['hours'], 2)) . 'h<div class="small text-muted">' . e((string)($entry['notes'] ?? '')) . '</div></div>';
if (can('time_entries.record')) { echo '<hr><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="time">'; if ($user['role_name'] !== 'Technician') { echo '<div class="col-md-4"><select class="form-select" name="technician_id" required><option value="">Technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select></div>'; } echo '<div class="col-md-4"><input class="form-control" type="date" name="work_date" value="' . e(date('Y-m-d')) . '" required></div><div class="col-md-4"><input class="form-control" type="number" step="0.01" min="0.01" name="hours" placeholder="Hours"></div><div class="col-md-4 form-check pt-2"><input class="form-check-input" type="checkbox" name="counts_toward_sla" value="1" id="sla-time" checked><label class="form-check-label" for="sla-time">Counts toward SLA</label></div><div class="col-12"><input class="form-control" name="notes" placeholder="Time entry notes"></div><div class="col-12"><button class="btn btn-outline-primary">Add time</button></div></form>'; }
echo '</div></div></div><div class="col-lg-4"><div class="card mb-4"><div class="card-body"><h2 class="h5">Status</h2><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="status"><select class="form-select mb-2" name="status">';
foreach ((new \App\Domain\Jobcard\StatusTransitionValidator())->allowedFrom($jobcard['status']) as $status) echo '<option value="' . e($status) . '"' . ($status === $jobcard['status'] ? ' selected' : '') . '>' . e(ucwords(str_replace('_', ' ', $status))) . '</option>';
echo '</select><button class="btn btn-outline-primary w-100">Update status</button></form></div></div><div class="card"><div class="card-body"><h2 class="h5">Assigned technicians</h2>';
if (!$assigned) echo '<p class="text-muted">No technicians assigned.</p>'; foreach ($assigned as $assignment) echo '<div class="py-1">' . e($assignment['name']) . '</div>';
if (can('jobcards.assign')) { echo '<hr><form method="post"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="assign"><select class="form-select mb-2" name="technician_id"><option value="">Select technician</option>'; foreach ($technicians as $technician) echo '<option value="' . (int)$technician['id'] . '">' . e($technician['name']) . '</option>'; echo '</select><button class="btn btn-outline-primary w-100">Assign</button></form>'; }
echo '</div></div></div></div>';
render_footer(); exit;
}
if ($route === 'jobcards') { if ($route === 'jobcards') {
require_permission('jobcards.view'); require_permission('jobcards.view');
$errors = []; $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 = $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]); $stmt->execute(['reference' => $reference, 'client' => $clientId, 'created_by' => $user['id'], 'priority' => $priority, 'requested' => $workRequested]);
$jobcardId = (int)$pdo->lastInsertId(); $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]); audit('jobcard_created', 'jobcard', $jobcardId, ['reference_no' => $reference]);
$pdo->commit(); $pdo->commit();
header('Location: /?route=jobcards&created=1'); exit; 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(); $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'); render_header('Jobcards');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>'; echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Jobcards</h1><p class="text-muted mb-0">Track requested work and operational status.</p></div>';
if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>'; if (can('jobcards.manage')) echo '<button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-jobcard">New jobcard</button>';
@@ -127,7 +263,7 @@ if ($route === 'jobcards') {
if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; } if (can('jobcards.manage')) { echo '<div class="collapse mb-4" id="new-jobcard"><div class="card"><div class="card-body"><h2 class="h5">Create jobcard</h2><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-6"><label class="form-label" for="jobcard-client">Client</label><select class="form-select" id="jobcard-client" name="client_id" required><option value="">Choose client</option>'; foreach ($clients as $client) echo '<option value="' . (int)$client['id'] . '">' . e($client['name']) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label" for="jobcard-priority">Priority</label><select class="form-select" id="jobcard-priority" name="priority"><option>low</option><option selected>normal</option><option>high</option><option>critical</option></select></div><div class="col-12"><label class="form-label" for="work-requested">Work requested</label><textarea class="form-control" id="work-requested" name="work_requested" rows="4" maxlength="10000" required></textarea></div><div class="col-12"><button class="btn btn-primary">Create jobcard</button></div></form></div></div></div>'; }
echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>'; echo '<div class="card"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead><tr><th>Reference</th><th>Client</th><th>Priority</th><th>Status</th><th>Work requested</th><th>Created</th></tr></thead><tbody>';
if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>'; if (!$jobcards) echo '<tr><td colspan="6" class="text-center text-muted py-4">No jobcards found.</td></tr>';
foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold">' . e($jobcard['reference_no']) . '</td><td>' . e($jobcard['client_name']) . '</td><td>' . e(ucfirst($jobcard['priority'])) . '</td><td>' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</td><td class="text-truncate" style="max-width: 320px">' . e($jobcard['work_requested']) . '</td><td>' . e($jobcard['created_at']) . '</td></tr>'; foreach ($jobcards as $jobcard) echo '<tr><td class="fw-semibold"><a class="text-decoration-none" href="/?route=jobcard&id=' . (int)$jobcard['id'] . '">' . e($jobcard['reference_no']) . '</a></td><td>' . e($jobcard['client_name']) . '</td><td>' . e(ucfirst($jobcard['priority'])) . '</td><td>' . e(ucwords(str_replace('_', ' ', $jobcard['status']))) . '</td><td class="text-truncate" style="max-width: 320px">' . e($jobcard['work_requested']) . '</td><td>' . e($jobcard['created_at']) . '</td></tr>';
echo '</tbody></table></div></div>'; echo '</tbody></table></div></div>';
render_footer(); exit; render_footer(); exit;
} }
@@ -142,40 +278,66 @@ if ($route === 'client') {
if (!$client) { http_response_code(404); exit('Client not found'); } if (!$client) { http_response_code(404); exit('Client not found'); }
$contactErrors = []; $contactErrors = [];
$contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false]; $contactOld = ['name' => '', 'email' => '', 'phone' => '', 'is_primary' => false];
$slaErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_permission('clients.manage');
verify_csrf(); verify_csrf();
$contact = validate_client_contact($_POST); $clientAction = scalar_input($_POST['action'] ?? null, 'contact');
$contactOld = $contact; if ($clientAction === 'sla') {
$contactErrors = $contact['errors']; require_permission('sla.manage');
if ($contactErrors === []) { $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']);
$pdo = db(); $slaErrors = array_values($sla['errors']);
try { if (!$slaErrors) {
$pdo->beginTransaction(); 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']]);
if ($contact['is_primary']) { audit('sla_agreement_updated', 'client', $clientId);
$pdo->prepare('UPDATE client_contacts SET is_primary = 0 WHERE client_id = :client')->execute(['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 = 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]); $contactsStmt->execute(['id' => $clientId]);
$contacts = $contactsStmt->fetchAll(); $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'); render_header('Client details');
echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>'; echo '<div class="d-flex justify-content-between align-items-center mb-4"><div><a href="/?route=clients" class="text-decoration-none">← Back to clients</a><h1 class="h3 mt-2 mb-1">' . e($client['name']) . '</h1><p class="text-muted mb-0">Client profile and support contacts.</p></div><span class="badge text-bg-' . ($client['status'] === 'active' ? 'success' : 'secondary') . '">' . e(ucfirst($client['status'])) . '</span></div>' . (isset($_GET['contact_created']) ? '<div class="alert alert-success">Contact added successfully.</div>' : '') . (isset($_GET['sla_updated']) ? '<div class="alert alert-success">SLA agreement updated.</div>' : '') . ($contactErrors ? '<div class="alert alert-danger">' . e(implode(' ', $contactErrors)) . '</div>' : '') . ($slaErrors ? '<div class="alert alert-danger">' . e(implode(' ', $slaErrors)) . '</div>' : '') . '<div class="row g-4"><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Support information</h2><dl class="row mb-0"><dt class="col-sm-5">Email</dt><dd class="col-sm-7">' . e((string)($client['support_email'] ?? '—')) . '</dd><dt class="col-sm-5">Phone</dt><dd class="col-sm-7">' . e((string)($client['support_phone'] ?? '—')) . '</dd><dt class="col-sm-5">Preferred method</dt><dd class="col-sm-7">' . e((string)($client['preferred_contact_method'] ?? '—')) . '</dd><dt class="col-sm-5">Address</dt><dd class="col-sm-7">' . nl2br(e((string)($client['physical_address'] ?? '—'))) . '</dd></dl></div></div></div><div class="col-lg-6"><div class="card h-100"><div class="card-body"><h2 class="h5">Contacts</h2>';
if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>'; if (!$contacts) echo '<p class="text-muted mb-0">No contacts recorded.</p>';
foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>'; foreach ($contacts as $contact) echo '<div class="border-bottom py-2"><div class="fw-semibold">' . e($contact['name']) . ($contact['is_primary'] ? ' <span class="badge text-bg-primary">Primary</span>' : '') . '</div><div class="small text-muted">' . e((string)($contact['email'] ?? '')) . ' ' . e((string)($contact['phone'] ?? '')) . '</div></div>';
if (can('clients.manage')) echo '<hr><h3 class="h6 mt-3">Add contact</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-12"><input class="form-control" name="name" placeholder="Full name" value="' . e((string)$contactOld['name']) . '" required></div><div class="col-md-6"><input class="form-control" type="email" name="email" placeholder="Email" value="' . e((string)($contactOld['email'] ?? '')) . '"></div><div class="col-md-6"><input class="form-control" name="phone" placeholder="Phone" value="' . e((string)($contactOld['phone'] ?? '')) . '"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="contact-primary"><label class="form-check-label" for="contact-primary">Primary contact</label></div><div class="col-12"><button class="btn btn-sm btn-outline-primary">Add contact</button></div></form>'; if (can('clients.manage')) echo '<hr><h3 class="h6 mt-3">Add contact</h3><form method="post" class="row g-2"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="contact"><div class="col-12"><input class="form-control" name="name" placeholder="Full name" value="' . e((string)$contactOld['name']) . '" required></div><div class="col-md-6"><input class="form-control" type="email" name="email" placeholder="Email" value="' . e((string)($contactOld['email'] ?? '')) . '"></div><div class="col-md-6"><input class="form-control" name="phone" placeholder="Phone" value="' . e((string)($contactOld['phone'] ?? '')) . '"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="contact-primary"><label class="form-check-label" for="contact-primary">Primary contact</label></div><div class="col-12"><button class="btn btn-sm btn-outline-primary">Add contact</button></div></form>';
echo '</div></div></div></div>'; echo '</div></div></div></div>';
if (can('sla.view') || can('sla.manage')) {
echo '<div class="card mt-4"><div class="card-body"><h2 class="h5">SLA agreement</h2>';
if (can('sla.manage')) { echo '<form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><input type="hidden" name="action" value="sla"><div class="col-md-3 form-check ms-2"><input class="form-check-input" type="checkbox" name="enabled" value="1" id="sla-enabled"' . (($slaAgreement['enabled'] ?? true) ? ' checked' : '') . '><label class="form-check-label" for="sla-enabled">Enabled</label></div><div class="col-md-4"><label class="form-label">Agreement type</label><input class="form-control" name="agreement_type" value="' . e((string)($slaAgreement['agreement_type'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">Allocated hours</label><input class="form-control" type="number" min="0" step="0.01" name="allocated_hours" value="' . e((string)($slaAgreement['allocated_hours'] ?? '0')) . '"></div><div class="col-md-3"><label class="form-label">Period</label><select class="form-select" name="period_type">'; foreach (['monthly','annual','custom'] as $period) echo '<option value="' . $period . '"' . (($slaAgreement['period_type'] ?? 'monthly') === $period ? ' selected' : '') . '>' . e(ucfirst($period)) . '</option>'; echo '</select></div><div class="col-md-3"><label class="form-label">Start date</label><input class="form-control" type="date" name="start_date" value="' . e((string)($slaAgreement['start_date'] ?? '')) . '"></div><div class="col-md-3"><label class="form-label">End date</label><input class="form-control" type="date" name="end_date" value="' . e((string)($slaAgreement['end_date'] ?? '')) . '"></div><div class="col-md-3 form-check pt-4"><input class="form-check-input" type="checkbox" name="rollover_enabled" value="1" id="sla-rollover"' . (($slaAgreement['rollover_enabled'] ?? false) ? ' checked' : '') . '><label class="form-check-label" for="sla-rollover">Rollover enabled</label></div><div class="col-12"><label class="form-label">SLA notes</label><textarea class="form-control" name="notes" rows="3">' . e((string)($slaAgreement['notes'] ?? '')) . '</textarea></div><div class="col-12"><button class="btn btn-primary">Save SLA</button></div></form>';
} elseif ($slaAgreement) echo '<dl class="row mb-0"><dt class="col-sm-3">Type</dt><dd class="col-sm-9">' . e((string)($slaAgreement['agreement_type'] ?? '—')) . '</dd><dt class="col-sm-3">Allocation</dt><dd class="col-sm-9">' . e(number_format((float)$slaAgreement['allocated_hours'], 2)) . ' hours / ' . e($slaAgreement['period_type']) . '</dd></dl>';
else echo '<p class="text-muted mb-0">No SLA agreement configured.</p>';
echo '</div></div>';
}
render_footer(); render_footer();
exit; exit;
} }
@@ -219,11 +381,77 @@ if ($route === 'clients') {
exit; 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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use at least 12 characters with upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th></tr></thead><tbody>';
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td></tr>';
echo '</tbody></table></div></div>'; 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 '<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3 mb-1">Users</h1><p class="text-muted mb-0">Create and review system accounts.</p></div><button class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#new-user">New user</button></div>' . (isset($_GET['created']) ? '<div class="alert alert-success">User created successfully.</div>' : '') . ($userErrors ? '<div class="alert alert-danger">' . e(implode(' ', $userErrors)) . '</div>' : '') . '<div class="collapse mb-4" id="new-user"><div class="card"><div class="card-body"><form method="post" class="row g-3"><input type="hidden" name="_csrf" value="' . e(csrf_token()) . '"><div class="col-md-4"><label class="form-label">Name</label><input class="form-control" name="name" required></div><div class="col-md-4"><label class="form-label">Email</label><input class="form-control" type="email" name="email" required></div><div class="col-md-4"><label class="form-label">Role</label><select class="form-select" name="role_id" required><option value="">Choose role</option>'; foreach ($roles as $role) echo '<option value="' . (int)$role['id'] . '">' . e($role['name']) . '</option>'; echo '</select></div><div class="col-md-6"><label class="form-label">Initial password</label><input class="form-control" type="password" name="password" minlength="12" required><div class="form-text">Use upper/lowercase, number and symbol.</div></div><div class="col-12"><button class="btn btn-primary">Create user</button></div></form></div></div></div><div class="card"><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th></tr></thead><tbody>';
foreach ($users as $listedUser) echo '<tr><td>' . e($listedUser['name']) . '</td><td>' . e($listedUser['email']) . '</td><td>' . e($listedUser['role_name']) . '</td><td>' . e($listedUser['is_active'] ? 'Active' : 'Inactive') . '</td><td>' . e((string)($listedUser['last_login_at'] ?? 'Never')) . '</td></tr>';
echo '</tbody></table></div></div>'; render_footer(); exit;
}
if ($route === 'reports') { if ($route === 'reports') {
require_permission('reports.view'); require_permission('reports.view');
$format = scalar_input($_GET['format'] ?? null); $format = scalar_input($_GET['format'] ?? null);
if ($format === 'csv') require_permission('reports.export'); 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); $rows = array_map(static fn (array $row): array => [$row['client_name'], (int)$row['jobcards'], round((float)$row['hours'], 2)], $reportRows);
if ($format === 'csv') { if ($format === 'csv') {
$csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true); $csv = (new CsvExporter())->export(['Client', 'Jobcards', 'Hours'], $rows, true);
+144
View File
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/Jobcard/AssignmentValidator.php';
require_once __DIR__ . '/../app/Domain/Jobcard/TimeEntryCommand.php';
use App\Domain\Jobcard\AssignmentValidator;
use App\Domain\Jobcard\TimeEntryCommand;
function assignment_time_entry_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$assignment = (new AssignmentValidator())->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");
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/SLA/SlaAgreement.php';
use App\Domain\SLA\SlaAgreement;
function sla_agreement_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$agreement = new SlaAgreement();
sla_agreement_assert_same([
'client_id' => 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");
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/Domain/User/PasswordPolicy.php';
require_once __DIR__ . '/../app/Domain/User/UserRecord.php';
use App\Domain\User\PasswordPolicy;
use App\Domain\User\UserRecord;
function user_record_assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true));
}
}
$service = new UserRecord();
$normalized = $service->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");