Files

166 lines
10 KiB
PHP
Raw Permalink Normal View History

<?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'])); }
}