feat: complete jobcard operations and access controls
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+14
-1
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/app/Domain/User/PasswordPolicy.php';
|
||||
|
||||
function load_dotenv(string $path): void
|
||||
{
|
||||
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;
|
||||
$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();
|
||||
|
||||
+20
-3
@@ -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';
|
||||
|
||||
+253
-25
@@ -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'); ?><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'];
|
||||
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') {
|
||||
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 '<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>';
|
||||
@@ -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>'; }
|
||||
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>';
|
||||
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>';
|
||||
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 '<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>';
|
||||
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>';
|
||||
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();
|
||||
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 '<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') {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user