Der Client verglich last_seen_at bislang selbst gegen Date.now() - je nach Client-Zeitzone/-Uhr konnte das falsche Ergebnisse liefern und liess Agenten faelschlich als abwesend erscheinen (Issue #20, Folge von #17). Die API liefert die Anwesenheit jetzt direkt als is_present-Feld mit, berechnet serverseitig via gmdate()/time() (AgentRepository::isPresent()). Das Frontend liest das Feld nur noch aus, ohne eigene Zeit-Arithmetik.
184 lines
6.1 KiB
PHP
184 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Monitor\Api\Repository;
|
|
|
|
use InvalidArgumentException;
|
|
use Monitor\Api\Auth\ApiKeyGenerator;
|
|
use Monitor\Api\Db\Database;
|
|
use Monitor\Api\Http\NotFoundException;
|
|
|
|
final class AgentRepository
|
|
{
|
|
/** @var string[] */
|
|
public const ROLES = ['entwickler', 'product_owner', 'qa', 'user'];
|
|
|
|
/**
|
|
* Praesenz-Timeout (Issue #17/#20): ein Agent gilt nur noch als anwesend, solange
|
|
* last_seen_at hoechstens so lange zurueckliegt. Der Server berechnet daraus das
|
|
* is_present-Feld selbst (gmdate()-basiert, also unabhaengig von der lokalen Zeitzone
|
|
* des Clients) - der Client macht dazu keine eigene Zeit-Arithmetik mehr.
|
|
*/
|
|
private const PRESENCE_TIMEOUT_SECONDS = 600;
|
|
|
|
public function __construct(private readonly Database $db, private readonly int $deskCount = 8)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Registriert einen neuen Agenten und weist ihm bei der Registrierung fest einen
|
|
* Schreibtisch zu (F1/F2: "Agenten erhalten bei der Registrierung eine feste Zuordnung").
|
|
* Die Rolle "user" bekommt nie einen Schreibtisch (kein fester Sitzplatz, F2). Sind alle
|
|
* Schreibtische belegt, registriert sich der Agent trotzdem, aber ohne Schreibtisch
|
|
* ("kommen durch die Tuer, machen ihre Aufgaben und gehen wieder").
|
|
*
|
|
* @return array{agent: array<string, mixed>, api_key: string}
|
|
*/
|
|
public function register(string $displayName, string $role): array
|
|
{
|
|
if (!in_array($role, self::ROLES, true)) {
|
|
throw new InvalidArgumentException('Unbekannte Rolle: ' . $role);
|
|
}
|
|
|
|
$deskIndex = $role === 'user' ? null : $this->findFreeDesk();
|
|
|
|
$apiKey = ApiKeyGenerator::generate();
|
|
$now = $this->db->now();
|
|
|
|
$stmt = $this->db->pdo()->prepare(
|
|
'INSERT INTO agents (display_name, role, api_key_hash, desk_index, state, created_at, last_seen_at)
|
|
VALUES (:display_name, :role, :api_key_hash, :desk_index, :state, :created_at, :created_at)'
|
|
);
|
|
$stmt->execute([
|
|
'display_name' => $displayName,
|
|
'role' => $role,
|
|
'api_key_hash' => ApiKeyGenerator::hash($apiKey),
|
|
'desk_index' => $deskIndex,
|
|
'state' => 'idle',
|
|
'created_at' => $now,
|
|
]);
|
|
|
|
$agent = $this->findById((int) $this->db->pdo()->lastInsertId());
|
|
|
|
return ['agent' => $agent, 'api_key' => $apiKey];
|
|
}
|
|
|
|
private function findFreeDesk(): ?int
|
|
{
|
|
$stmt = $this->db->pdo()->query(
|
|
'SELECT desk_index FROM agents WHERE desk_index IS NOT NULL'
|
|
);
|
|
$occupied = array_map('intval', array_column($stmt->fetchAll(), 'desk_index'));
|
|
|
|
for ($i = 0; $i < $this->deskCount; $i++) {
|
|
if (!in_array($i, $occupied, true)) {
|
|
return $i;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string, mixed>|null */
|
|
public function findById(int $id): ?array
|
|
{
|
|
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE id = :id');
|
|
$stmt->execute(['id' => $id]);
|
|
$row = $stmt->fetch();
|
|
|
|
return $row === false ? null : $this->hidingKey($row);
|
|
}
|
|
|
|
/** @return array<string, mixed>|null */
|
|
public function findByApiKey(string $apiKey): ?array
|
|
{
|
|
$stmt = $this->db->pdo()->prepare('SELECT * FROM agents WHERE api_key_hash = :hash');
|
|
$stmt->execute(['hash' => ApiKeyGenerator::hash($apiKey)]);
|
|
$row = $stmt->fetch();
|
|
|
|
return $row === false ? null : $this->hidingKey($row);
|
|
}
|
|
|
|
/** @return array<int, array<string, mixed>> */
|
|
public function all(): array
|
|
{
|
|
$stmt = $this->db->pdo()->query('SELECT * FROM agents ORDER BY id ASC');
|
|
|
|
return array_map(fn (array $row) => $this->hidingKey($row), $stmt->fetchAll());
|
|
}
|
|
|
|
/**
|
|
* Ein unbekanntes Ticket wuerde sonst erst in der FOREIGN-KEY-Constraint auflaufen und
|
|
* als ungefangene PDOException beim Client landen - deshalb hier pruefen und als
|
|
* regulaeren Validierungsfehler (422) melden.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function updateActivity(int $id, string $state, ?int $currentTicketId): array
|
|
{
|
|
if ($currentTicketId !== null && !$this->ticketExists($currentTicketId)) {
|
|
throw new InvalidArgumentException('Unbekanntes Ticket: ' . $currentTicketId);
|
|
}
|
|
|
|
$stmt = $this->db->pdo()->prepare(
|
|
'UPDATE agents SET state = :state, current_ticket_id = :ticket_id, last_seen_at = :now WHERE id = :id'
|
|
);
|
|
$stmt->execute([
|
|
'state' => $state,
|
|
'ticket_id' => $currentTicketId,
|
|
'now' => $this->db->now(),
|
|
'id' => $id,
|
|
]);
|
|
|
|
$agent = $this->findById($id);
|
|
if ($agent === null) {
|
|
throw new NotFoundException('Agent nicht gefunden: ' . $id);
|
|
}
|
|
|
|
return $agent;
|
|
}
|
|
|
|
private function ticketExists(int $ticketId): bool
|
|
{
|
|
$stmt = $this->db->pdo()->prepare('SELECT 1 FROM tickets WHERE id = :id');
|
|
$stmt->execute(['id' => $ticketId]);
|
|
|
|
return $stmt->fetchColumn() !== false;
|
|
}
|
|
|
|
/** @param array<string, mixed> $row @return array<string, mixed> */
|
|
private function hidingKey(array $row): array
|
|
{
|
|
unset($row['api_key_hash']);
|
|
$row['is_present'] = $this->isPresent($row['last_seen_at'] ?? null);
|
|
|
|
return $row;
|
|
}
|
|
|
|
/**
|
|
* Fehlt last_seen_at oder ist es nicht parsbar, gilt der Agent defensiv als anwesend,
|
|
* statt faelschlich einen leeren Platz zu melden.
|
|
*/
|
|
private function isPresent(?string $lastSeenAt): bool
|
|
{
|
|
if ($lastSeenAt === null) {
|
|
return true;
|
|
}
|
|
|
|
$lastSeenTimestamp = strtotime($lastSeenAt);
|
|
if ($lastSeenTimestamp === false) {
|
|
return true;
|
|
}
|
|
|
|
return (time() - $lastSeenTimestamp) <= self::PRESENCE_TIMEOUT_SECONDS;
|
|
}
|
|
|
|
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
|
public function fingerprint(): string
|
|
{
|
|
$row = $this->db->pdo()->query('SELECT COUNT(*) AS c, MAX(last_seen_at) AS m FROM agents')->fetch();
|
|
|
|
return $row['c'] . ':' . ($row['m'] ?? '');
|
|
}
|
|
}
|