feat: complete jobcard management workflows and UI

This commit is contained in:
Marco0300
2026-09-01 21:47:35 +02:00
parent b983f90dcb
commit 9ce08bc6f8
27 changed files with 1081 additions and 115 deletions
@@ -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