feat: complete jobcard management workflows and UI
This commit is contained in:
@@ -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'])); }
|
||||
}
|
||||
Reference in New Issue
Block a user