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