> */ 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 */ public static function categories(): array { return array_keys(self::FIELDS); } /** @return array */ 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 */ 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 */ 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 */ 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,errors:array} */ 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 */ 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'])); } }