feat: add administration reporting and correction services
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Credential;
|
||||
|
||||
require_once __DIR__ . '/TechnicalInformation.php';
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* PDO persistence for one non-secret technical-information record per client/category.
|
||||
*
|
||||
* The repository deliberately stores only the validated metadata fields in JSON and
|
||||
* returns an allow-listed display projection alongside audit identifiers.
|
||||
*/
|
||||
final class TechnicalInformationRepository
|
||||
{
|
||||
private TechnicalInformation $information;
|
||||
|
||||
public function __construct(private PDO $pdo, ?TechnicalInformation $information = null)
|
||||
{
|
||||
$this->information = $information ?? new TechnicalInformation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, normalize and atomically upsert a record using the schema's
|
||||
* technical_client_category unique key.
|
||||
*
|
||||
* @return array{id:int,client_id:int,category:string,data:array{label:string,username:string|null,notes:string|null},updated_by:int|null,created_at:string|null,updated_at:string|null,display:array<string,mixed>,audit:array<string,mixed>}
|
||||
*/
|
||||
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 = $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(
|
||||
'INSERT INTO technical_information (client_id, category, data_json, updated_by) '
|
||||
. 'VALUES (:client_id, :category, :data_json, :updated_by) '
|
||||
. 'ON DUPLICATE KEY UPDATE data_json = VALUES(data_json), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
|
||||
);
|
||||
$statement->execute([
|
||||
'client_id' => $clientId,
|
||||
'category' => $normalizedCategory,
|
||||
'data_json' => $json,
|
||||
'updated_by' => $updatedBy,
|
||||
]);
|
||||
|
||||
$record = $this->find($clientId, $normalizedCategory);
|
||||
if ($record === null) {
|
||||
throw new PDOException('Technical information upsert did not produce a readable record.');
|
||||
}
|
||||
|
||||
return $this->withAudit($record, 'technical_information.upserted');
|
||||
}
|
||||
|
||||
/** Convenience form for callers holding category inside the information payload. */
|
||||
public function upsertInformation(int $clientId, array $information, ?int $updatedBy = null): array
|
||||
{
|
||||
$category = $information['category'] ?? null;
|
||||
if (!is_string($category)) {
|
||||
throw new InvalidArgumentException('Technical information category is required.');
|
||||
}
|
||||
unset($information['category']);
|
||||
return $this->upsert($clientId, $category, $information, $updatedBy);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find(int $clientId, string $category): ?array
|
||||
{
|
||||
$this->assertIds($clientId, null);
|
||||
$normalizedCategory = strtolower(trim($category));
|
||||
if (!in_array($normalizedCategory, TechnicalInformation::categories(), true)) {
|
||||
throw new InvalidArgumentException('Credential category is invalid.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
|
||||
. 'FROM technical_information WHERE client_id = :client_id AND category = :category LIMIT 1'
|
||||
);
|
||||
$statement->execute(['client_id' => $clientId, 'category' => $normalizedCategory]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->hydrate($row) : null;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function forClient(int $clientId): array
|
||||
{
|
||||
$this->assertIds($clientId, null);
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, client_id, category, data_json, updated_by, created_at, updated_at '
|
||||
. 'FROM technical_information WHERE client_id = :client_id ORDER BY category ASC, id ASC'
|
||||
);
|
||||
$statement->execute(['client_id' => $clientId]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
return array_map(fn(array $row): array => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record @return array<string,mixed> */
|
||||
public function display(array $record): array
|
||||
{
|
||||
$data = is_array($record['data'] ?? null) ? $record['data'] : $this->decodeData($record['data_json'] ?? null);
|
||||
$display = [];
|
||||
foreach (['id', 'client_id', 'category'] as $field) {
|
||||
if (array_key_exists($field, $record)) $display[$field] = $record[$field];
|
||||
}
|
||||
foreach (['label', 'username', 'notes'] as $field) {
|
||||
if (array_key_exists($field, $data)) $display[$field] = $data[$field];
|
||||
}
|
||||
return $display;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record @return array<string,mixed> */
|
||||
public function toDisplay(array $record): array
|
||||
{
|
||||
return $this->display($record);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @return array<string,mixed> */
|
||||
private function hydrate(array $row): array
|
||||
{
|
||||
$data = $this->decodeData($row['data_json'] ?? null);
|
||||
$record = [
|
||||
'id' => (int) $row['id'],
|
||||
'client_id' => (int) $row['client_id'],
|
||||
'category' => (string) $row['category'],
|
||||
'data' => $data,
|
||||
'updated_by' => $row['updated_by'] === null ? null : (int) $row['updated_by'],
|
||||
'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : null,
|
||||
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
|
||||
];
|
||||
$record['display'] = $this->display($record);
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $record @return array<string,mixed> */
|
||||
private function withAudit(array $record, string $event): array
|
||||
{
|
||||
$record['audit'] = [
|
||||
'event' => $event,
|
||||
'entity_type' => 'technical_information',
|
||||
'entity_id' => $record['id'],
|
||||
'client_id' => $record['client_id'],
|
||||
'category' => $record['category'],
|
||||
'updated_by' => $record['updated_by'],
|
||||
];
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @return array{label:string,username:string|null,notes:string|null} */
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
private function assertIds(int $clientId, ?int $updatedBy): void
|
||||
{
|
||||
if ($clientId < 1) throw new InvalidArgumentException('Client id must be a positive integer.');
|
||||
if ($updatedBy !== null && $updatedBy < 1) throw new InvalidArgumentException('Updated-by id must be a positive integer.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user