feat: complete jobcard management workflows and UI
This commit is contained in:
@@ -16,24 +16,36 @@ final class ContactEditCommand
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateEdit(mixed $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
$result = ($this->contacts ?? new ContactUpdateCommand())->validateForEdit($id, $input, $existingContacts);
|
||||
if ($id < 1) {
|
||||
if ($normalizedId === null) {
|
||||
$result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$result['valid'] = false;
|
||||
} else {
|
||||
$target = $this->find($normalizedId, $existingContacts);
|
||||
if ($target === null) $result['errors']['id'] = 'Contact was not found.';
|
||||
else {
|
||||
$oldClient = $this->id($target['client_id'] ?? null);
|
||||
$newClient = $this->id($input['client_id'] ?? null);
|
||||
if ($oldClient !== null && $newClient !== $oldClient) $result['errors']['client_id'] = 'Contact client ID cannot be changed during edit.';
|
||||
}
|
||||
}
|
||||
$result['id'] = $normalizedId;
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
$result['action'] = 'edit';
|
||||
$result['audit'] = ['event' => 'client_contact_updated', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $result['client_id'] ?? null];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching the create/update command vocabulary. */
|
||||
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateForEdit(mixed $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateEdit($id, $input, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validate(array $input, array $existingContacts = [], ?int $currentId = null): array
|
||||
public function validate(array $input, array $existingContacts = [], mixed $currentId = null): array
|
||||
{
|
||||
return $currentId === null
|
||||
? ($this->contacts ?? new ContactUpdateCommand())->validateForCreate($input, $existingContacts)
|
||||
@@ -45,27 +57,28 @@ final class ContactEditCommand
|
||||
* contact for the same client; deleting the sole contact is not allowed.
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function validateDelete(int $id, array $existingContacts = []): array
|
||||
public function validateDelete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
$errors = [];
|
||||
$target = null;
|
||||
foreach ($existingContacts as $contact) {
|
||||
if (is_array($contact) && $this->id($contact['id'] ?? null) === $id) {
|
||||
if ($normalizedId !== null && is_array($contact) && $this->id($contact['id'] ?? null) === $normalizedId) {
|
||||
$target = $contact;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($id < 1) $errors['id'] = 'Contact ID must be a positive integer.';
|
||||
if ($normalizedId === null) $errors['id'] = 'Contact ID must be a positive integer.';
|
||||
if ($target === null) {
|
||||
$errors['id'] = 'Contact was not found.';
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
|
||||
if ($normalizedId !== null) $errors['id'] = 'Contact was not found.';
|
||||
return ['valid' => false, 'action' => 'delete', 'id' => $normalizedId, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors, 'audit' => null];
|
||||
}
|
||||
$clientId = $this->id($target['client_id'] ?? null);
|
||||
if ($clientId === null) {
|
||||
$errors['client_id'] = 'Contact client ID must be a positive integer.';
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replacement_primary_contact_id' => null, 'errors' => $errors];
|
||||
}
|
||||
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $id));
|
||||
$sameClient = array_values(array_filter($existingContacts, fn ($row): bool => is_array($row) && $this->id($row['client_id'] ?? null) === $clientId && $this->id($row['id'] ?? null) !== $normalizedId));
|
||||
$replacement = null;
|
||||
if (count($sameClient) === 0) {
|
||||
$errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
|
||||
@@ -74,36 +87,37 @@ final class ContactEditCommand
|
||||
$replacement = isset($sameClient[0]) ? $this->id($sameClient[0]['id'] ?? null) : null;
|
||||
if ($replacement === null) $errors['delete'] = 'The only contact cannot be deleted; add another contact first.';
|
||||
}
|
||||
return ['valid' => $errors === [], 'id' => $id, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors];
|
||||
return ['valid' => $errors === [], 'action' => 'delete', 'id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'client_contact_deleted', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'replacement_primary_contact_id' => $replacement] : null];
|
||||
}
|
||||
|
||||
public function validateForDelete(int $id, array $existingContacts = []): array
|
||||
public function validateForDelete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function delete(int $id, array $existingContacts = []): array
|
||||
public function delete(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $existingContacts);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validatePrimary(int $id, array $existingContacts = []): array
|
||||
public function validatePrimary(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
$normalizedId = $this->id($id);
|
||||
foreach ($existingContacts as $row) {
|
||||
if (is_array($row) && $this->id($row['id'] ?? null) === $id) {
|
||||
if ($normalizedId !== null && is_array($row) && $this->id($row['id'] ?? null) === $normalizedId) {
|
||||
$clientId = $this->id($row['client_id'] ?? null);
|
||||
$demote = [];
|
||||
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $id && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
|
||||
return ['valid' => true, 'id' => $id, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => []];
|
||||
foreach ($existingContacts as $other) if (is_array($other) && $this->id($other['client_id'] ?? null) === $clientId && $this->id($other['id'] ?? null) !== $normalizedId && $this->boolean($other['is_primary'] ?? false)) $demote[] = $this->id($other['id'] ?? null);
|
||||
return ['valid' => true, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => $clientId, 'replace_primary_contact_ids' => array_values(array_filter($demote)), 'errors' => [], 'audit' => ['event' => 'client_contact_primary_set', 'entity_type' => 'client_contact', 'entity_id' => $normalizedId, 'client_id' => $clientId, 'demoted_contact_ids' => array_values(array_filter($demote))]];
|
||||
}
|
||||
}
|
||||
return ['valid' => false, 'id' => $id, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => 'Contact was not found.']];
|
||||
return ['valid' => false, 'action' => 'set_primary', 'id' => $normalizedId, 'client_id' => null, 'replace_primary_contact_ids' => [], 'errors' => ['id' => $normalizedId === null ? 'Contact ID must be a positive integer.' : 'Contact was not found.'], 'audit' => null];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function setPrimary(int $id, array $existingContacts = []): array
|
||||
public function setPrimary(mixed $id, array $existingContacts = []): array
|
||||
{
|
||||
return $this->validatePrimary($id, $existingContacts);
|
||||
}
|
||||
@@ -115,4 +129,9 @@ final class ContactEditCommand
|
||||
return null;
|
||||
}
|
||||
private function boolean(mixed $value): bool { return $value === true || $value === 1 || (is_string($value) && in_array(strtolower(trim($value)), ['1','true','yes','on'], true)); }
|
||||
private function find(int $id, array $rows): ?array
|
||||
{
|
||||
foreach ($rows as $row) if (is_array($row) && $this->id($row['id'] ?? null) === $id) return $row;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,12 @@ final class ContactUpdateCommand
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingContacts = []): array
|
||||
public function validateForEdit(mixed $id, array $input, array $existingContacts = []): array
|
||||
{
|
||||
$result = $this->validate($input, $existingContacts, $id);
|
||||
$result['id'] = $id;
|
||||
if ($id < 1) $result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$normalizedId = $this->positiveId($id);
|
||||
$result = $this->validate($input, $existingContacts, $normalizedId);
|
||||
$result['id'] = $normalizedId;
|
||||
if ($normalizedId === null) $result['errors']['id'] = 'Contact ID must be a positive integer.';
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ final class TechnicalInformation
|
||||
public const CATEGORY_SSH = 'ssh';
|
||||
public const CATEGORY_API = 'api';
|
||||
public const CATEGORY_OTHER = 'other';
|
||||
public const CATEGORY_MICROSOFT = 'microsoft';
|
||||
public const CATEGORY_NETWORK = 'network';
|
||||
public const CATEGORY_ROUTER = 'router';
|
||||
public const CATEGORY_INFRASTRUCTURE = 'infrastructure';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function categories(): array
|
||||
{
|
||||
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER];
|
||||
return [self::CATEGORY_HOSTING, self::CATEGORY_VPN, self::CATEGORY_EMAIL, self::CATEGORY_DOMAIN, self::CATEGORY_DATABASE, self::CATEGORY_SSH, self::CATEGORY_API, self::CATEGORY_OTHER, self::CATEGORY_MICROSOFT, self::CATEGORY_NETWORK, self::CATEGORY_ROUTER, self::CATEGORY_INFRASTRUCTURE];
|
||||
}
|
||||
|
||||
/** @return array{category:string, label:string, username:string|null, notes:string|null} */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Storage-agnostic command/service for structured, non-secret technical information.
|
||||
* It validates category-specific JSON, supports safe edits/deletes, and can delegate
|
||||
* persistence to the existing repository or a compatible adapter.
|
||||
*/
|
||||
final class TechnicalInformationCommand
|
||||
{
|
||||
/** @var array<string,list<string>> */
|
||||
private const FIELDS = [
|
||||
'microsoft' => ['label', 'tenant', 'product', 'portal_url', 'username', 'notes'],
|
||||
'network' => ['label', 'hostname', 'ip_address', 'vlan', 'username', 'notes'],
|
||||
'router' => ['label', 'hostname', 'ip_address', 'model', 'username', 'notes'],
|
||||
'infrastructure' => ['label', 'hostname', 'ip_address', 'role', 'os', 'username', 'notes'],
|
||||
];
|
||||
|
||||
/** @return list<string> */
|
||||
public static function categories(): array { return array_keys(self::FIELDS); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validate(array $input): array
|
||||
{
|
||||
$clientId = $this->positiveId($input['client_id'] ?? null);
|
||||
$category = $this->category($input['category'] ?? null);
|
||||
$data = $this->inputData($input);
|
||||
$errors = [];
|
||||
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
|
||||
if ($category === null) $errors['category'] = 'Technical information category is invalid.';
|
||||
$dataResult = $this->validateData($category ?? '', $data);
|
||||
$errors = [...$errors, ...$dataResult['errors']];
|
||||
$record = ['client_id' => $clientId, 'category' => $category, 'data' => $dataResult['data']];
|
||||
return ['valid' => $errors === [], 'record' => $record, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function validateForCreate(array $input): array { return $this->validate($input); }
|
||||
public function validateCreate(array $input): array { return $this->validate($input); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateEdit(array $existing, array $changes): array
|
||||
{
|
||||
$id = $this->positiveId($existing['id'] ?? null);
|
||||
$existingCategory = $this->category($existing['category'] ?? null);
|
||||
$base = ['client_id' => $existing['client_id'] ?? null, 'category' => $existing['category'] ?? null, 'data' => $this->inputData($existing)];
|
||||
if (array_key_exists('client_id', $changes)) $base['client_id'] = $changes['client_id'];
|
||||
$categoryChange = array_key_exists('category', $changes) ? $this->category($changes['category']) : $existingCategory;
|
||||
$categoryChanged = array_key_exists('category', $changes) && $categoryChange !== $existingCategory;
|
||||
$changeData = $this->inputData($changes);
|
||||
$base['data'] = [...$this->inputData($existing), ...$changeData];
|
||||
$result = $this->validate($base);
|
||||
if ($id === null) { $result['errors']['id'] = 'Technical information ID must be a positive integer.'; $result['valid'] = false; }
|
||||
if ($categoryChanged) { $result['errors']['category'] = 'Technical information category cannot be changed during edit.'; $result['valid'] = false; }
|
||||
$result['record']['id'] = $id;
|
||||
return ['valid' => $result['valid'], 'action' => 'edit', 'record' => $result['record'], 'errors' => $result['errors']];
|
||||
}
|
||||
|
||||
public function validateForEdit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); }
|
||||
public function edit(array $existing, array $changes): array { return $this->validateEdit($existing, $changes); }
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateDelete(array $existing): array
|
||||
{
|
||||
$id = $this->positiveId($existing['id'] ?? null);
|
||||
$clientId = $this->positiveId($existing['client_id'] ?? null);
|
||||
$category = $this->category($existing['category'] ?? null);
|
||||
$errors = [];
|
||||
if ($id === null) $errors['id'] = 'Technical information ID must be a positive integer.';
|
||||
if ($clientId === null) $errors['client_id'] = 'Client ID must be a positive integer.';
|
||||
if ($category === null) $errors['category'] = 'Technical information category is invalid.';
|
||||
return ['valid' => $errors === [], 'action' => 'delete', 'id' => $id, 'client_id' => $clientId, 'category' => $category, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function validateForDelete(array $existing): array { return $this->validateDelete($existing); }
|
||||
|
||||
public function delete(array $existing, ?object $repository = null): array
|
||||
{
|
||||
$result = $this->validateDelete($existing);
|
||||
if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information delete: ' . implode(' ', $result['errors']));
|
||||
$repository ??= $this->repository;
|
||||
if ($repository !== null && method_exists($repository, 'delete')) return $repository->delete($result['id']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function display(array $record): array
|
||||
{
|
||||
$category = $this->category($record['category'] ?? null);
|
||||
$data = $this->inputData($record);
|
||||
$safe = [];
|
||||
foreach (['id', 'client_id', 'category'] as $field) if (array_key_exists($field, $record)) $safe[$field] = $record[$field];
|
||||
$safeData = [];
|
||||
foreach (self::FIELDS[$category ?? ''] ?? [] as $field) if (array_key_exists($field, $data)) $safeData[$field] = $data[$field];
|
||||
$safe['data'] = $safeData;
|
||||
return $safe;
|
||||
}
|
||||
|
||||
public function toDisplay(array $record): array { return $this->display($record); }
|
||||
|
||||
/** Persist a validated create through the repository/adapter when supplied. */
|
||||
public function create(int $clientId, array $input, ?int $updatedBy = null, ?object $repository = null): array
|
||||
{
|
||||
if ($repository === null) $repository = $this->repository;
|
||||
if ($repository === null && isset($input['repository']) && is_object($input['repository'])) $repository = $input['repository'];
|
||||
$result = $this->validate([...$input, 'client_id' => $clientId]);
|
||||
$this->throwIfInvalid($result);
|
||||
if ($repository === null) return $result['record'];
|
||||
if (method_exists($repository, 'upsertInformation')) return $repository->upsertInformation($clientId, ['category' => $result['record']['category'], 'data' => $result['record']['data']], $updatedBy);
|
||||
if (method_exists($repository, 'upsert')) return $repository->upsert($clientId, $result['record']['category'], $result['record']['data'], $updatedBy);
|
||||
throw new InvalidArgumentException('Technical information repository adapter is incompatible.');
|
||||
}
|
||||
|
||||
/** Constructor-compatible service form: new TechnicalInformationCommand($repository). */
|
||||
public function __construct(private readonly ?object $repository = null) {}
|
||||
|
||||
public function store(int $clientId, array $input, ?int $updatedBy = null): array { return $this->create($clientId, $input, $updatedBy, $this->repository); }
|
||||
|
||||
public function update(array $existing, array $changes, ?int $updatedBy = null, ?object $repository = null): array
|
||||
{
|
||||
$result = $this->validateEdit($existing, $changes);
|
||||
if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information edit: ' . implode(' ', $result['errors']));
|
||||
$repository ??= $this->repository;
|
||||
if ($repository !== null && method_exists($repository, 'upsert')) return $repository->upsert($result['record']['client_id'], $result['record']['category'], $result['record']['data'], $updatedBy);
|
||||
return $result['record'];
|
||||
}
|
||||
|
||||
/** @return array{data:array<string,mixed>,errors:array<string,string>} */
|
||||
public function validateData(string $category, array $data): array
|
||||
{
|
||||
$allowed = self::FIELDS[$category] ?? [];
|
||||
$normalized = [];
|
||||
$errors = [];
|
||||
foreach ($data as $field => $value) {
|
||||
if (!is_string($field) || !in_array($field, $allowed, true)) { $errors['data.' . (string)$field] = 'This technical-information field is not allowed.'; continue; }
|
||||
if ($field === 'vlan') {
|
||||
if ((is_int($value) || (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1)) && (int)$value >= 1 && (int)$value <= 4094) $normalized[$field] = (int)$value;
|
||||
else $errors['data.vlan'] = 'VLAN must be an integer from 1 to 4094.';
|
||||
continue;
|
||||
}
|
||||
if (!is_scalar($value)) { $errors['data.' . $field] = 'Technical-information fields must be scalar JSON values.'; continue; }
|
||||
$text = trim((string)$value);
|
||||
if ($text === '') continue;
|
||||
if (in_array($field, ['ip_address'], true) && filter_var($text, FILTER_VALIDATE_IP) === false) $errors['data.' . $field] = 'IP address is invalid.';
|
||||
elseif ($field === 'portal_url' && filter_var($text, FILTER_VALIDATE_URL) === false) $errors['data.' . $field] = 'Portal URL is invalid.';
|
||||
elseif (mb_strlen($text) > 1000) $errors['data.' . $field] = 'Technical-information text is too long.';
|
||||
else $normalized[$field] = $text;
|
||||
}
|
||||
if (!isset($normalized['label'])) $errors['data.label'] = 'Technical information label is required.';
|
||||
return ['data' => $normalized, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function inputData(array $input): array
|
||||
{
|
||||
if (isset($input['data']) && is_array($input['data'])) return $input['data'];
|
||||
return array_diff_key($input, array_flip(['id', 'client_id', 'category', 'valid', 'errors', 'action', 'display', 'audit', 'data_json', 'updated_by', 'created_at', 'updated_at']));
|
||||
}
|
||||
private function category(mixed $value): ?string { if (!is_scalar($value)) return null; $value = strtolower(trim((string)$value)); return in_array($value, self::categories(), true) ? $value : null; }
|
||||
private function positiveId(mixed $value): ?int { return is_int($value) && $value > 0 ? $value : (is_string($value) && preg_match('/^[1-9]\d*$/', trim($value)) === 1 ? (int)$value : null); }
|
||||
private function throwIfInvalid(array $result): void { if (!$result['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $result['errors'])); }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
require_once __DIR__ . '/TechnicalInformation.php';
|
||||
require_once __DIR__ . '/TechnicalInformationCommand.php';
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PDO;
|
||||
@@ -33,17 +34,17 @@ final class TechnicalInformationRepository
|
||||
public function upsert(int $clientId, string $category, array $data, ?int $updatedBy = null): array
|
||||
{
|
||||
$this->assertIds($clientId, $updatedBy);
|
||||
$validation = $this->information->validate([...$data, 'category' => $category]);
|
||||
if (!$validation['valid']) {
|
||||
throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
$normalizedCategory = strtolower(trim($category));
|
||||
if (in_array($normalizedCategory, TechnicalInformationCommand::categories(), true)) {
|
||||
$validation = (new TechnicalInformationCommand())->validate(['client_id' => $clientId, 'category' => $normalizedCategory, 'data' => $data]);
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
$jsonData = $validation['record']['data'];
|
||||
} else {
|
||||
$validation = $this->information->validate([...$data, 'category' => $category]);
|
||||
if (!$validation['valid']) throw new InvalidArgumentException('Invalid technical information: ' . implode(' ', $validation['errors']));
|
||||
$normalizedCategory = $validation['category'];
|
||||
$jsonData = ['label' => $validation['label'], 'username' => $validation['username'], 'notes' => $validation['notes']];
|
||||
}
|
||||
|
||||
$normalizedCategory = $validation['category'];
|
||||
$jsonData = [
|
||||
'label' => $validation['label'],
|
||||
'username' => $validation['username'],
|
||||
'notes' => $validation['notes'],
|
||||
];
|
||||
$json = json_encode($jsonData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
@@ -73,7 +74,11 @@ final class TechnicalInformationRepository
|
||||
if (!is_string($category)) {
|
||||
throw new InvalidArgumentException('Technical information category is required.');
|
||||
}
|
||||
unset($information['category']);
|
||||
if (isset($information['data']) && is_array($information['data'])) {
|
||||
$information = $information['data'];
|
||||
} else {
|
||||
unset($information['category']);
|
||||
}
|
||||
return $this->upsert($clientId, $category, $information, $updatedBy);
|
||||
}
|
||||
|
||||
@@ -128,6 +133,15 @@ final class TechnicalInformationRepository
|
||||
return $this->display($record);
|
||||
}
|
||||
|
||||
/** Delete by record id; adapters may use the same contract for logical commands. */
|
||||
public function delete(int $id): array
|
||||
{
|
||||
if ($id < 1) throw new InvalidArgumentException('Technical information id must be a positive integer.');
|
||||
$statement = $this->pdo->prepare('DELETE FROM technical_information WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
return ['valid' => true, 'action' => 'delete', 'id' => $id, 'errors' => []];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
private function hydrate(array $row): array
|
||||
{
|
||||
@@ -159,17 +173,19 @@ final class TechnicalInformationRepository
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @return array{label:string,username:string|null,notes:string|null} */
|
||||
/** @return array<string,mixed> */
|
||||
private function decodeData(mixed $json): array
|
||||
{
|
||||
if (!is_string($json) || $json === '') throw new PDOException('Technical information JSON is missing.');
|
||||
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($decoded)) throw new PDOException('Technical information JSON must be an object.');
|
||||
return [
|
||||
'label' => is_string($decoded['label'] ?? null) ? $decoded['label'] : '',
|
||||
'username' => isset($decoded['username']) && is_string($decoded['username']) ? $decoded['username'] : null,
|
||||
'notes' => isset($decoded['notes']) && is_string($decoded['notes']) ? $decoded['notes'] : null,
|
||||
];
|
||||
$allowed = ['label', 'tenant', 'product', 'portal_url', 'hostname', 'ip_address', 'vlan', 'model', 'role', 'os', 'username', 'notes'];
|
||||
$data = [];
|
||||
foreach ($allowed as $field) {
|
||||
if (!array_key_exists($field, $decoded) || !is_scalar($decoded[$field])) continue;
|
||||
$data[$field] = $field === 'vlan' ? (int)$decoded[$field] : (string)$decoded[$field];
|
||||
}
|
||||
return $data + ['label' => '', 'username' => null, 'notes' => null];
|
||||
}
|
||||
|
||||
private function assertIds(int $clientId, ?int $updatedBy): void
|
||||
|
||||
@@ -26,6 +26,7 @@ final class TimeEntryCorrectionCommand
|
||||
if (array_key_exists($field, $changes) && $this->positiveId($changes[$field]) !== $this->positiveId($existing[$field] ?? null)) $errors[$field] = "{$field} cannot be changed during correction.";
|
||||
}
|
||||
$allowed = ['work_date', 'start_time', 'end_time', 'hours', 'notes', 'counts_toward_sla'];
|
||||
foreach (array_keys($changes) as $field) if (!in_array($field, $allowed, true) && !in_array($field, ['jobcard_id', 'technician_id'], true)) $errors[$field] = "{$field} cannot be changed during correction.";
|
||||
$payload = $existing;
|
||||
foreach ($allowed as $field) if (array_key_exists($field, $changes)) $payload[$field] = $changes[$field];
|
||||
$validated = ($this->entries ?? new TimeEntryCommand())->validate($payload);
|
||||
@@ -33,7 +34,8 @@ final class TimeEntryCorrectionCommand
|
||||
$entry = [...$payload, ...$validated];
|
||||
unset($entry['valid'], $entry['errors']);
|
||||
$entry['id'] = $id;
|
||||
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors];
|
||||
$changed = []; foreach ($allowed as $field) if (array_key_exists($field, $changes) && ($existing[$field] ?? null) !== ($entry[$field] ?? null)) $changed[] = $field;
|
||||
return ['valid' => $errors === [], 'action' => 'correct', 'id' => $id, 'entry' => $entry, 'errors' => $errors, 'audit' => ['event' => 'time_entry_corrected', 'entity_type' => 'time_entry', 'entity_id' => $id, 'changed_fields' => $changed, 'before' => $existing, 'after' => $entry]];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@@ -57,7 +59,7 @@ final class TimeEntryCorrectionCommand
|
||||
$reason = is_scalar($input['reason'] ?? null) ? trim((string)$input['reason']) : '';
|
||||
if ($reason === '') $errors['reason'] = 'A void reason is required.';
|
||||
elseif (mb_strlen($reason) > 1000) $errors['reason'] = 'Void reason must be 1000 characters or fewer.';
|
||||
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors];
|
||||
return ['valid' => $errors === [], 'action' => 'void', 'id' => $id, 'void_reason' => $reason === '' ? null : $reason, 'entry' => $existing, 'errors' => $errors, 'audit' => $errors === [] ? ['event' => 'time_entry_voided', 'entity_type' => 'time_entry', 'entity_id' => $id, 'void_reason' => $reason] : null];
|
||||
}
|
||||
|
||||
public function void(array $existing, array $input = []): array
|
||||
|
||||
@@ -23,8 +23,12 @@ final class NotificationQueue
|
||||
$lookup = $pdo->prepare('SELECT id FROM users WHERE email = :email AND is_active = 1 LIMIT 1');
|
||||
$insert = $pdo->prepare('INSERT INTO notifications (user_id, type, title, body, deduplication_key, read_at) VALUES (:user, :type, :title, :body, :dedup, :read_at) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)');
|
||||
$ids = [];
|
||||
$startedTransaction = false;
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
if (!$pdo->inTransaction()) {
|
||||
$pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
foreach ($validation['recipients'] as $email) {
|
||||
$dto = ($this->records ?? new NotificationRecord())->toQueueDto($record, $email);
|
||||
$lookup->execute(['email' => $email]);
|
||||
@@ -34,9 +38,9 @@ final class NotificationQueue
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
if ($id > 0 && !in_array($id, $ids, true)) $ids[] = $id;
|
||||
}
|
||||
$pdo->commit();
|
||||
if ($startedTransaction) $pdo->commit();
|
||||
} catch (\Throwable $exception) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
if ($startedTransaction && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $exception;
|
||||
}
|
||||
return $ids;
|
||||
@@ -58,4 +62,15 @@ final class NotificationQueue
|
||||
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Mark a notification unread only for the authenticated user's row. */
|
||||
public function markUnread(PDO $pdo, int|string $userId, array $command): bool
|
||||
{
|
||||
$user = filter_var($userId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
$validation = ($this->records ?? new NotificationRecord())->validateMarkUnread($command);
|
||||
if ($user === false || !$validation['valid']) throw new InvalidArgumentException('Invalid mark-unread command.');
|
||||
$stmt = $pdo->prepare('UPDATE notifications SET read_at = NULL WHERE id = :id AND user_id = :user');
|
||||
$stmt->execute(['id' => $validation['notification_id'], 'user' => $user]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ final class NotificationRecord
|
||||
];
|
||||
}
|
||||
|
||||
/** Validate the command used by a user to mark one notification read. */
|
||||
/** Validate the command used by a user to change one notification's read state. */
|
||||
public function validateMarkRead(array $command): array
|
||||
{
|
||||
$value = $command['notification_id'] ?? $command['id'] ?? null;
|
||||
@@ -46,6 +46,12 @@ final class NotificationRecord
|
||||
return ['valid' => $errors === [], 'notification_id' => $notificationId, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Alias with an explicit command name for callers that mark notifications unread. */
|
||||
public function validateMarkUnread(array $command): array
|
||||
{
|
||||
return $this->validateMarkRead($command);
|
||||
}
|
||||
|
||||
/** Return one normalized queue DTO for a single recipient. */
|
||||
public function toQueueDto(array $record, string $recipient): array
|
||||
{
|
||||
@@ -136,6 +142,8 @@ final class NotificationRecord
|
||||
return [
|
||||
'type' => $normalized['type'],
|
||||
'recipients' => $normalized['recipients'],
|
||||
'title' => is_scalar($record['title'] ?? null) ? trim((string) $record['title']) : '',
|
||||
'body' => is_scalar($record['body'] ?? null) && trim((string) $record['body']) !== '' ? trim((string) $record['body']) : null,
|
||||
'is_read' => $normalized['is_read'],
|
||||
'deduplication_key' => $normalized['deduplication_key'],
|
||||
];
|
||||
|
||||
@@ -29,12 +29,33 @@ final class ClientHistoryService
|
||||
return $this->filter($rows, $criteria);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function validateForClient(mixed $clientId, array $rows, array $criteria = []): array
|
||||
{
|
||||
$id = $this->positiveId($clientId);
|
||||
if ($id === null) return ['valid' => false, 'client_id' => null, 'timeline' => [], 'errors' => ['client_id' => 'Client ID must be a positive integer.']];
|
||||
return ['valid' => true, 'client_id' => $id, 'timeline' => $this->forClient($rows, $id, $criteria), 'errors' => []];
|
||||
}
|
||||
|
||||
/** Controller-ready client history timeline command. */
|
||||
public function timeline(array $rows, mixed $clientId, array $criteria = []): array
|
||||
{
|
||||
return $this->validateForClient($clientId, $rows, $criteria);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function history(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function query(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function getHistory(array $rows, array|\ReportFilters|null $criteria = null): array { return $this->filter($rows, $criteria); }
|
||||
|
||||
private function positiveId(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) return $value;
|
||||
if (is_string($value) && preg_match('/^[1-9]\\d*$/', trim($value)) === 1) { $id = filter_var(trim($value), FILTER_VALIDATE_INT); return $id === false ? null : $id; }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ReportFilters.php';
|
||||
require_once __DIR__ . '/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/ReportQuery.php';
|
||||
require_once __DIR__ . '/ReportAudience.php';
|
||||
|
||||
final class HoursPerClientReport
|
||||
/** Deterministic hours aggregation grouped by client. */
|
||||
final class HoursPerClientReport implements ReportQuery
|
||||
{
|
||||
/** @param list<array<string, mixed>> $entries
|
||||
* @return list<array{client_id:int, client_name:string, hours:float}>
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?ReportFilters $filters = null,
|
||||
private readonly ?ReportDataMapper $mapper = null,
|
||||
) {}
|
||||
|
||||
/** @param list<array<string,mixed>> $entries @return list<array<string,mixed>> */
|
||||
public function build(array $entries, string $audience = ReportAudience::CLIENT): array
|
||||
{
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$selected = array_values(array_filter($entries, static fn (array $entry): bool => $filters->matches($entry)));
|
||||
$rows = $this->aggregate($selected);
|
||||
// This report contains no technician or internal-note fields, so the same
|
||||
// stable projection is safe for both audiences.
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $entries @return list<array{client_id:int,client_name:string,hours:float}> */
|
||||
public function aggregate(array $entries): array
|
||||
{
|
||||
$totals = [];
|
||||
@@ -15,20 +34,16 @@ final class HoursPerClientReport
|
||||
$id = (int)($entry['client_id'] ?? 0);
|
||||
$key = (string)$id;
|
||||
if (!isset($totals[$key])) {
|
||||
$totals[$key] = [
|
||||
'client_id' => $id,
|
||||
'client_name' => (string)($entry['client_name'] ?? ''),
|
||||
'hours' => 0.0,
|
||||
];
|
||||
$totals[$key] = ['client_id' => $id, 'client_name' => (string)($entry['client_name'] ?? ''), 'hours' => 0.0];
|
||||
}
|
||||
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
|
||||
}
|
||||
$rows = array_values($totals);
|
||||
foreach ($rows as &$row) {
|
||||
$row['hours'] = round($row['hours'], 2);
|
||||
}
|
||||
foreach ($rows as &$row) $row['hours'] = round($row['hours'], 2);
|
||||
unset($row);
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: ($a['client_id'] <=> $b['client_id']));
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function query(array $entries, string $audience = ReportAudience::CLIENT): array { return $this->build($entries, $audience); }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ final class PrintReportRenderer
|
||||
foreach ($rows as $row) {
|
||||
$body .= '<tr>' . implode('', array_map(fn(mixed $value): string => '<td>' . $this->escape($value) . '</td>', $row)) . '</tr>';
|
||||
}
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>' . $this->escape($title) . '</title><style>body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
|
||||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="format" content="application/pdf"><title>' . $this->escape($title) . '</title><style>@page{size:auto;margin:1.5cm}body{font-family:Arial,sans-serif;color:#222;margin:2rem}h1{font-size:1.5rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #bbb;padding:.5rem;text-align:left;vertical-align:top}th{background:#eee}@media print{body{margin:0}h1{font-size:1.2rem}table{font-size:10pt}thead{display:table-header-group}tr{page-break-inside:avoid}}</style></head><body><main><h1>' . $this->escape($title) . '</h1><table><thead><tr>' . $head . '</tr></thead><tbody>' . $body . '</tbody></table></main></body></html>';
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
|
||||
@@ -20,9 +20,14 @@ final class SlaReport
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$mapper = $this->mapper ?? new ReportDataMapper();
|
||||
// SLA status is derived below; apply all source-field filters first and
|
||||
// apply the SLA filter to the computed status after usage is calculated.
|
||||
$sourceFilters = $filters->sla === null
|
||||
? $filters
|
||||
: new ReportFilters($filters->clientId, $filters->dateFrom, $filters->dateTo, $filters->technicianId, $filters->status, $filters->priority);
|
||||
$rows = [];
|
||||
foreach ($agreements as $agreement) {
|
||||
if (!$filters->matches($agreement)) continue;
|
||||
if (!$sourceFilters->matches($agreement)) continue;
|
||||
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
|
||||
$used = 0.0;
|
||||
foreach ((array)($agreement['hours'] ?? []) as $hours) $used += max(0.0, (float)$hours);
|
||||
@@ -30,6 +35,7 @@ final class SlaReport
|
||||
$remaining = round(max(0.0, $allocated - $used), 2);
|
||||
$percentage = $allocated > 0 ? round(($used / $allocated) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
|
||||
$status = $used > $allocated ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
|
||||
if ($filters->sla !== null && $filters->sla !== $status) continue;
|
||||
$record = [
|
||||
'client_id' => (int)($agreement['client_id'] ?? 0),
|
||||
'client_name' => (string)($agreement['client_name'] ?? ''),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ReportFilters.php';
|
||||
require_once __DIR__ . '/ReportDataMapper.php';
|
||||
require_once __DIR__ . '/ReportQuery.php';
|
||||
require_once __DIR__ . '/ReportAudience.php';
|
||||
|
||||
/** Deterministic technician workload totals, with a client-safe projection. */
|
||||
final class TechnicianWorkloadReport implements ReportQuery
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?ReportFilters $filters = null,
|
||||
private readonly ?ReportDataMapper $mapper = null,
|
||||
) {}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
public function build(array $rows, string $audience = ReportAudience::INTERNAL): array
|
||||
{
|
||||
ReportAudience::validate($audience);
|
||||
$filters = $this->filters ?? new ReportFilters();
|
||||
$totals = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!$filters->matches($row)) continue;
|
||||
$technicianId = (int)($row['technician_id'] ?? 0);
|
||||
$clientId = (int)($row['client_id'] ?? 0);
|
||||
$key = $audience === ReportAudience::CLIENT ? 'client:' . $clientId : 'technician:' . $technicianId;
|
||||
if (!isset($totals[$key])) {
|
||||
$totals[$key] = $audience === ReportAudience::CLIENT
|
||||
? ['client_id' => $clientId, 'client_name' => (string)($row['client_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0]
|
||||
: ['technician_id' => $technicianId, 'technician_name' => (string)($row['technician_name'] ?? ''), 'hours' => 0.0, 'sla_hours' => 0.0];
|
||||
}
|
||||
$hours = max(0.0, (float)($row['hours'] ?? 0));
|
||||
$totals[$key]['hours'] += $hours;
|
||||
if (!empty($row['counts_toward_sla'])) $totals[$key]['sla_hours'] += $hours;
|
||||
}
|
||||
$result = array_values($totals);
|
||||
foreach ($result as &$item) {
|
||||
$item['hours'] = round($item['hours'], 2);
|
||||
$item['sla_hours'] = round($item['sla_hours'], 2);
|
||||
}
|
||||
unset($item);
|
||||
usort($result, static fn (array $a, array $b): int => array_key_exists('client_id', $a)
|
||||
? (strcmp((string)($a['client_name'] ?? ''), (string)($b['client_name'] ?? '')) ?: ((int)$a['client_id'] <=> (int)$b['client_id']))
|
||||
: (strcmp((string)($a['technician_name'] ?? ''), (string)($b['technician_name'] ?? '')) ?: ((int)$a['technician_id'] <=> (int)$b['technician_id'])));
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function query(array $rows, string $audience = ReportAudience::INTERNAL): array { return $this->build($rows, $audience); }
|
||||
}
|
||||
@@ -49,7 +49,24 @@ final class RolePermissionService
|
||||
return ['role_id' => $roleId ?? 0, 'permissions' => $normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** Alias matching controller command terminology. */
|
||||
/** Validate creation of a custom role and its optional permission set. */
|
||||
public function validateForCreate(array $input, array $available = []): array
|
||||
{
|
||||
$record = $this->roles ?? new RoleRecord();
|
||||
$result = $record->validate($input);
|
||||
if ($record->isAdministrator($input)) {
|
||||
$result['errors']['role'] = 'The protected Administrator role cannot be created or renamed.';
|
||||
}
|
||||
if (array_key_exists('permissions', $input)) {
|
||||
$assignment = $this->validateAssignment(['id' => 2, 'name' => $result['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
|
||||
$result['permissions'] = $assignment['permissions'];
|
||||
$result['errors'] = [...$result['errors'], ...$assignment['errors']];
|
||||
}
|
||||
$result['valid'] = $result['errors'] === [];
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{role_id: int, permissions: list<string>, valid: bool, errors: array<string, string>} */
|
||||
public function validateForAssignment(array $role, array $selected, array $available = []): array
|
||||
{
|
||||
return $this->validateAssignment($role, $selected, $available);
|
||||
@@ -68,9 +85,7 @@ final class RolePermissionService
|
||||
$result['id'] = $id;
|
||||
if ($id < 1) $result['errors']['id'] = 'Role ID must be a positive integer.';
|
||||
$current = ['id' => $id, 'name' => $id === 1 ? 'Administrator' : ($input['current_name'] ?? ($input['name'] ?? null))];
|
||||
if (array_key_exists('current_name', $input)) {
|
||||
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
|
||||
}
|
||||
if (!$record->canRename($current, $result['name'])) $result['errors']['role'] = 'The protected Administrator role cannot be renamed.';
|
||||
if (array_key_exists('permissions', $input)) {
|
||||
$assignment = $this->validateAssignment(['id' => $id, 'name' => $current['name']], is_array($input['permissions']) ? $input['permissions'] : [], $available);
|
||||
$result['permissions'] = $assignment['permissions'];
|
||||
@@ -80,6 +95,28 @@ final class RolePermissionService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching edit controller terminology. */
|
||||
public function validateEdit(int $id, array $input, array $available = []): array
|
||||
{
|
||||
return $this->validateForEdit($id, $input, $available);
|
||||
}
|
||||
|
||||
/** Validate deletion of a custom role; ID 1 is always protected. */
|
||||
public function validateDelete(int $id, array $role = []): array
|
||||
{
|
||||
$errors = [];
|
||||
if ($id < 1) $errors['id'] = 'Role ID must be a positive integer.';
|
||||
if ($id === 1 || ($role !== [] && !($this->roles ?? new RoleRecord())->canDelete(['id' => $id, ...$role]))) {
|
||||
$errors['role'] = 'The protected Administrator role cannot be deleted.';
|
||||
}
|
||||
return ['valid' => $errors === [], 'id' => $id, 'errors' => $errors];
|
||||
}
|
||||
|
||||
public function delete(int $id, array $role = []): array
|
||||
{
|
||||
return $this->validateDelete($id, $role);
|
||||
}
|
||||
|
||||
public function canAssignPermissions(array $role): bool
|
||||
{
|
||||
return !($this->roles ?? new RoleRecord())->isAdministrator($role);
|
||||
|
||||
@@ -48,6 +48,7 @@ final class RoleRecord
|
||||
|
||||
public function isAdministrator(array $record): bool
|
||||
{
|
||||
if (isset($record['id']) && (int) $record['id'] === 1) return true;
|
||||
return $this->canonicalName($record['name'] ?? null) === self::ADMINISTRATOR;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,18 @@ final class UserAdminService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Controller-friendly user-create DTO. */
|
||||
public function validateForCreate(array $input, array $existingUsers = []): array
|
||||
{
|
||||
$result = ($this->users ?? new UserRecord())->validateForCreate($input);
|
||||
$email = $result['email'] ?? '';
|
||||
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, 0)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
$result['valid'] = false;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validateForEdit(int $id, array $input, array $existingUsers = []): array
|
||||
{
|
||||
@@ -40,7 +52,7 @@ final class UserAdminService
|
||||
|
||||
$result = ($this->users ?? new UserRecord())->validate($input);
|
||||
$existing = $existingUsers[array_search($id, array_map(static fn($row) => is_array($row) ? (int)($row['id'] ?? 0) : 0, $existingUsers), true)] ?? [];
|
||||
if ($this->isProtectedAdministrator($existing) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== (int)($existing['role_id'] ?? 1)) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
|
||||
if ($this->isProtectedAdministrator(['id' => $id, ...$existing]) && array_key_exists('role_id', $input) && (int)$input['role_id'] !== 1) $result['errors']['role_id'] = 'The protected Administrator account cannot be reassigned.';
|
||||
$email = $result['email'] ?? '';
|
||||
if (is_string($email) && $email !== '' && $this->hasDuplicateEmail($email, $existingUsers, $id)) {
|
||||
$result['errors']['email'] = 'Email address is already in use.';
|
||||
@@ -52,6 +64,12 @@ final class UserAdminService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Alias matching controller command terminology. */
|
||||
public function validateEdit(int $id, array $input, array $existingUsers = []): array
|
||||
{
|
||||
return $this->validateForEdit($id, $input, $existingUsers);
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, id: int, is_active: bool, errors: array<string, string>} */
|
||||
public function validateDeactivate(array $user): array
|
||||
{
|
||||
@@ -121,6 +139,7 @@ final class UserAdminService
|
||||
|
||||
public function isProtectedAdministrator(array $user): bool
|
||||
{
|
||||
if (isset($user['id']) && (int)$user['id'] === 1) return true;
|
||||
if (isset($user['role_id']) && (int)$user['role_id'] === 1) return true;
|
||||
$role = $user['role_name'] ?? $user['role'] ?? null;
|
||||
return is_scalar($role) && strtolower(trim((string) $role)) === 'administrator';
|
||||
|
||||
Reference in New Issue
Block a user