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,audit:array} */ 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|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> */ 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 $record @return array */ 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 $record @return array */ public function toDisplay(array $record): array { return $this->display($record); } /** @param array $row @return array */ 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 $record @return array */ 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.'); } }