feat: complete jobcard operations and access controls

This commit is contained in:
Marco0300
2026-09-01 20:07:20 +02:00
parent 703c3ca67d
commit 168c15c6ae
12 changed files with 1241 additions and 29 deletions
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
/**
* Validates and normalizes the command used to replace a jobcard's technician assignments.
*/
final class AssignmentValidator
{
/**
* @return array{valid: bool, jobcard_id: ?int, user_ids: list<int>, errors: array<string, string>}
*/
public function validate(array $payload): array
{
$errors = [];
$jobcardId = $this->positiveInteger($payload['jobcard_id'] ?? null);
if ($jobcardId === null) {
$errors['jobcard_id'] = 'Jobcard ID must be a positive integer.';
}
$userIds = [];
$assignments = $payload['user_ids'] ?? null;
if (!is_array($assignments) || $assignments === []) {
$errors['user_ids'] = 'At least one technician ID is required.';
} else {
foreach ($assignments as $userId) {
$normalized = $this->positiveInteger($userId);
if ($normalized === null) {
$errors['user_ids'] = 'Technician IDs must be positive integers.';
continue;
}
if (!in_array($normalized, $userIds, true)) {
$userIds[] = $normalized;
}
}
}
return [
'valid' => $errors === [],
'jobcard_id' => $jobcardId,
'user_ids' => $userIds,
'errors' => $errors,
];
}
private function positiveInteger(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) {
$integer = filter_var($value, FILTER_VALIDATE_INT);
return $integer !== false ? $integer : null;
}
return null;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeEntryValidator.php';
/**
* Validates and normalizes a framework-free command for recording jobcard time.
*/
final class TimeEntryCommand
{
public const MAX_NOTES_LENGTH = 10000;
public function __construct(private readonly ?TimeEntryValidator $timeEntries = null)
{
}
/**
* @return array{valid: bool, jobcard_id: ?int, technician_id: ?int, work_date: string, start_time: ?string, end_time: ?string, hours: ?float, notes: ?string, counts_toward_sla: bool, errors: array<string, string>}
*/
public function validate(array $command): array
{
$jobcardId = $this->positiveInteger($command['jobcard_id'] ?? null);
$technicianId = $this->positiveInteger($command['technician_id'] ?? null);
$workDate = $this->text($command['work_date'] ?? null) ?? '';
$startTime = $this->text($command['start_time'] ?? null);
$endTime = $this->text($command['end_time'] ?? null);
$notes = $this->text($command['notes'] ?? null);
$slaFlag = $this->boolean($command['counts_toward_sla'] ?? true);
$manualHours = $command['hours'] ?? null;
if ($manualHours === '') $manualHours = null;
$entry = ($this->timeEntries ?? new TimeEntryValidator())->validate([
'work_date' => $workDate,
'start_time' => $startTime,
'end_time' => $endTime,
'hours' => $manualHours,
]);
$errors = $entry['errors'];
if ($jobcardId === null) $errors['jobcard_id'] = 'Jobcard ID must be a positive integer.';
if ($technicianId === null) $errors['technician_id'] = 'Technician ID must be a positive integer.';
if ($manualHours !== null && ($startTime !== null || $endTime !== null)) $errors['time'] = 'Manual hours and start/end times cannot both be supplied.';
if ($notes !== null && mb_strlen($notes) > self::MAX_NOTES_LENGTH) $errors['notes'] = 'Notes are too long.';
if ($slaFlag === null) $errors['counts_toward_sla'] = 'SLA flag must be boolean.';
return [
'valid' => $errors === [],
'jobcard_id' => $jobcardId,
'technician_id' => $technicianId,
'work_date' => $workDate,
'start_time' => $startTime,
'end_time' => $endTime,
'hours' => $entry['hours'],
'notes' => $notes,
'counts_toward_sla' => $slaFlag ?? false,
'errors' => $errors,
];
}
private function positiveInteger(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) {
$integer = filter_var($value, FILTER_VALIDATE_INT);
return $integer !== false ? $integer : null;
}
return null;
}
private function boolean(mixed $value): ?bool
{
if (is_bool($value)) return $value;
if ($value === 1 || $value === 0) return $value === 1;
if (is_string($value)) {
return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off', '' => false,
default => null,
};
}
return null;
}
private function text(mixed $value): ?string
{
if (!is_scalar($value)) return null;
$text = trim((string) $value);
return $text === '' ? null : $text;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Domain\SLA;
final class SlaAgreement
{
/** @var list<string> */
private const DISPLAY_FIELDS = [
'id',
'client_id',
'enabled',
'agreement_type',
'allocated_hours',
'period_type',
'start_date',
'end_date',
'rollover_enabled',
'notes',
];
/** @return array{client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */
public function normalize(array $record): array
{
return [
'client_id' => $this->integer($record['client_id'] ?? null),
'enabled' => $this->boolean($record['enabled'] ?? true, true),
'agreement_type' => $this->text($record['agreement_type'] ?? null),
'allocated_hours' => $this->number($record['allocated_hours'] ?? 0),
'period_type' => strtolower($this->text($record['period_type'] ?? null) ?? 'monthly'),
'start_date' => $this->text($record['start_date'] ?? null),
'end_date' => $this->text($record['end_date'] ?? null),
'rollover_enabled' => $this->boolean($record['rollover_enabled'] ?? false, false),
'notes' => $this->text($record['notes'] ?? null),
];
}
/** @return array{valid: bool, errors: array<string, string>, client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if (!$this->positiveInteger($record['client_id'] ?? null)) {
$errors['client_id'] = 'Client ID must be a positive integer.';
}
if (!$this->validBoolean($record['enabled'] ?? true)) {
$errors['enabled'] = 'Enabled must be a boolean value.';
}
if (!$this->nullableScalar($record['agreement_type'] ?? null)) {
$errors['agreement_type'] = 'Agreement type must be text.';
} elseif ($normalized['agreement_type'] !== null && $this->length($normalized['agreement_type']) > 120) {
$errors['agreement_type'] = 'Agreement type must be 120 characters or fewer.';
}
if (!is_numeric($record['allocated_hours'] ?? 0) || !is_finite((float) ($record['allocated_hours'] ?? 0))) {
$errors['allocated_hours'] = 'Allocated hours must be a finite number.';
} elseif ($normalized['allocated_hours'] < 0) {
$errors['allocated_hours'] = 'Allocated hours must not be negative.';
}
if (!$this->nullableScalar($record['period_type'] ?? null)
|| !in_array($normalized['period_type'], ['monthly', 'annual', 'custom'], true)) {
$errors['period_type'] = 'Period type must be monthly, annual, or custom.';
}
if (!$this->nullableScalar($record['start_date'] ?? null)) {
$errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.';
} elseif ($normalized['start_date'] !== null && !$this->validDate($normalized['start_date'])) {
$errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.';
}
if (!$this->nullableScalar($record['end_date'] ?? null)) {
$errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.';
} elseif ($normalized['end_date'] !== null && !$this->validDate($normalized['end_date'])) {
$errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.';
}
if (!isset($errors['start_date'])
&& !isset($errors['end_date'])
&& $normalized['start_date'] !== null
&& $normalized['end_date'] !== null
&& $normalized['start_date'] > $normalized['end_date']) {
$errors['end_date'] = 'End date must not be before start date.';
}
if (!$this->validBoolean($record['rollover_enabled'] ?? false)) {
$errors['rollover_enabled'] = 'Rollover enabled must be a boolean value.';
} elseif ($normalized['rollover_enabled']) {
$errors['rollover_enabled'] = 'Rollover cannot be enabled until rollover rules are configured.';
}
if (!$this->nullableScalar($record['notes'] ?? null)) {
$errors['notes'] = 'Notes must be text.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
private function integer(mixed $value): int
{
$text = is_int($value) || is_string($value) ? trim((string) $value) : '';
return preg_match('/^\d+$/', $text) === 1 ? (int) $text : 0;
}
private function positiveInteger(mixed $value): bool
{
if (is_int($value)) return $value > 0;
if (!is_string($value)) return false;
$value = trim($value);
return preg_match('/^\d+$/', $value) === 1 && (int) $value > 0;
}
private function number(mixed $value): float
{
return is_numeric($value) && is_finite((float) $value) ? (float) $value : 0.0;
}
private function boolean(mixed $value, bool $default): bool
{
if (is_bool($value)) return $value;
if ($value === 1 || $value === 0) return $value === 1;
if (is_string($value)) {
$value = strtolower(trim($value));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true;
if (in_array($value, ['0', 'false', 'no', 'off', ''], true)) return false;
}
return $default;
}
private function validBoolean(mixed $value): bool
{
if (is_bool($value) || $value === 0 || $value === 1) return true;
if (!is_string($value)) return false;
return in_array(strtolower(trim($value)), ['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off', ''], true);
}
private function nullableScalar(mixed $value): bool
{
return $value === null || is_scalar($value);
}
private function validDate(string $value): bool
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = \DateTimeImmutable::getLastErrors();
return $date !== false
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
&& $date->format('Y-m-d') === $value;
}
private function length(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
private function text(mixed $value): ?string
{
if ($value === null) return null;
$text = trim(is_scalar($value) ? (string) $value : '');
return $text === '' ? null : $text;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
/**
* Applies the same password rules to initial credentials and password resets.
* It validates only; callers remain responsible for hashing accepted passwords.
*/
final class PasswordPolicy
{
public const MINIMUM_LENGTH = 12;
/** @return array{valid: bool, errors: list<string>} */
public function validate(mixed $password): array
{
$errors = [];
if (!is_string($password)) {
return ['valid' => false, 'errors' => ['password must be a string']];
}
if (mb_strlen($password) < self::MINIMUM_LENGTH) {
$errors[] = 'minimum length';
}
if (preg_match('/\p{Lu}/u', $password) !== 1) {
$errors[] = 'uppercase letter';
}
if (preg_match('/\p{Ll}/u', $password) !== 1) {
$errors[] = 'lowercase letter';
}
if (preg_match('/\p{N}/u', $password) !== 1) {
$errors[] = 'number';
}
if (preg_match('/[^\p{L}\p{N}\s]/u', $password) !== 1) {
$errors[] = 'symbol';
}
if ($this->isCommonPlaceholder($password)) {
$errors[] = 'common placeholder';
}
return ['valid' => $errors === [], 'errors' => $errors];
}
/** @return array{valid: bool, errors: list<string>} */
public function validateInitial(mixed $password): array
{
return $this->validate($password);
}
/** @return array{valid: bool, errors: list<string>} */
public function validateReset(mixed $password): array
{
return $this->validate($password);
}
public function isValid(mixed $password): bool
{
return $this->validate($password)['valid'];
}
private function isCommonPlaceholder(string $password): bool
{
$canonical = strtolower($password);
$canonical = strtr($canonical, ['@' => 'a', '$' => 's', '0' => 'o']);
$canonical = preg_replace('/[^a-z0-9]/', '', $canonical) ?? '';
return preg_match(
'/^(?:password|changeme|welcome|admin|administrator|temporary|temppassword|qwerty|letmein)[0-9]*$/',
$canonical,
) === 1;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Domain\User;
final class UserRecord
{
/** @var list<string> */
private const DISPLAY_FIELDS = [
'id',
'name',
'email',
'role_id',
'role_name',
'is_active',
'last_login_at',
'created_at',
'updated_at',
];
private readonly PasswordPolicy $passwordPolicy;
public function __construct(?PasswordPolicy $passwordPolicy = null)
{
$this->passwordPolicy = $passwordPolicy ?? new PasswordPolicy();
}
/** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed} */
public function normalize(array $record): array
{
return [
'name' => trim(is_scalar($record['name'] ?? null) ? (string) $record['name'] : ''),
'email' => strtolower(trim(is_scalar($record['email'] ?? null) ? (string) $record['email'] : '')),
'role_id' => $this->normalizeRoleId($record['role_id'] ?? null),
'is_active' => $this->normalizeActive($record['is_active'] ?? $record['active'] ?? true),
];
}
/** @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array<string, string>} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if ($normalized['name'] === '') {
$errors['name'] = 'User name is required.';
} elseif (mb_strlen($normalized['name']) > 120) {
$errors['name'] = 'User name must be 120 characters or fewer.';
}
if ($normalized['email'] === '') {
$errors['email'] = 'Email address is required.';
} elseif (mb_strlen($normalized['email']) > 190) {
$errors['email'] = 'Email address must be 190 characters or fewer.';
} elseif (filter_var($normalized['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'] = 'Email address must be valid.';
}
if (!is_int($normalized['role_id']) || $normalized['role_id'] < 1) {
$errors['role_id'] = 'Role must be a positive integer.';
}
if (!is_bool($normalized['is_active'])) {
$errors['is_active'] = 'Active flag must be boolean.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
/**
* Validates a new user and its initial password without returning the
* plaintext password in the result.
*
* @return array{name: string, email: string, role_id: int|string|null, is_active: bool|mixed, valid: bool, errors: array<string, string>}
*/
public function validateForCreate(array $record): array
{
$result = $this->validate($record);
$password = $this->passwordPolicy->validateInitial($record['password'] ?? null);
if (!$password['valid']) {
$result['errors']['password'] = 'Password requires: ' . implode(', ', $password['errors']) . '.';
$result['valid'] = false;
}
return $result;
}
/** @return array<string, mixed> */
public function display(array $record): array
{
$safe = [];
foreach (self::DISPLAY_FIELDS as $field) {
if (array_key_exists($field, $record)) {
$safe[$field] = $record[$field];
}
}
return $safe;
}
/** @return array<string, mixed> */
public function toDisplay(array $record): array
{
return $this->display($record);
}
private function normalizeRoleId(mixed $value): int|string|null
{
if (is_int($value)) {
return $value;
}
if (is_string($value) && preg_match('/^[0-9]+$/', trim($value)) === 1) {
return (int) trim($value);
}
return is_scalar($value) || $value === null ? $value : null;
}
private function normalizeActive(mixed $value): mixed
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) && ($value === 0 || $value === 1)) {
return $value === 1;
}
if (is_string($value)) {
return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
}
return $value;
}
}