diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d1a01ae
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Kopieren nach .env und Werte anpassen. Wird von docker-compose.yml gelesen.
+
+# Administrator-Key zur Registrierung neuer Agenten (POST /api/v1/agents, Header X-Admin-Key)
+MONITOR_ADMIN_API_KEY=change-me
+
+# Codeberg-Repo, aus dem Backlog/Done per Sync-Skript befuellt werden
+CODEBERG_OWNER=fds2610
+CODEBERG_REPO=monitor
+# Optional: Token mit Lesezugriff, falls Rate-Limits ohne Token zu knapp sind
+CODEBERG_TOKEN=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..997ef86
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+api/vendor/
+api/data/
+.env
diff --git a/API.md b/API.md
new file mode 100644
index 0000000..df612f0
--- /dev/null
+++ b/API.md
@@ -0,0 +1,221 @@
+# monitor REST-API (Phase 1)
+
+Diese API ist die zentrale Schnittstelle, ueber die entfernte Agenten (Menschen oder
+automatisierte Systeme) ihre Aktivitaet melden: Tickets anlegen/verschieben, Events melden,
+Systemstatus berichten, KPIs eintragen und den eigenen Zustand aktualisieren. Sie ist Teil
+von Phase 1 der Neuausrichtung (siehe [Visualization.md](Visualization.md), Abschnitt 9).
+
+Implementierung: Vanilla-PHP (kein Framework), Persistenz in SQLite
+(`api/data/monitor.sqlite`). Details zum lokalen Setup: [api/README.md](api/README.md).
+
+## Inhaltsverzeichnis
+
+- [Grundlagen](#grundlagen)
+- [Authentifizierung](#authentifizierung)
+- [Agenten](#agenten)
+- [Tickets](#tickets)
+- [Events](#events)
+- [Systeme](#systeme)
+- [KPIs](#kpis)
+- [Codeberg-Sync (Backlog/Done)](#codeberg-sync-backlogdone)
+- [Fehlerformat](#fehlerformat)
+
+## Grundlagen
+
+- Basis-Pfad: `/api/v1`
+- Request-/Response-Body: `application/json`
+- Alle Zeitstempel: ISO-8601 UTC (`YYYY-MM-DDTHH:mm:ssZ`), serverseitig erzeugt via
+ `Database::now()`, sofern nicht explizit im Request angegeben.
+- Lesende Endpunkte (`GET`) sind oeffentlich, da sie das Frontend bedienen.
+- Schreibende Endpunkte (`POST`/`PATCH`) erfordern einen Agenten- oder Admin-Key
+ (siehe [Authentifizierung](#authentifizierung)).
+
+## Authentifizierung
+
+Zwei Schluesseltypen, beide statisch (F14):
+
+| Header | Verwendet fuer | Herkunft |
+|---|---|---|
+| `X-Admin-Key` | `POST /api/v1/agents` (Registrierung neuer Agenten) | Umgebungsvariable `MONITOR_ADMIN_API_KEY` |
+| `X-Api-Key` | Alle anderen schreibenden Endpunkte | Wird bei der Agenten-Registrierung einmalig zurueckgegeben |
+
+Der Klartext-API-Key eines Agenten wird **nur einmal** bei der Registrierung ausgegeben.
+Gespeichert wird ausschliesslich ein SHA-256-Hash in SQLite (Tabelle `agents`,
+Spalte `api_key_hash`) - der Klartext ist danach nicht mehr rekonstruierbar und muss vom
+Agenten sicher verwahrt werden.
+
+Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`.
+
+## Agenten
+
+Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `user`. Ein Agent bekommt bei der
+Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen
+(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester
+Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne
+Schreibtisch (`desk_index: null`).
+
+### `POST /api/v1/agents` — Agent registrieren
+
+Auth: `X-Admin-Key`.
+
+```json
+// Request
+{ "display_name": "Alice", "role": "entwickler" }
+```
+
+```json
+// Response 201
+{
+ "agent": {
+ "id": 1, "display_name": "Alice", "role": "entwickler",
+ "desk_index": 0, "state": "idle", "current_ticket_id": null,
+ "created_at": "2026-07-27T10:00:00Z", "last_seen_at": "2026-07-27T10:00:00Z"
+ },
+ "api_key": "‹64 Hex-Zeichen, nur jetzt sichtbar›"
+}
+```
+
+### `GET /api/v1/agents` — Alle Agenten
+
+Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`
+
+### `POST /api/v1/agents/{id}/activity` — Eigenen Zustand melden
+
+Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seinen eigenen Zustand
+melden, sonst `403`).
+
+```json
+// Request
+{ "state": "working", "current_ticket": 42 }
+```
+
+Response 200: aktualisiertes Agent-Objekt.
+
+## Tickets
+
+Spalten (F3): `backlog`, `bereit`, `in_arbeit`, `qa`, `testing`, `fertig`, `done`.
+
+- `backlog` und `done` werden zusaetzlich regelmaessig aus Codeberg-Issues befuellt
+ (siehe [Codeberg-Sync](#codeberg-sync-backlogdone)), alle anderen Bewegungen ergeben
+ sich ausschliesslich aus Agenten-Meldungen (F4).
+- Ticket-Typ und Farbe (statt Prioritaet) werden bei Codeberg-Tickets aus dem ersten
+ Issue-Label uebernommen (F4).
+- Stapel-Darstellung ("aelteste 4 + Rest als Stapel", F4) ist reine Frontend-Logik;
+ die API liefert Tickets dafuer sortiert nach Spalte und Alter.
+
+### `GET /api/v1/tickets` — Board-Zustand
+
+Query-Parameter: `column` (optional, filtert auf eine Spalte).
+
+Response: `{ "tickets": [ { "id", "external_ref", "title", "description", "column_name", "type", "color", "assignee_agent_id", "source", "created_at", "updated_at" }, ... ] }`
+
+### `POST /api/v1/tickets` — Ticket anlegen
+
+Auth: `X-Api-Key`.
+
+```json
+// Request (nur "title" ist Pflicht)
+{ "title": "Neues Bug-Ticket", "description": "...", "column": "backlog", "type": "bug", "color": "#ff0000" }
+```
+
+Response `201` mit dem angelegten Ticket, `422` bei fehlendem `title` oder ungueltiger
+`column`.
+
+### `PATCH /api/v1/tickets/{id}` — Ticket verschieben/aendern
+
+Auth: `X-Api-Key`. Erlaubte Felder im Body: `column`, `title`, `description`, `type`,
+`color`, `assignee_agent_id` (alle optional, nur uebergebene Felder werden geaendert).
+
+Response `200` mit dem aktualisierten Ticket, `404` falls unbekannt, `422` bei ungueltiger
+`column`.
+
+## Events
+
+`severity`: `warning` (gelb) oder `error` (rot). Neuestes Event zuerst (F5).
+
+### `POST /api/v1/events` — Event melden
+
+Auth: `X-Api-Key`.
+
+```json
+{ "system_id": "srv1", "name": "Ping", "message": "Host nicht erreichbar", "severity": "error" }
+```
+
+Pflichtfelder: `name`, `message`, `severity`. `occurred_at` optional (Default: jetzt).
+Response `201` mit dem gespeicherten Event.
+
+### `GET /api/v1/events` — Event-Liste
+
+Query-Parameter: `limit` (Default `5`, entspricht der im Plan festgelegten Zeilenzahl des
+Event-Monitors, F5; Maximum intern 200).
+
+Response: `{ "events": [ { "id", "system_id", "name", "message", "severity", "occurred_at", "created_at" }, ... ] }`
+
+## Systeme
+
+Bis zu 9 Kacheln (F6, `SystemRepository::MAX_TILES`), Fehler zuerst sortiert. Aktuell
+werden Systeme zusaetzlich aus der bestehenden `status.txt` (geschrieben von
+`check_fds.sh`) importiert; per API gemeldete Systeme (`source: "api"`) haben Vorrang und
+werden vom Legacy-Import nicht ueberschrieben.
+
+### `POST /api/v1/systems/{id}/status` — Systemstatus melden
+
+Auth: `X-Api-Key`.
+
+```json
+{ "status": "ok", "name": "Webserver", "type": "host", "detail": "optional" }
+```
+
+`status`: `ok` oder `error`. Response `200` mit dem gespeicherten System, `422` bei
+ungueltigem `status`.
+
+### `GET /api/v1/systems` — Systemuebersicht
+
+Response: `{ "systems": [ { "id", "name", "type", "status", "detail", "source", "last_check_at" }, ... ] }`
+(maximal 9 Eintraege).
+
+## KPIs
+
+F7: Konkrete Kennzahlen sind noch offen ("kommt spaeter"). Phase 1 stellt nur die
+generische Speicher-/Abfragestruktur bereit.
+
+### `POST /api/v1/kpis/{key}` — KPI-Datenpunkt melden
+
+Auth: `X-Api-Key`.
+
+```json
+{ "value": 12.5, "label": "Offene Tickets", "unit": "count", "timestamp": "2026-07-27T10:00:00Z" }
+```
+
+Pflichtfeld: `value` (numerisch). `label`/`unit`/`timestamp` optional. Response `201` mit
+dem KPI inkl. Verlauf.
+
+### `GET /api/v1/kpis` — Alle KPIs
+
+Response: `{ "kpis": [ { "key", "label", "unit", "current_value", "history": [ { "value", "recorded_at" }, ... ] }, ... ] }`
+
+## Codeberg-Sync (Backlog/Done)
+
+`api/bin/sync-codeberg.php` (per Cron oder manuell auszufuehren) liest offene und
+geschlossene Issues aus dem konfigurierten Codeberg-Repo (`CODEBERG_OWNER`/`CODEBERG_REPO`)
+und gleicht sie ueber `external_ref` (= Issue-Nummer) mit den Tickets ab:
+
+- Offene Issues → Spalte `backlog` (nur falls das Ticket noch in `backlog`/`done` ist -
+ manuell auf dem Board bewegte Tickets werden nicht zurueckgesetzt).
+- Geschlossene Issues → Spalte `done`.
+- Ticket-Typ/-Farbe kommen vom ersten Issue-Label (Name/Farbe).
+
+Aufruf via Docker: `docker compose run --rm api php bin/sync-codeberg.php`.
+
+## Fehlerformat
+
+Alle Fehlerantworten haben die Form `{ "error": "‹Meldung›" }` mit passendem HTTP-Status:
+
+| Status | Bedeutung |
+|---|---|
+| `400` | Ungueltige Anfrage (z. B. unbekannter Endpunkt-Parameter) |
+| `401` | Fehlender/ungueltiger `X-Api-Key`/`X-Admin-Key` |
+| `403` | Aktion nicht erlaubt (z. B. fremde Agent-Aktivitaet melden) |
+| `404` | Ressource nicht gefunden |
+| `405` | HTTP-Methode fuer diesen Pfad nicht erlaubt |
+| `422` | Validierungsfehler (Pflichtfeld fehlt, ungueltiger Enum-Wert) |
diff --git a/api/.dockerignore b/api/.dockerignore
new file mode 100644
index 0000000..671b17f
--- /dev/null
+++ b/api/.dockerignore
@@ -0,0 +1,3 @@
+vendor/
+data/
+.git/
diff --git a/api/README.md b/api/README.md
new file mode 100644
index 0000000..13c54f3
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1,22 @@
+# monitor REST-API (Phase 1)
+
+Vanilla-PHP-REST-API (kein Framework, siehe Visualization.md Abschnitt 5) fuer Tickets,
+Events, Systeme, KPIs und Agenten. Persistenz in SQLite (`api/data/monitor.sqlite`).
+
+Vollstaendige Endpunkt-Dokumentation: [../API.md](../API.md).
+
+## Entwicklung nur mit Docker (kein lokales PHP/Composer noetig)
+
+Aus dem Repo-Wurzelverzeichnis:
+
+```bash
+cp .env.example .env # einmalig, MONITOR_ADMIN_API_KEY anpassen
+
+docker compose up --build # startet die API auf http://localhost:8080
+docker compose run --rm api composer test # PHPUnit inkl. Coverage
+docker compose run --rm api php bin/sync-codeberg.php # Backlog/Done aus Codeberg ziehen
+```
+
+Composer-Abhaengigkeiten (nur PHPUnit als Dev-Dependency) werden beim ersten Start
+automatisch **im Container** installiert (`docker/entrypoint.sh`) - auf dem Host ist
+weder PHP noch Composer noch SQLite erforderlich.
diff --git a/api/bin/sync-codeberg.php b/api/bin/sync-codeberg.php
new file mode 100755
index 0000000..61ed31e
--- /dev/null
+++ b/api/bin/sync-codeberg.php
@@ -0,0 +1,26 @@
+#!/usr/bin/env php
+dbPath());
+
+$service = new CodebergSyncService(
+ new CurlCodebergClient($config->codebergApiBase(), $config->codebergToken()),
+ new TicketRepository($db),
+ $config->codebergOwner(),
+ $config->codebergRepo(),
+);
+
+$service->sync();
+
+fwrite(STDOUT, "Codeberg-Sync abgeschlossen.\n");
diff --git a/api/composer.json b/api/composer.json
new file mode 100644
index 0000000..76c54d1
--- /dev/null
+++ b/api/composer.json
@@ -0,0 +1,30 @@
+{
+ "name": "fds2610/monitor-api",
+ "description": "REST-API fuer die Buero-Visualisierung von monitor (Tickets, Events, Systeme, KPIs, Agenten)",
+ "type": "project",
+ "license": "proprietary",
+ "require": {
+ "php": ">=8.1",
+ "ext-pdo": "*",
+ "ext-pdo_sqlite": "*",
+ "ext-json": "*",
+ "ext-curl": "*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^10.5"
+ },
+ "autoload": {
+ "psr-4": {
+ "Monitor\\Api\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Monitor\\Api\\Tests\\": "tests/Unit/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit --coverage-text",
+ "serve": "php -S 0.0.0.0:8080 -t public"
+ }
+}
diff --git a/api/docker/Dockerfile b/api/docker/Dockerfile
new file mode 100644
index 0000000..ade73ef
--- /dev/null
+++ b/api/docker/Dockerfile
@@ -0,0 +1,18 @@
+FROM php:8.2-cli-alpine
+
+RUN apk add --no-cache curl-dev sqlite-dev \
+ && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
+ && docker-php-ext-install pdo pdo_sqlite curl \
+ && pecl install pcov \
+ && docker-php-ext-enable pcov \
+ && apk del .build-deps
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+
+WORKDIR /app
+
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+ENTRYPOINT ["entrypoint.sh"]
+CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]
diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh
new file mode 100755
index 0000000..27a6957
--- /dev/null
+++ b/api/docker/entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Installiert Abhaengigkeiten automatisch beim ersten Start im Container, damit auf dem
+# Host ausser Docker nichts installiert werden muss (composer bleibt im Container).
+set -e
+
+if [ ! -d /app/vendor ]; then
+ composer install --no-interaction --no-progress --working-dir=/app
+fi
+
+exec "$@"
diff --git a/api/phpunit.xml b/api/phpunit.xml
new file mode 100644
index 0000000..b268752
--- /dev/null
+++ b/api/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ tests/Unit
+
+
+
+
+ src
+
+
+
diff --git a/api/public/index.php b/api/public/index.php
new file mode 100644
index 0000000..a603adc
--- /dev/null
+++ b/api/public/index.php
@@ -0,0 +1,17 @@
+dbPath());
+
+$app = new Application($db, $config);
+$response = $app->handle(Request::fromGlobals());
+$response->send();
diff --git a/api/src/Application.php b/api/src/Application.php
new file mode 100644
index 0000000..153056a
--- /dev/null
+++ b/api/src/Application.php
@@ -0,0 +1,113 @@
+deskCount());
+ $tickets = new TicketRepository($db);
+ $events = new EventRepository($db);
+ $systems = new SystemRepository($db);
+ $kpis = new KpiRepository($db);
+
+ $this->agentAuth = new AgentAuthenticator($agents);
+ $this->adminAuth = new AdminAuthenticator($config->adminApiKey());
+
+ $agentController = new AgentController($agents);
+ $ticketController = new TicketController($tickets);
+ $eventController = new EventController($events);
+ $systemController = new SystemController($systems, new LegacyStatusFileReader(), $config->statusFilePath());
+ $kpiController = new KpiController($kpis);
+
+ $this->router = new Router();
+
+ $this->router->add('GET', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->index($r));
+ $this->router->add('POST', '/api/v1/tickets', fn (Request $r, array $p) => $ticketController->create($r), 'agent');
+ $this->router->add('PATCH', '/api/v1/tickets/{id}', fn (Request $r, array $p) => $ticketController->update($r, $p), 'agent');
+
+ $this->router->add('GET', '/api/v1/events', fn (Request $r, array $p) => $eventController->index($r));
+ $this->router->add('POST', '/api/v1/events', fn (Request $r, array $p) => $eventController->create($r), 'agent');
+
+ $this->router->add('GET', '/api/v1/systems', fn (Request $r, array $p) => $systemController->index($r));
+ $this->router->add('POST', '/api/v1/systems/{id}/status', fn (Request $r, array $p) => $systemController->reportStatus($r, $p), 'agent');
+
+ $this->router->add('GET', '/api/v1/kpis', fn (Request $r, array $p) => $kpiController->index($r));
+ $this->router->add('POST', '/api/v1/kpis/{key}', fn (Request $r, array $p) => $kpiController->report($r, $p), 'agent');
+
+ $this->router->add('GET', '/api/v1/agents', fn (Request $r, array $p) => $agentController->index($r));
+ $this->router->add('POST', '/api/v1/agents', fn (Request $r, array $p) => $agentController->register($r), 'admin');
+ $this->router->add(
+ 'POST',
+ '/api/v1/agents/{id}/activity',
+ fn (Request $r, array $p, array $agent) => $agentController->activity($r, $p, $agent),
+ 'agent'
+ );
+ }
+
+ public function handle(Request $request): Response
+ {
+ try {
+ $match = $this->router->match($request);
+ } catch (MethodNotAllowedException) {
+ return Response::error('Methode nicht erlaubt.', 405);
+ }
+
+ if ($match === null) {
+ return Response::error('Nicht gefunden.', 404);
+ }
+
+ $agent = null;
+ if ($match->auth === 'agent') {
+ $agent = $this->agentAuth->authenticate($request);
+ if ($agent === null) {
+ return Response::error('Ungueltiger oder fehlender X-Api-Key-Header.', 401);
+ }
+ } elseif ($match->auth === 'admin') {
+ if (!$this->adminAuth->authenticate($request)) {
+ return Response::error('Ungueltiger oder fehlender X-Admin-Key-Header.', 401);
+ }
+ }
+
+ try {
+ return ($match->handler)($request, $match->params, $agent);
+ } catch (NotFoundException $e) {
+ return Response::error($e->getMessage(), 404);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+ }
+}
diff --git a/api/src/Auth/AdminAuthenticator.php b/api/src/Auth/AdminAuthenticator.php
new file mode 100644
index 0000000..c667b09
--- /dev/null
+++ b/api/src/Auth/AdminAuthenticator.php
@@ -0,0 +1,30 @@
+adminApiKey === null || $this->adminApiKey === '') {
+ return false;
+ }
+
+ $provided = $request->header('x-admin-key');
+
+ return $provided !== null && hash_equals($this->adminApiKey, $provided);
+ }
+}
diff --git a/api/src/Auth/AgentAuthenticator.php b/api/src/Auth/AgentAuthenticator.php
new file mode 100644
index 0000000..3ab7071
--- /dev/null
+++ b/api/src/Auth/AgentAuthenticator.php
@@ -0,0 +1,30 @@
+|null Der authentifizierte Agent, oder null falls ungueltig. */
+ public function authenticate(Request $request): ?array
+ {
+ $apiKey = $request->header('x-api-key');
+ if ($apiKey === null || $apiKey === '') {
+ return null;
+ }
+
+ return $this->agents->findByApiKey($apiKey);
+ }
+}
diff --git a/api/src/Auth/ApiKeyGenerator.php b/api/src/Auth/ApiKeyGenerator.php
new file mode 100644
index 0000000..1a2d892
--- /dev/null
+++ b/api/src/Auth/ApiKeyGenerator.php
@@ -0,0 +1,23 @@
+env[$key] ?? getenv($key);
+ if ($value === false || $value === null || $value === '') {
+ return $default;
+ }
+
+ return (string) $value;
+ }
+
+ public function dbPath(): string
+ {
+ return $this->get('MONITOR_DB_PATH', dirname(__DIR__) . '/data/monitor.sqlite');
+ }
+
+ public function adminApiKey(): ?string
+ {
+ return $this->get('MONITOR_ADMIN_API_KEY');
+ }
+
+ public function statusFilePath(): string
+ {
+ return $this->get('MONITOR_STATUS_FILE', dirname(__DIR__, 2) . '/status.txt');
+ }
+
+ public function codebergApiBase(): string
+ {
+ return rtrim((string) $this->get('CODEBERG_API_BASE', 'https://codeberg.org/api/v1'), '/');
+ }
+
+ public function codebergOwner(): string
+ {
+ return (string) $this->get('CODEBERG_OWNER', 'fds2610');
+ }
+
+ public function codebergRepo(): string
+ {
+ return (string) $this->get('CODEBERG_REPO', 'monitor');
+ }
+
+ public function codebergToken(): ?string
+ {
+ return $this->get('CODEBERG_TOKEN');
+ }
+
+ public function deskCount(): int
+ {
+ return (int) $this->get('MONITOR_DESK_COUNT', '8');
+ }
+}
diff --git a/api/src/Controller/AgentController.php b/api/src/Controller/AgentController.php
new file mode 100644
index 0000000..d8e81ab
--- /dev/null
+++ b/api/src/Controller/AgentController.php
@@ -0,0 +1,63 @@
+ $this->agents->all()]);
+ }
+
+ public function register(Request $request): Response
+ {
+ $displayName = trim((string) ($request->body['display_name'] ?? ''));
+ $role = (string) ($request->body['role'] ?? '');
+
+ if ($displayName === '') {
+ return Response::error('Feld "display_name" ist erforderlich.', 422);
+ }
+
+ try {
+ $result = $this->agents->register($displayName, $role);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+
+ // Der Klartext-API-Key wird nur bei der Registrierung einmalig zurueckgegeben.
+ return Response::json(['agent' => $result['agent'], 'api_key' => $result['api_key']], 201);
+ }
+
+ /**
+ * @param array $params
+ * @param array $authenticatedAgent
+ */
+ public function activity(Request $request, array $params, array $authenticatedAgent): Response
+ {
+ if ((int) $params['id'] !== (int) $authenticatedAgent['id']) {
+ return Response::error('Ein Agent darf nur seine eigene Aktivitaet melden.', 403);
+ }
+
+ $state = (string) ($request->body['state'] ?? '');
+ if ($state === '') {
+ return Response::error('Feld "state" ist erforderlich.', 422);
+ }
+
+ $currentTicketId = isset($request->body['current_ticket']) ? (int) $request->body['current_ticket'] : null;
+
+ $agent = $this->agents->updateActivity((int) $params['id'], $state, $currentTicketId);
+
+ return Response::json(['agent' => $agent]);
+ }
+}
diff --git a/api/src/Controller/EventController.php b/api/src/Controller/EventController.php
new file mode 100644
index 0000000..03409b0
--- /dev/null
+++ b/api/src/Controller/EventController.php
@@ -0,0 +1,41 @@
+query['limit']) ? (int) $request->query['limit'] : EventRepository::DEFAULT_LIMIT;
+
+ return Response::json(['events' => $this->events->latest($limit)]);
+ }
+
+ public function create(Request $request): Response
+ {
+ foreach (['name', 'message', 'severity'] as $field) {
+ if (!isset($request->body[$field]) || trim((string) $request->body[$field]) === '') {
+ return Response::error(sprintf('Feld "%s" ist erforderlich.', $field), 422);
+ }
+ }
+
+ try {
+ $event = $this->events->create($request->body);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+
+ return Response::json(['event' => $event], 201);
+ }
+}
diff --git a/api/src/Controller/KpiController.php b/api/src/Controller/KpiController.php
new file mode 100644
index 0000000..fc209a8
--- /dev/null
+++ b/api/src/Controller/KpiController.php
@@ -0,0 +1,39 @@
+ $this->kpis->all()]);
+ }
+
+ /** @param array $params */
+ public function report(Request $request, array $params): Response
+ {
+ if (!isset($request->body['value']) || !is_numeric($request->body['value'])) {
+ return Response::error('Feld "value" ist erforderlich und muss numerisch sein.', 422);
+ }
+
+ $kpi = $this->kpis->report(
+ $params['key'],
+ (float) $request->body['value'],
+ isset($request->body['label']) ? (string) $request->body['label'] : null,
+ isset($request->body['unit']) ? (string) $request->body['unit'] : null,
+ isset($request->body['timestamp']) ? (string) $request->body['timestamp'] : null,
+ );
+
+ return Response::json(['kpi' => $kpi], 201);
+ }
+}
diff --git a/api/src/Controller/SystemController.php b/api/src/Controller/SystemController.php
new file mode 100644
index 0000000..5c7096a
--- /dev/null
+++ b/api/src/Controller/SystemController.php
@@ -0,0 +1,40 @@
+systems->importLegacyRows($this->legacyReader->read($this->statusFilePath));
+
+ return Response::json(['systems' => $this->systems->all()]);
+ }
+
+ /** @param array $params */
+ public function reportStatus(Request $request, array $params): Response
+ {
+ try {
+ $system = $this->systems->reportStatus($params['id'], $request->body);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+
+ return Response::json(['system' => $system]);
+ }
+}
diff --git a/api/src/Controller/TicketController.php b/api/src/Controller/TicketController.php
new file mode 100644
index 0000000..28dcbde
--- /dev/null
+++ b/api/src/Controller/TicketController.php
@@ -0,0 +1,55 @@
+query['column'] ?? null;
+
+ try {
+ return Response::json(['tickets' => $this->tickets->all($column !== null ? (string) $column : null)]);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 400);
+ }
+ }
+
+ public function create(Request $request): Response
+ {
+ if (!isset($request->body['title']) || trim((string) $request->body['title']) === '') {
+ return Response::error('Feld "title" ist erforderlich.', 422);
+ }
+
+ try {
+ $ticket = $this->tickets->create($request->body);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+
+ return Response::json(['ticket' => $ticket], 201);
+ }
+
+ /** @param array $params */
+ public function update(Request $request, array $params): Response
+ {
+ try {
+ $ticket = $this->tickets->update((int) $params['id'], $request->body);
+ } catch (InvalidArgumentException $e) {
+ return Response::error($e->getMessage(), 422);
+ }
+
+ return Response::json(['ticket' => $ticket]);
+ }
+}
diff --git a/api/src/Db/Database.php b/api/src/Db/Database.php
new file mode 100644
index 0000000..238f3a0
--- /dev/null
+++ b/api/src/Db/Database.php
@@ -0,0 +1,66 @@
+pdo = new PDO($dsn);
+ $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ $this->pdo->exec('PRAGMA foreign_keys = ON');
+ }
+
+ public static function forFile(string $path): self
+ {
+ $dir = dirname($path);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0775, true);
+ }
+
+ $db = new self('sqlite:' . $path);
+ $db->migrate();
+
+ return $db;
+ }
+
+ public static function inMemory(): self
+ {
+ $db = new self('sqlite::memory:');
+ $db->migrate();
+
+ return $db;
+ }
+
+ public function migrate(): void
+ {
+ $migrationsDir = __DIR__ . '/migrations';
+ $files = glob($migrationsDir . '/*.sql') ?: [];
+ sort($files);
+
+ foreach ($files as $file) {
+ $this->pdo->exec((string) file_get_contents($file));
+ }
+ }
+
+ public function pdo(): PDO
+ {
+ return $this->pdo;
+ }
+
+ public function now(): string
+ {
+ return gmdate('Y-m-d\TH:i:s\Z');
+ }
+}
diff --git a/api/src/Db/migrations/001_initial.sql b/api/src/Db/migrations/001_initial.sql
new file mode 100644
index 0000000..8ada7f7
--- /dev/null
+++ b/api/src/Db/migrations/001_initial.sql
@@ -0,0 +1,74 @@
+-- Initiales Schema fuer die monitor-REST-API (Phase 1).
+-- Enum-artige Spalten werden per CHECK-Constraint abgesichert, da SQLite keine echten
+-- ENUM-Typen kennt. Alle Zeitangaben werden als ISO-8601-UTC-Strings gespeichert.
+
+CREATE TABLE IF NOT EXISTS agents (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ display_name TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN ('entwickler', 'product_owner', 'qa', 'user')),
+ api_key_hash TEXT NOT NULL UNIQUE,
+ desk_index INTEGER,
+ state TEXT NOT NULL DEFAULT 'idle',
+ current_ticket_id INTEGER,
+ created_at TEXT NOT NULL,
+ last_seen_at TEXT,
+ FOREIGN KEY (current_ticket_id) REFERENCES tickets (id)
+);
+
+CREATE TABLE IF NOT EXISTS tickets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ external_ref TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ column_name TEXT NOT NULL
+ CHECK (column_name IN ('backlog', 'bereit', 'in_arbeit', 'qa', 'testing', 'fertig', 'done')),
+ type TEXT,
+ color TEXT,
+ assignee_agent_id INTEGER,
+ source TEXT NOT NULL DEFAULT 'api' CHECK (source IN ('api', 'codeberg')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (assignee_agent_id) REFERENCES agents (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_tickets_column ON tickets (column_name);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_external_ref ON tickets (external_ref)
+ WHERE external_ref IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ system_id TEXT,
+ name TEXT NOT NULL,
+ message TEXT NOT NULL,
+ severity TEXT NOT NULL CHECK (severity IN ('warning', 'error')),
+ occurred_at TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events (occurred_at DESC);
+
+CREATE TABLE IF NOT EXISTS systems (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT 'host',
+ status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
+ detail TEXT,
+ source TEXT NOT NULL CHECK (source IN ('legacy', 'api')),
+ last_check_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS kpis (
+ key TEXT PRIMARY KEY,
+ label TEXT,
+ unit TEXT
+);
+
+CREATE TABLE IF NOT EXISTS kpi_values (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kpi_key TEXT NOT NULL,
+ value REAL NOT NULL,
+ recorded_at TEXT NOT NULL,
+ FOREIGN KEY (kpi_key) REFERENCES kpis (key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_kpi_values_key ON kpi_values (kpi_key, recorded_at DESC);
diff --git a/api/src/Http/MethodNotAllowedException.php b/api/src/Http/MethodNotAllowedException.php
new file mode 100644
index 0000000..ab094e9
--- /dev/null
+++ b/api/src/Http/MethodNotAllowedException.php
@@ -0,0 +1,9 @@
+ $headers */
+ private function __construct(
+ public readonly string $method,
+ public readonly string $path,
+ public readonly array $query,
+ public readonly array $body,
+ private readonly array $headers,
+ ) {
+ }
+
+ public static function fromGlobals(): self
+ {
+ $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
+ $uri = $_SERVER['REQUEST_URI'] ?? '/';
+ $path = rtrim((string) parse_url($uri, PHP_URL_PATH), '/');
+ if ($path === '') {
+ $path = '/';
+ }
+
+ parse_str((string) (parse_url($uri, PHP_URL_QUERY) ?? ''), $query);
+
+ $rawBody = file_get_contents('php://input') ?: '';
+ $body = [];
+ if ($rawBody !== '') {
+ $decoded = json_decode($rawBody, true);
+ if (is_array($decoded)) {
+ $body = $decoded;
+ }
+ }
+
+ $headers = [];
+ foreach ($_SERVER as $key => $value) {
+ if (str_starts_with($key, 'HTTP_')) {
+ $name = str_replace('_', '-', strtolower(substr($key, 5)));
+ $headers[$name] = (string) $value;
+ }
+ }
+ if (isset($_SERVER['CONTENT_TYPE'])) {
+ $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
+ }
+
+ return new self($method, $path, $query, $body, $headers);
+ }
+
+ /** @param array $body */
+ public static function create(string $method, string $path, array $query = [], array $body = [], array $headers = []): self
+ {
+ $normalized = [];
+ foreach ($headers as $name => $value) {
+ $normalized[strtolower($name)] = $value;
+ }
+
+ return new self($method, rtrim($path, '/') ?: '/', $query, $body, $normalized);
+ }
+
+ public function header(string $name): ?string
+ {
+ return $this->headers[strtolower($name)] ?? null;
+ }
+}
diff --git a/api/src/Http/Response.php b/api/src/Http/Response.php
new file mode 100644
index 0000000..9b60c7b
--- /dev/null
+++ b/api/src/Http/Response.php
@@ -0,0 +1,38 @@
+ $message]);
+ }
+
+ public function send(): void
+ {
+ http_response_code($this->status);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+
+ public function body(): mixed
+ {
+ return $this->data;
+ }
+}
diff --git a/api/src/Http/Router.php b/api/src/Http/Router.php
new file mode 100644
index 0000000..587f8f1
--- /dev/null
+++ b/api/src/Http/Router.php
@@ -0,0 +1,73 @@
+ $params */
+ public function __construct(
+ public readonly \Closure $handler,
+ public readonly array $params,
+ public readonly ?string $auth,
+ ) {
+ }
+}
+
+/**
+ * Minimalistischer Pfad-Router (kein Framework). Unterstuetzt Platzhalter der Form
+ * "{id}" innerhalb eines Segments.
+ */
+final class Router
+{
+ /** @var array */
+ private array $routes = [];
+
+ public function add(string $method, string $pattern, \Closure $handler, ?string $auth = null): void
+ {
+ $paramNames = [];
+ $regex = preg_replace_callback('/\{(\w+)\}/', function (array $m) use (&$paramNames): string {
+ $paramNames[] = $m[1];
+ return '(?P<' . $m[1] . '>[^/]+)';
+ }, $pattern);
+
+ $this->routes[] = [
+ 'method' => strtoupper($method),
+ 'pattern' => $pattern,
+ 'regex' => '#^' . $regex . '$#',
+ 'params' => $paramNames,
+ 'handler' => $handler,
+ 'auth' => $auth,
+ ];
+ }
+
+ public function match(Request $request): ?RouteMatch
+ {
+ $methodMatchedElsewhere = false;
+
+ foreach ($this->routes as $route) {
+ if (!preg_match($route['regex'], $request->path, $matches)) {
+ continue;
+ }
+
+ if ($route['method'] !== $request->method) {
+ $methodMatchedElsewhere = true;
+ continue;
+ }
+
+ $params = [];
+ foreach ($route['params'] as $name) {
+ $params[$name] = $matches[$name];
+ }
+
+ return new RouteMatch($route['handler'], $params, $route['auth']);
+ }
+
+ if ($methodMatchedElsewhere) {
+ throw new MethodNotAllowedException();
+ }
+
+ return null;
+ }
+}
diff --git a/api/src/Repository/AgentRepository.php b/api/src/Repository/AgentRepository.php
new file mode 100644
index 0000000..f060482
--- /dev/null
+++ b/api/src/Repository/AgentRepository.php
@@ -0,0 +1,131 @@
+, 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|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|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> */
+ 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());
+ }
+
+ /** @return array */
+ public function updateActivity(int $id, string $state, ?int $currentTicketId): array
+ {
+ $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;
+ }
+
+ /** @param array $row @return array */
+ private function hidingKey(array $row): array
+ {
+ unset($row['api_key_hash']);
+
+ return $row;
+ }
+}
diff --git a/api/src/Repository/EventRepository.php b/api/src/Repository/EventRepository.php
new file mode 100644
index 0000000..ba62ddb
--- /dev/null
+++ b/api/src/Repository/EventRepository.php
@@ -0,0 +1,73 @@
+ $data @return array */
+ public function create(array $data): array
+ {
+ $severity = $data['severity'] ?? null;
+ if (!in_array($severity, self::SEVERITIES, true)) {
+ throw new InvalidArgumentException('Ungueltige severity: ' . var_export($severity, true));
+ }
+
+ $now = $this->db->now();
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO events (system_id, name, message, severity, occurred_at, created_at)
+ VALUES (:system_id, :name, :message, :severity, :occurred_at, :created_at)'
+ );
+ $stmt->execute([
+ 'system_id' => $data['system_id'] ?? null,
+ 'name' => $data['name'],
+ 'message' => $data['message'],
+ 'severity' => $severity,
+ 'occurred_at' => $data['occurred_at'] ?? $now,
+ 'created_at' => $now,
+ ]);
+
+ return $this->find((int) $this->db->pdo()->lastInsertId());
+ }
+
+ /** @return array|null */
+ public function find(int $id): ?array
+ {
+ $stmt = $this->db->pdo()->prepare('SELECT * FROM events WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+
+ return $row === false ? null : $row;
+ }
+
+ /**
+ * Neuestes Event zuerst (F5: "Der neueste Event erscheint oben"). Default-Limit 5
+ * entspricht der im Plan festgelegten maximalen Zeilenzahl des Event-Monitors.
+ *
+ * @return array>
+ */
+ public function latest(int $limit = self::DEFAULT_LIMIT): array
+ {
+ $limit = max(1, min($limit, 200));
+ $stmt = $this->db->pdo()->prepare(
+ 'SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT :limit'
+ );
+ $stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
+ $stmt->execute();
+
+ return $stmt->fetchAll();
+ }
+}
diff --git a/api/src/Repository/KpiRepository.php b/api/src/Repository/KpiRepository.php
new file mode 100644
index 0000000..693f0c2
--- /dev/null
+++ b/api/src/Repository/KpiRepository.php
@@ -0,0 +1,80 @@
+ */
+ public function report(string $key, float $value, ?string $label, ?string $unit, ?string $timestamp): array
+ {
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO kpis (key, label, unit) VALUES (:key, :label, :unit)
+ ON CONFLICT(key) DO UPDATE SET
+ label = COALESCE(excluded.label, kpis.label),
+ unit = COALESCE(excluded.unit, kpis.unit)'
+ );
+ $stmt->execute(['key' => $key, 'label' => $label, 'unit' => $unit]);
+
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO kpi_values (kpi_key, value, recorded_at) VALUES (:key, :value, :recorded_at)'
+ );
+ $stmt->execute([
+ 'key' => $key,
+ 'value' => $value,
+ 'recorded_at' => $timestamp ?? $this->db->now(),
+ ]);
+
+ return $this->find($key);
+ }
+
+ /** @return array|null */
+ public function find(string $key): ?array
+ {
+ $stmt = $this->db->pdo()->prepare('SELECT * FROM kpis WHERE key = :key');
+ $stmt->execute(['key' => $key]);
+ $kpi = $stmt->fetch();
+ if ($kpi === false) {
+ return null;
+ }
+
+ $kpi['history'] = $this->history($key);
+ $kpi['current_value'] = $kpi['history'][0]['value'] ?? null;
+
+ return $kpi;
+ }
+
+ /** @return array> */
+ private function history(string $key): array
+ {
+ $stmt = $this->db->pdo()->prepare(
+ 'SELECT value, recorded_at FROM kpi_values WHERE kpi_key = :key ORDER BY recorded_at DESC, id DESC LIMIT :limit'
+ );
+ $stmt->bindValue('key', $key);
+ $stmt->bindValue('limit', $this->historyLimit, \PDO::PARAM_INT);
+ $stmt->execute();
+
+ return $stmt->fetchAll();
+ }
+
+ /** @return array> */
+ public function all(): array
+ {
+ $stmt = $this->db->pdo()->query('SELECT key FROM kpis ORDER BY key ASC');
+ $keys = array_column($stmt->fetchAll(), 'key');
+
+ return array_map(fn (string $key) => $this->find($key), $keys);
+ }
+}
diff --git a/api/src/Repository/LegacyStatusFileReader.php b/api/src/Repository/LegacyStatusFileReader.php
new file mode 100644
index 0000000..63b25c1
--- /dev/null
+++ b/api/src/Repository/LegacyStatusFileReader.php
@@ -0,0 +1,44 @@
+ " - eine Zeile pro Host.
+ * Bleibt bestehen, bis Agenten den Systemstatus vollstaendig per REST melden (F6).
+ */
+final class LegacyStatusFileReader
+{
+ /** @return array */
+ public function read(string $path): array
+ {
+ if (!is_file($path) || !is_readable($path)) {
+ return [];
+ }
+
+ $rows = [];
+ $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
+
+ foreach ($lines as $line) {
+ $parts = preg_split('/\s+/', trim($line));
+ if ($parts === false || count($parts) < 4) {
+ continue;
+ }
+
+ [$hostname, $ip, $status] = $parts;
+ $timestamp = implode(' ', array_slice($parts, 3));
+
+ $rows[] = [
+ 'id' => $hostname,
+ 'name' => $hostname,
+ 'status' => $status === 'online' ? 'ok' : 'error',
+ 'detail' => $ip,
+ 'last_check_at' => $timestamp,
+ ];
+ }
+
+ return $rows;
+ }
+}
diff --git a/api/src/Repository/SystemRepository.php b/api/src/Repository/SystemRepository.php
new file mode 100644
index 0000000..cf00294
--- /dev/null
+++ b/api/src/Repository/SystemRepository.php
@@ -0,0 +1,105 @@
+ $data @return array */
+ public function reportStatus(string $id, array $data): array
+ {
+ $status = $data['status'] ?? null;
+ if (!in_array($status, self::STATUSES, true)) {
+ throw new InvalidArgumentException('Ungueltiger status: ' . var_export($status, true));
+ }
+
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO systems (id, name, type, status, detail, source, last_check_at)
+ VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name, type = excluded.type, status = excluded.status,
+ detail = excluded.detail, source = excluded.source, last_check_at = excluded.last_check_at'
+ );
+ $stmt->execute([
+ 'id' => $id,
+ 'name' => $data['name'] ?? $id,
+ 'type' => $data['type'] ?? 'host',
+ 'status' => $status,
+ 'detail' => $data['detail'] ?? null,
+ 'source' => 'api',
+ 'last_check_at' => $this->db->now(),
+ ]);
+
+ return $this->find($id);
+ }
+
+ /**
+ * Importiert Zeilen aus der bestehenden status.txt (F6). API-seitig gemeldete Systeme
+ * ("source = api") werden dabei nicht ueberschrieben, da sie aktueller/verbindlicher sind.
+ *
+ * @param array $rows
+ */
+ public function importLegacyRows(array $rows): void
+ {
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO systems (id, name, type, status, detail, source, last_check_at)
+ VALUES (:id, :name, :type, :status, :detail, :source, :last_check_at)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name, status = excluded.status,
+ detail = excluded.detail, last_check_at = excluded.last_check_at
+ WHERE systems.source = :source'
+ );
+
+ foreach ($rows as $row) {
+ $stmt->execute([
+ 'id' => $row['id'],
+ 'name' => $row['name'],
+ 'type' => 'host',
+ 'status' => $row['status'],
+ 'detail' => $row['detail'],
+ 'source' => 'legacy',
+ 'last_check_at' => $row['last_check_at'],
+ ]);
+ }
+ }
+
+ /** @return array|null */
+ public function find(string $id): ?array
+ {
+ $stmt = $this->db->pdo()->prepare('SELECT * FROM systems WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+
+ return $row === false ? null : $row;
+ }
+
+ /**
+ * Fehler zuerst, danach nach letztem Check sortiert, gedeckelt auf MAX_TILES (F6:
+ * "es werden maximal 9 Kacheln dargestellt").
+ *
+ * @return array>
+ */
+ public function all(): array
+ {
+ $stmt = $this->db->pdo()->prepare(
+ "SELECT * FROM systems ORDER BY (status = 'error') DESC, last_check_at DESC LIMIT :limit"
+ );
+ $stmt->bindValue('limit', self::MAX_TILES, \PDO::PARAM_INT);
+ $stmt->execute();
+
+ return $stmt->fetchAll();
+ }
+}
diff --git a/api/src/Repository/TicketRepository.php b/api/src/Repository/TicketRepository.php
new file mode 100644
index 0000000..9eb9a66
--- /dev/null
+++ b/api/src/Repository/TicketRepository.php
@@ -0,0 +1,164 @@
+ $data @return array */
+ public function create(array $data): array
+ {
+ $column = $data['column'] ?? 'backlog';
+ $this->assertValidColumn($column);
+
+ $now = $this->db->now();
+ $stmt = $this->db->pdo()->prepare(
+ 'INSERT INTO tickets (external_ref, title, description, column_name, type, color, assignee_agent_id, source, created_at, updated_at)
+ VALUES (:external_ref, :title, :description, :column_name, :type, :color, :assignee_agent_id, :source, :created_at, :updated_at)'
+ );
+ $stmt->execute([
+ 'external_ref' => $data['external_ref'] ?? null,
+ 'title' => $data['title'],
+ 'description' => $data['description'] ?? null,
+ 'column_name' => $column,
+ 'type' => $data['type'] ?? null,
+ 'color' => $data['color'] ?? null,
+ 'assignee_agent_id' => $data['assignee_agent_id'] ?? null,
+ 'source' => $data['source'] ?? 'api',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ return $this->find((int) $this->db->pdo()->lastInsertId());
+ }
+
+ /** @param array $changes @return array */
+ public function update(int $id, array $changes): array
+ {
+ if ($this->find($id) === null) {
+ throw new NotFoundException('Ticket nicht gefunden: ' . $id);
+ }
+
+ $fields = [];
+ $params = ['id' => $id];
+
+ if (array_key_exists('column', $changes)) {
+ $this->assertValidColumn($changes['column']);
+ $fields[] = 'column_name = :column_name';
+ $params['column_name'] = $changes['column'];
+ }
+ foreach (['title', 'description', 'type', 'color'] as $key) {
+ if (array_key_exists($key, $changes)) {
+ $fields[] = "$key = :$key";
+ $params[$key] = $changes[$key];
+ }
+ }
+ if (array_key_exists('assignee_agent_id', $changes)) {
+ $fields[] = 'assignee_agent_id = :assignee_agent_id';
+ $params['assignee_agent_id'] = $changes['assignee_agent_id'];
+ }
+
+ if ($fields === []) {
+ return $this->find($id);
+ }
+
+ $fields[] = 'updated_at = :updated_at';
+ $params['updated_at'] = $this->db->now();
+
+ $sql = 'UPDATE tickets SET ' . implode(', ', $fields) . ' WHERE id = :id';
+ $this->db->pdo()->prepare($sql)->execute($params);
+
+ return $this->find($id);
+ }
+
+ /** @return array|null */
+ public function find(int $id): ?array
+ {
+ $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+
+ return $row === false ? null : $row;
+ }
+
+ /** @return array|null */
+ public function findByExternalRef(string $externalRef): ?array
+ {
+ $stmt = $this->db->pdo()->prepare('SELECT * FROM tickets WHERE external_ref = :ref');
+ $stmt->execute(['ref' => $externalRef]);
+ $row = $stmt->fetch();
+
+ return $row === false ? null : $row;
+ }
+
+ /**
+ * Liefert Tickets sortiert nach Spalte und Alter, damit das Frontend die "aelteste 4 +
+ * Stapel"-Regel (F4) ohne weitere API-Unterstuetzung umsetzen kann.
+ *
+ * @return array>
+ */
+ public function all(?string $column = null): array
+ {
+ if ($column !== null) {
+ $this->assertValidColumn($column);
+ $stmt = $this->db->pdo()->prepare(
+ 'SELECT * FROM tickets WHERE column_name = :column ORDER BY created_at ASC, id ASC'
+ );
+ $stmt->execute(['column' => $column]);
+
+ return $stmt->fetchAll();
+ }
+
+ $stmt = $this->db->pdo()->query('SELECT * FROM tickets ORDER BY column_name ASC, created_at ASC, id ASC');
+
+ return $stmt->fetchAll();
+ }
+
+ /**
+ * Legt ein Ticket aus einem Codeberg-Issue an oder aktualisiert es (Abgleich ueber
+ * external_ref). Wird vom Codeberg-Sync fuer Backlog/Done verwendet (F4).
+ *
+ * @param array $data
+ * @return array
+ */
+ public function upsertFromCodeberg(array $data): array
+ {
+ $existing = $this->findByExternalRef((string) $data['external_ref']);
+ if ($existing === null) {
+ return $this->create($data + ['source' => 'codeberg']);
+ }
+
+ // Manuell auf dem Board bewegte Tickets (z. B. in_arbeit/qa/testing) werden vom
+ // periodischen Codeberg-Sync nicht zurueckgesetzt - nur Backlog/Done werden gepflegt.
+ if (!in_array($existing['column_name'], ['backlog', 'done'], true)) {
+ return $existing;
+ }
+
+ return $this->update((int) $existing['id'], [
+ 'column' => $data['column'],
+ 'title' => $data['title'],
+ 'description' => $data['description'] ?? null,
+ 'type' => $data['type'] ?? null,
+ 'color' => $data['color'] ?? null,
+ ]);
+ }
+
+ private function assertValidColumn(mixed $column): void
+ {
+ if (!is_string($column) || !in_array($column, self::COLUMNS, true)) {
+ throw new InvalidArgumentException('Ungueltige Spalte: ' . var_export($column, true));
+ }
+ }
+}
diff --git a/api/src/Sync/CodebergClient.php b/api/src/Sync/CodebergClient.php
new file mode 100644
index 0000000..e555d92
--- /dev/null
+++ b/api/src/Sync/CodebergClient.php
@@ -0,0 +1,16 @@
+}>
+ */
+ public function listIssues(string $owner, string $repo, string $state): array;
+}
diff --git a/api/src/Sync/CodebergSyncService.php b/api/src/Sync/CodebergSyncService.php
new file mode 100644
index 0000000..822e9c7
--- /dev/null
+++ b/api/src/Sync/CodebergSyncService.php
@@ -0,0 +1,53 @@
+client->listIssues($this->owner, $this->repo, 'open') as $issue) {
+ $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'backlog'));
+ }
+
+ foreach ($this->client->listIssues($this->owner, $this->repo, 'closed') as $issue) {
+ $this->tickets->upsertFromCodeberg($this->mapIssue($issue, 'done'));
+ }
+ }
+
+ /**
+ * @param array{number: int, title: string, body: ?string, labels: array} $issue
+ * @return array
+ */
+ private function mapIssue(array $issue, string $column): array
+ {
+ $firstLabel = $issue['labels'][0] ?? null;
+
+ return [
+ 'external_ref' => (string) $issue['number'],
+ 'title' => $issue['title'],
+ 'description' => $issue['body'],
+ 'column' => $column,
+ 'type' => $firstLabel['name'] ?? null,
+ 'color' => $firstLabel['color'] ?? null,
+ ];
+ }
+}
diff --git a/api/src/Sync/CurlCodebergClient.php b/api/src/Sync/CurlCodebergClient.php
new file mode 100644
index 0000000..b264840
--- /dev/null
+++ b/api/src/Sync/CurlCodebergClient.php
@@ -0,0 +1,93 @@
+apiBase,
+ rawurlencode($owner),
+ rawurlencode($repo),
+ rawurlencode($state),
+ $page,
+ self::PER_PAGE
+ );
+
+ $pageIssues = $this->fetch($url);
+ if ($pageIssues === []) {
+ break;
+ }
+
+ foreach ($pageIssues as $issue) {
+ $issues[] = [
+ 'number' => (int) $issue['number'],
+ 'title' => (string) $issue['title'],
+ 'body' => isset($issue['body']) ? (string) $issue['body'] : null,
+ 'labels' => array_map(
+ static fn (array $label): array => [
+ 'name' => (string) $label['name'],
+ 'color' => '#' . ltrim((string) $label['color'], '#'),
+ ],
+ $issue['labels'] ?? []
+ ),
+ ];
+ }
+
+ if (count($pageIssues) < self::PER_PAGE) {
+ break;
+ }
+ }
+
+ return $issues;
+ }
+
+ /** @return array> */
+ private function fetch(string $url): array
+ {
+ $headers = ['Accept: application/json'];
+ if ($this->token !== null && $this->token !== '') {
+ $headers[] = 'Authorization: token ' . $this->token;
+ }
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+ $body = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ if ($body === false) {
+ throw new RuntimeException('Codeberg-API nicht erreichbar: ' . $error);
+ }
+ if ($status >= 400) {
+ throw new RuntimeException('Codeberg-API-Fehler (HTTP ' . $status . '): ' . $body);
+ }
+
+ $decoded = json_decode((string) $body, true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+}
diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php
new file mode 100644
index 0000000..02924b4
--- /dev/null
+++ b/api/tests/Unit/ApplicationTest.php
@@ -0,0 +1,95 @@
+ 'admin-secret', 'MONITOR_STATUS_FILE' => '/does/not/exist']);
+ $this->app = new Application(Database::inMemory(), $config);
+ }
+
+ public function testUnknownRouteReturns404(): void
+ {
+ $response = $this->app->handle(Request::create('GET', '/nope'));
+
+ self::assertSame(404, $response->status);
+ }
+
+ public function testWrongMethodReturns405(): void
+ {
+ $response = $this->app->handle(Request::create('DELETE', '/api/v1/tickets'));
+
+ self::assertSame(405, $response->status);
+ }
+
+ public function testWritingWithoutAgentKeyReturns401(): void
+ {
+ $response = $this->app->handle(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X']));
+
+ self::assertSame(401, $response->status);
+ }
+
+ public function testRegisterAgentRequiresAdminKey(): void
+ {
+ $response = $this->app->handle(Request::create('POST', '/api/v1/agents', body: [
+ 'display_name' => 'Alice', 'role' => 'entwickler',
+ ]));
+
+ self::assertSame(401, $response->status);
+ }
+
+ public function testFullFlowRegisterAgentThenCreateTicket(): void
+ {
+ $registerResponse = $this->app->handle(Request::create(
+ 'POST',
+ '/api/v1/agents',
+ body: ['display_name' => 'Alice', 'role' => 'entwickler'],
+ headers: ['X-Admin-Key' => 'admin-secret']
+ ));
+ self::assertSame(201, $registerResponse->status);
+ $apiKey = $registerResponse->body()['api_key'];
+
+ $createResponse = $this->app->handle(Request::create(
+ 'POST',
+ '/api/v1/tickets',
+ body: ['title' => 'Neues Ticket'],
+ headers: ['X-Api-Key' => $apiKey]
+ ));
+ self::assertSame(201, $createResponse->status);
+
+ $listResponse = $this->app->handle(Request::create('GET', '/api/v1/tickets'));
+ self::assertCount(1, $listResponse->body()['tickets']);
+ }
+
+ public function testPatchUnknownTicketReturns404(): void
+ {
+ $registerResponse = $this->app->handle(Request::create(
+ 'POST',
+ '/api/v1/agents',
+ body: ['display_name' => 'Alice', 'role' => 'entwickler'],
+ headers: ['X-Admin-Key' => 'admin-secret']
+ ));
+ $apiKey = $registerResponse->body()['api_key'];
+
+ $response = $this->app->handle(Request::create(
+ 'PATCH',
+ '/api/v1/tickets/999',
+ body: ['column' => 'bereit'],
+ headers: ['X-Api-Key' => $apiKey]
+ ));
+
+ self::assertSame(404, $response->status);
+ }
+}
diff --git a/api/tests/Unit/Auth/AdminAuthenticatorTest.php b/api/tests/Unit/Auth/AdminAuthenticatorTest.php
new file mode 100644
index 0000000..0d9d70b
--- /dev/null
+++ b/api/tests/Unit/Auth/AdminAuthenticatorTest.php
@@ -0,0 +1,40 @@
+authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'secret'])));
+ }
+
+ public function testAuthenticateRejectsWrongKey(): void
+ {
+ $auth = new AdminAuthenticator('secret');
+
+ self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'wrong'])));
+ }
+
+ public function testAuthenticateRejectsWhenNoAdminKeyConfigured(): void
+ {
+ $auth = new AdminAuthenticator(null);
+
+ self::assertFalse($auth->authenticate(Request::create('POST', '/x', headers: ['X-Admin-Key' => 'anything'])));
+ }
+
+ public function testAuthenticateRejectsMissingHeader(): void
+ {
+ $auth = new AdminAuthenticator('secret');
+
+ self::assertFalse($auth->authenticate(Request::create('POST', '/x')));
+ }
+}
diff --git a/api/tests/Unit/Auth/AgentAuthenticatorTest.php b/api/tests/Unit/Auth/AgentAuthenticatorTest.php
new file mode 100644
index 0000000..39cc98d
--- /dev/null
+++ b/api/tests/Unit/Auth/AgentAuthenticatorTest.php
@@ -0,0 +1,47 @@
+register('Alice', 'entwickler');
+ $auth = new AgentAuthenticator($agents);
+
+ $request = Request::create('GET', '/x', headers: ['X-Api-Key' => $registration['api_key']]);
+
+ $result = $auth->authenticate($request);
+
+ self::assertNotNull($result);
+ self::assertSame($registration['agent']['id'], $result['id']);
+ }
+
+ public function testAuthenticateReturnsNullWithoutHeader(): void
+ {
+ $agents = new AgentRepository(Database::inMemory());
+ $auth = new AgentAuthenticator($agents);
+
+ self::assertNull($auth->authenticate(Request::create('GET', '/x')));
+ }
+
+ public function testAuthenticateReturnsNullForWrongKey(): void
+ {
+ $agents = new AgentRepository(Database::inMemory());
+ $agents->register('Alice', 'entwickler');
+ $auth = new AgentAuthenticator($agents);
+
+ $request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'wrong']);
+
+ self::assertNull($auth->authenticate($request));
+ }
+}
diff --git a/api/tests/Unit/Auth/ApiKeyGeneratorTest.php b/api/tests/Unit/Auth/ApiKeyGeneratorTest.php
new file mode 100644
index 0000000..db07045
--- /dev/null
+++ b/api/tests/Unit/Auth/ApiKeyGeneratorTest.php
@@ -0,0 +1,28 @@
+codebergApiBase());
+ self::assertSame('fds2610', $config->codebergOwner());
+ self::assertSame('monitor', $config->codebergRepo());
+ self::assertNull($config->codebergToken());
+ self::assertNull($config->adminApiKey());
+ self::assertSame(8, $config->deskCount());
+ }
+
+ public function testValuesCanBeOverridden(): void
+ {
+ $config = new Config([
+ 'CODEBERG_API_BASE' => 'https://example.org/api/v1/',
+ 'CODEBERG_OWNER' => 'someone',
+ 'CODEBERG_REPO' => 'somerepo',
+ 'CODEBERG_TOKEN' => 'tok',
+ 'MONITOR_ADMIN_API_KEY' => 'secret',
+ 'MONITOR_DESK_COUNT' => '3',
+ ]);
+
+ self::assertSame('https://example.org/api/v1', $config->codebergApiBase());
+ self::assertSame('someone', $config->codebergOwner());
+ self::assertSame('somerepo', $config->codebergRepo());
+ self::assertSame('tok', $config->codebergToken());
+ self::assertSame('secret', $config->adminApiKey());
+ self::assertSame(3, $config->deskCount());
+ }
+}
diff --git a/api/tests/Unit/Controller/AgentControllerTest.php b/api/tests/Unit/Controller/AgentControllerTest.php
new file mode 100644
index 0000000..5cf9ca8
--- /dev/null
+++ b/api/tests/Unit/Controller/AgentControllerTest.php
@@ -0,0 +1,101 @@
+repository = new AgentRepository(Database::inMemory());
+ $this->controller = new AgentController($this->repository);
+ }
+
+ public function testRegisterRejectsMissingDisplayName(): void
+ {
+ $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: ['role' => 'entwickler']));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testRegisterRejectsUnknownRole(): void
+ {
+ $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [
+ 'display_name' => 'Alice', 'role' => 'ceo',
+ ]));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testRegisterReturns201WithApiKey(): void
+ {
+ $response = $this->controller->register(Request::create('POST', '/api/v1/agents', body: [
+ 'display_name' => 'Alice', 'role' => 'entwickler',
+ ]));
+
+ self::assertSame(201, $response->status);
+ self::assertArrayHasKey('api_key', $response->body());
+ self::assertArrayNotHasKey('api_key_hash', $response->body()['agent']);
+ }
+
+ public function testActivityRejectsMismatchedAgentId(): void
+ {
+ $registration = $this->repository->register('Alice', 'entwickler');
+ $other = $this->repository->register('Bob', 'qa');
+
+ $response = $this->controller->activity(
+ Request::create('POST', '/x', body: ['state' => 'working']),
+ ['id' => (string) $other['agent']['id']],
+ $registration['agent']
+ );
+
+ self::assertSame(403, $response->status);
+ }
+
+ public function testActivityRejectsMissingState(): void
+ {
+ $registration = $this->repository->register('Alice', 'entwickler');
+
+ $response = $this->controller->activity(
+ Request::create('POST', '/x', body: []),
+ ['id' => (string) $registration['agent']['id']],
+ $registration['agent']
+ );
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testActivityUpdatesOwnState(): void
+ {
+ $registration = $this->repository->register('Alice', 'entwickler');
+
+ $response = $this->controller->activity(
+ Request::create('POST', '/x', body: ['state' => 'working', 'current_ticket' => 7]),
+ ['id' => (string) $registration['agent']['id']],
+ $registration['agent']
+ );
+
+ self::assertSame(200, $response->status);
+ self::assertSame('working', $response->body()['agent']['state']);
+ self::assertSame(7, $response->body()['agent']['current_ticket_id']);
+ }
+
+ public function testIndexListsAgents(): void
+ {
+ $this->repository->register('Alice', 'entwickler');
+
+ $response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
+
+ self::assertCount(1, $response->body()['agents']);
+ }
+}
diff --git a/api/tests/Unit/Controller/EventControllerTest.php b/api/tests/Unit/Controller/EventControllerTest.php
new file mode 100644
index 0000000..dd917da
--- /dev/null
+++ b/api/tests/Unit/Controller/EventControllerTest.php
@@ -0,0 +1,58 @@
+controller = new EventController(new EventRepository(Database::inMemory()));
+ }
+
+ public function testCreateRejectsMissingFields(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: ['name' => 'X']));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testCreateRejectsInvalidSeverity(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [
+ 'name' => 'X', 'message' => 'Y', 'severity' => 'info',
+ ]));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testCreateReturns201(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/events', body: [
+ 'name' => 'Ping', 'message' => 'down', 'severity' => 'error',
+ ]));
+
+ self::assertSame(201, $response->status);
+ self::assertSame('error', $response->body()['event']['severity']);
+ }
+
+ public function testIndexReturnsEvents(): void
+ {
+ $this->controller->create(Request::create('POST', '/api/v1/events', body: [
+ 'name' => 'Ping', 'message' => 'down', 'severity' => 'error',
+ ]));
+
+ $response = $this->controller->index(Request::create('GET', '/api/v1/events'));
+
+ self::assertCount(1, $response->body()['events']);
+ }
+}
diff --git a/api/tests/Unit/Controller/KpiControllerTest.php b/api/tests/Unit/Controller/KpiControllerTest.php
new file mode 100644
index 0000000..dfc551a
--- /dev/null
+++ b/api/tests/Unit/Controller/KpiControllerTest.php
@@ -0,0 +1,58 @@
+controller = new KpiController(new KpiRepository(Database::inMemory()));
+ }
+
+ public function testReportRejectsMissingValue(): void
+ {
+ $response = $this->controller->report(Request::create('POST', '/api/v1/kpis/open_tickets', body: []), ['key' => 'open_tickets']);
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testReportRejectsNonNumericValue(): void
+ {
+ $response = $this->controller->report(
+ Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 'abc']),
+ ['key' => 'open_tickets']
+ );
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testReportReturns201WithKpi(): void
+ {
+ $response = $this->controller->report(
+ Request::create('POST', '/api/v1/kpis/open_tickets', body: ['value' => 3]),
+ ['key' => 'open_tickets']
+ );
+
+ self::assertSame(201, $response->status);
+ self::assertSame(3.0, $response->body()['kpi']['current_value']);
+ }
+
+ public function testIndexListsKpis(): void
+ {
+ $this->controller->report(Request::create('POST', '/x', body: ['value' => 1]), ['key' => 'a']);
+
+ $response = $this->controller->index(Request::create('GET', '/api/v1/kpis'));
+
+ self::assertCount(1, $response->body()['kpis']);
+ }
+}
diff --git a/api/tests/Unit/Controller/SystemControllerTest.php b/api/tests/Unit/Controller/SystemControllerTest.php
new file mode 100644
index 0000000..d095de8
--- /dev/null
+++ b/api/tests/Unit/Controller/SystemControllerTest.php
@@ -0,0 +1,67 @@
+tmpFile = tempnam(sys_get_temp_dir(), 'status_');
+ file_put_contents($this->tmpFile, "Server1 1.2.3.4 online 2026-07-27 10:00\n");
+
+ $this->controller = new SystemController(
+ new SystemRepository(Database::inMemory()),
+ new LegacyStatusFileReader(),
+ $this->tmpFile
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->tmpFile)) {
+ unlink($this->tmpFile);
+ }
+ }
+
+ public function testIndexImportsLegacyStatusFile(): void
+ {
+ $response = $this->controller->index(Request::create('GET', '/api/v1/systems'));
+
+ self::assertSame(200, $response->status);
+ self::assertCount(1, $response->body()['systems']);
+ self::assertSame('Server1', $response->body()['systems'][0]['id']);
+ }
+
+ public function testReportStatusRejectsInvalidStatus(): void
+ {
+ $response = $this->controller->reportStatus(
+ Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'weird']),
+ ['id' => 'srv1']
+ );
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testReportStatusReturnsUpdatedSystem(): void
+ {
+ $response = $this->controller->reportStatus(
+ Request::create('POST', '/api/v1/systems/srv1/status', body: ['status' => 'ok']),
+ ['id' => 'srv1']
+ );
+
+ self::assertSame(200, $response->status);
+ self::assertSame('ok', $response->body()['system']['status']);
+ }
+}
diff --git a/api/tests/Unit/Controller/TicketControllerTest.php b/api/tests/Unit/Controller/TicketControllerTest.php
new file mode 100644
index 0000000..ec62134
--- /dev/null
+++ b/api/tests/Unit/Controller/TicketControllerTest.php
@@ -0,0 +1,79 @@
+repository = new TicketRepository(Database::inMemory());
+ $this->controller = new TicketController($this->repository);
+ }
+
+ public function testIndexReturnsAllTickets(): void
+ {
+ $this->repository->create(['title' => 'A']);
+
+ $response = $this->controller->index(Request::create('GET', '/api/v1/tickets'));
+
+ self::assertSame(200, $response->status);
+ self::assertCount(1, $response->body()['tickets']);
+ }
+
+ public function testCreateRejectsMissingTitle(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: []));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testCreateReturns201WithTicket(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'Neu']));
+
+ self::assertSame(201, $response->status);
+ self::assertSame('Neu', $response->body()['ticket']['title']);
+ }
+
+ public function testCreateRejectsInvalidColumnWith422(): void
+ {
+ $response = $this->controller->create(Request::create('POST', '/api/v1/tickets', body: ['title' => 'X', 'column' => 'invalid']));
+
+ self::assertSame(422, $response->status);
+ }
+
+ public function testUpdateMovesTicket(): void
+ {
+ $ticket = $this->repository->create(['title' => 'X']);
+
+ $response = $this->controller->update(
+ Request::create('PATCH', '/api/v1/tickets/' . $ticket['id'], body: ['column' => 'bereit']),
+ ['id' => (string) $ticket['id']]
+ );
+
+ self::assertSame(200, $response->status);
+ self::assertSame('bereit', $response->body()['ticket']['column_name']);
+ }
+
+ public function testUpdatePropagatesNotFoundForUnknownTicket(): void
+ {
+ $this->expectException(NotFoundException::class);
+
+ $this->controller->update(
+ Request::create('PATCH', '/api/v1/tickets/999', body: ['column' => 'bereit']),
+ ['id' => '999']
+ );
+ }
+}
diff --git a/api/tests/Unit/Db/DatabaseTest.php b/api/tests/Unit/Db/DatabaseTest.php
new file mode 100644
index 0000000..7c9b7ed
--- /dev/null
+++ b/api/tests/Unit/Db/DatabaseTest.php
@@ -0,0 +1,40 @@
+pdo()->query("SELECT name FROM sqlite_master WHERE type = 'table'");
+ $tables = array_column($stmt->fetchAll(), 'name');
+
+ foreach (['agents', 'tickets', 'events', 'systems', 'kpis', 'kpi_values'] as $expected) {
+ self::assertContains($expected, $tables);
+ }
+ }
+
+ public function testMigrateIsIdempotent(): void
+ {
+ $db = Database::inMemory();
+
+ // Zweiter Aufruf darf nicht fehlschlagen (CREATE TABLE IF NOT EXISTS).
+ $db->migrate();
+
+ self::assertTrue(true);
+ }
+
+ public function testNowReturnsIso8601Utc(): void
+ {
+ $db = Database::inMemory();
+
+ self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $db->now());
+ }
+}
diff --git a/api/tests/Unit/Http/RequestTest.php b/api/tests/Unit/Http/RequestTest.php
new file mode 100644
index 0000000..3a6feeb
--- /dev/null
+++ b/api/tests/Unit/Http/RequestTest.php
@@ -0,0 +1,48 @@
+path);
+ }
+
+ public function testCreateKeepsRootPath(): void
+ {
+ $request = Request::create('GET', '/');
+
+ self::assertSame('/', $request->path);
+ }
+
+ public function testHeaderLookupIsCaseInsensitive(): void
+ {
+ $request = Request::create('GET', '/x', headers: ['X-Api-Key' => 'abc']);
+
+ self::assertSame('abc', $request->header('x-api-key'));
+ self::assertSame('abc', $request->header('X-API-KEY'));
+ }
+
+ public function testHeaderReturnsNullWhenMissing(): void
+ {
+ $request = Request::create('GET', '/x');
+
+ self::assertNull($request->header('x-api-key'));
+ }
+
+ public function testCreateStoresQueryAndBody(): void
+ {
+ $request = Request::create('GET', '/x', query: ['limit' => '5'], body: ['title' => 'Hi']);
+
+ self::assertSame(['limit' => '5'], $request->query);
+ self::assertSame(['title' => 'Hi'], $request->body);
+ }
+}
diff --git a/api/tests/Unit/Http/ResponseTest.php b/api/tests/Unit/Http/ResponseTest.php
new file mode 100644
index 0000000..a8475b1
--- /dev/null
+++ b/api/tests/Unit/Http/ResponseTest.php
@@ -0,0 +1,27 @@
+ 'bar'], 201);
+
+ self::assertSame(201, $response->status);
+ self::assertSame(['foo' => 'bar'], $response->body());
+ }
+
+ public function testErrorResponseDefaultsToGivenStatus(): void
+ {
+ $response = Response::error('nope', 404);
+
+ self::assertSame(404, $response->status);
+ self::assertSame(['error' => 'nope'], $response->body());
+ }
+}
diff --git a/api/tests/Unit/Http/RouterTest.php b/api/tests/Unit/Http/RouterTest.php
new file mode 100644
index 0000000..00e3ff0
--- /dev/null
+++ b/api/tests/Unit/Http/RouterTest.php
@@ -0,0 +1,62 @@
+add('GET', '/api/v1/tickets', fn () => 'tickets');
+
+ $match = $router->match(Request::create('GET', '/api/v1/tickets'));
+
+ self::assertNotNull($match);
+ self::assertSame('tickets', ($match->handler)());
+ }
+
+ public function testExtractsPathParams(): void
+ {
+ $router = new Router();
+ $router->add('PATCH', '/api/v1/tickets/{id}', fn () => null);
+
+ $match = $router->match(Request::create('PATCH', '/api/v1/tickets/42'));
+
+ self::assertSame(['id' => '42'], $match->params);
+ }
+
+ public function testReturnsNullForUnknownPath(): void
+ {
+ $router = new Router();
+ $router->add('GET', '/api/v1/tickets', fn () => null);
+
+ self::assertNull($router->match(Request::create('GET', '/does/not/exist')));
+ }
+
+ public function testThrowsMethodNotAllowedForKnownPathWrongMethod(): void
+ {
+ $router = new Router();
+ $router->add('GET', '/api/v1/tickets', fn () => null);
+
+ $this->expectException(MethodNotAllowedException::class);
+
+ $router->match(Request::create('DELETE', '/api/v1/tickets'));
+ }
+
+ public function testCarriesAuthRequirement(): void
+ {
+ $router = new Router();
+ $router->add('POST', '/api/v1/tickets', fn () => null, 'agent');
+
+ $match = $router->match(Request::create('POST', '/api/v1/tickets'));
+
+ self::assertSame('agent', $match->auth);
+ }
+}
diff --git a/api/tests/Unit/Repository/AgentRepositoryTest.php b/api/tests/Unit/Repository/AgentRepositoryTest.php
new file mode 100644
index 0000000..4fdaccc
--- /dev/null
+++ b/api/tests/Unit/Repository/AgentRepositoryTest.php
@@ -0,0 +1,101 @@
+repository = new AgentRepository(Database::inMemory(), deskCount: 2);
+ }
+
+ public function testRegisterAssignsLowestFreeDesk(): void
+ {
+ $first = $this->repository->register('Alice', 'entwickler');
+ $second = $this->repository->register('Bob', 'qa');
+
+ self::assertSame(0, $first['agent']['desk_index']);
+ self::assertSame(1, $second['agent']['desk_index']);
+ self::assertNotSame($first['api_key'], $second['api_key']);
+ self::assertArrayNotHasKey('api_key_hash', $first['agent']);
+ }
+
+ public function testUserRoleNeverGetsADesk(): void
+ {
+ $result = $this->repository->register('Carol', 'user');
+
+ self::assertNull($result['agent']['desk_index']);
+ }
+
+ public function testOverflowAgentGetsNoDeskWhenAllOccupied(): void
+ {
+ $this->repository->register('Alice', 'entwickler');
+ $this->repository->register('Bob', 'qa');
+ $third = $this->repository->register('Dan', 'product_owner');
+
+ self::assertNull($third['agent']['desk_index']);
+ }
+
+ public function testRegisterRejectsUnknownRole(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->repository->register('Eve', 'ceo');
+ }
+
+ public function testFindByApiKeyReturnsMatchingAgent(): void
+ {
+ $result = $this->repository->register('Alice', 'entwickler');
+
+ $found = $this->repository->findByApiKey($result['api_key']);
+
+ self::assertNotNull($found);
+ self::assertSame($result['agent']['id'], $found['id']);
+ }
+
+ public function testFindByApiKeyReturnsNullForUnknownKey(): void
+ {
+ self::assertNull($this->repository->findByApiKey('does-not-exist'));
+ }
+
+ public function testUpdateActivityChangesStateAndTicket(): void
+ {
+ $result = $this->repository->register('Alice', 'entwickler');
+ $id = (int) $result['agent']['id'];
+
+ $updated = $this->repository->updateActivity($id, 'working', 42);
+
+ self::assertSame('working', $updated['state']);
+ self::assertSame(42, $updated['current_ticket_id']);
+ }
+
+ public function testUpdateActivityThrowsForUnknownAgent(): void
+ {
+ $this->expectException(NotFoundException::class);
+
+ $this->repository->updateActivity(999, 'working', null);
+ }
+
+ public function testAllListsAgentsWithoutApiKeyHash(): void
+ {
+ $this->repository->register('Alice', 'entwickler');
+ $this->repository->register('Bob', 'qa');
+
+ $all = $this->repository->all();
+
+ self::assertCount(2, $all);
+ foreach ($all as $agent) {
+ self::assertArrayNotHasKey('api_key_hash', $agent);
+ }
+ }
+}
diff --git a/api/tests/Unit/Repository/EventRepositoryTest.php b/api/tests/Unit/Repository/EventRepositoryTest.php
new file mode 100644
index 0000000..2f8160b
--- /dev/null
+++ b/api/tests/Unit/Repository/EventRepositoryTest.php
@@ -0,0 +1,72 @@
+repository = new EventRepository(Database::inMemory());
+ }
+
+ public function testCreateStoresEvent(): void
+ {
+ $event = $this->repository->create([
+ 'system_id' => 'srv1',
+ 'name' => 'Ping',
+ 'message' => 'Host nicht erreichbar',
+ 'severity' => 'error',
+ ]);
+
+ self::assertSame('error', $event['severity']);
+ self::assertSame('srv1', $event['system_id']);
+ }
+
+ public function testCreateRejectsInvalidSeverity(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->repository->create(['name' => 'X', 'message' => 'Y', 'severity' => 'info']);
+ }
+
+ public function testLatestReturnsNewestFirst(): void
+ {
+ $this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning', 'occurred_at' => '2026-01-01T00:00:00Z']);
+ $this->repository->create(['name' => 'B', 'message' => 'b', 'severity' => 'error', 'occurred_at' => '2026-01-02T00:00:00Z']);
+
+ $latest = $this->repository->latest();
+
+ self::assertSame('B', $latest[0]['name']);
+ self::assertSame('A', $latest[1]['name']);
+ }
+
+ public function testLatestRespectsDefaultLimitOfFive(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->repository->create([
+ 'name' => 'Event ' . $i,
+ 'message' => 'msg',
+ 'severity' => 'warning',
+ 'occurred_at' => sprintf('2026-01-01T00:00:%02dZ', $i),
+ ]);
+ }
+
+ self::assertCount(EventRepository::DEFAULT_LIMIT, $this->repository->latest());
+ }
+
+ public function testLatestClampsLimitToAtLeastOne(): void
+ {
+ $this->repository->create(['name' => 'A', 'message' => 'a', 'severity' => 'warning']);
+
+ self::assertCount(1, $this->repository->latest(0));
+ }
+}
diff --git a/api/tests/Unit/Repository/KpiRepositoryTest.php b/api/tests/Unit/Repository/KpiRepositoryTest.php
new file mode 100644
index 0000000..b7f65b6
--- /dev/null
+++ b/api/tests/Unit/Repository/KpiRepositoryTest.php
@@ -0,0 +1,61 @@
+repository = new KpiRepository(Database::inMemory(), historyLimit: 3);
+ }
+
+ public function testReportCreatesKpiWithCurrentValue(): void
+ {
+ $kpi = $this->repository->report('open_tickets', 5.0, 'Offene Tickets', 'count', '2026-01-01T00:00:00Z');
+
+ self::assertSame('open_tickets', $kpi['key']);
+ self::assertSame(5.0, $kpi['current_value']);
+ self::assertCount(1, $kpi['history']);
+ }
+
+ public function testReportAppendsHistoryAndUpdatesCurrentValue(): void
+ {
+ $this->repository->report('open_tickets', 5.0, null, null, '2026-01-01T00:00:00Z');
+ $kpi = $this->repository->report('open_tickets', 7.0, null, null, '2026-01-02T00:00:00Z');
+
+ self::assertSame(7.0, $kpi['current_value']);
+ self::assertCount(2, $kpi['history']);
+ }
+
+ public function testHistoryIsLimited(): void
+ {
+ for ($i = 0; $i < 5; $i++) {
+ $this->repository->report('kpi', (float) $i, null, null, sprintf('2026-01-0%dT00:00:00Z', $i + 1));
+ }
+
+ $kpi = $this->repository->find('kpi');
+
+ self::assertCount(3, $kpi['history']);
+ }
+
+ public function testFindReturnsNullForUnknownKey(): void
+ {
+ self::assertNull($this->repository->find('unknown'));
+ }
+
+ public function testAllListsAllKpis(): void
+ {
+ $this->repository->report('a', 1.0, null, null, '2026-01-01T00:00:00Z');
+ $this->repository->report('b', 2.0, null, null, '2026-01-01T00:00:00Z');
+
+ self::assertCount(2, $this->repository->all());
+ }
+}
diff --git a/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php b/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php
new file mode 100644
index 0000000..52d3b91
--- /dev/null
+++ b/api/tests/Unit/Repository/LegacyStatusFileReaderTest.php
@@ -0,0 +1,53 @@
+tmpFile = tempnam(sys_get_temp_dir(), 'status_');
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->tmpFile)) {
+ unlink($this->tmpFile);
+ }
+ }
+
+ public function testReadsValidLines(): void
+ {
+ file_put_contents($this->tmpFile, "Server1 192.168.178.2 online 2026-07-27 10:00\nServer2 192.168.178.3 offline 2026-07-27 10:01\n");
+
+ $rows = (new LegacyStatusFileReader())->read($this->tmpFile);
+
+ self::assertCount(2, $rows);
+ self::assertSame('Server1', $rows[0]['id']);
+ self::assertSame('ok', $rows[0]['status']);
+ self::assertSame('192.168.178.2', $rows[0]['detail']);
+ self::assertSame('2026-07-27 10:00', $rows[0]['last_check_at']);
+ self::assertSame('error', $rows[1]['status']);
+ }
+
+ public function testSkipsMalformedLines(): void
+ {
+ file_put_contents($this->tmpFile, "incomplete line\nServer1 1.2.3.4 online 2026-07-27 10:00\n");
+
+ $rows = (new LegacyStatusFileReader())->read($this->tmpFile);
+
+ self::assertCount(1, $rows);
+ }
+
+ public function testReturnsEmptyArrayForMissingFile(): void
+ {
+ self::assertSame([], (new LegacyStatusFileReader())->read('/does/not/exist.txt'));
+ }
+}
diff --git a/api/tests/Unit/Repository/SystemRepositoryTest.php b/api/tests/Unit/Repository/SystemRepositoryTest.php
new file mode 100644
index 0000000..f75eb01
--- /dev/null
+++ b/api/tests/Unit/Repository/SystemRepositoryTest.php
@@ -0,0 +1,81 @@
+repository = new SystemRepository(Database::inMemory());
+ }
+
+ public function testReportStatusInsertsSystem(): void
+ {
+ $system = $this->repository->reportStatus('srv1', ['status' => 'ok', 'name' => 'Server 1']);
+
+ self::assertSame('ok', $system['status']);
+ self::assertSame('api', $system['source']);
+ }
+
+ public function testReportStatusRejectsInvalidStatus(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->repository->reportStatus('srv1', ['status' => 'unknown']);
+ }
+
+ public function testReportStatusUpdatesExistingSystem(): void
+ {
+ $this->repository->reportStatus('srv1', ['status' => 'ok']);
+ $updated = $this->repository->reportStatus('srv1', ['status' => 'error', 'detail' => 'Timeout']);
+
+ self::assertSame('error', $updated['status']);
+ self::assertSame('Timeout', $updated['detail']);
+ }
+
+ public function testImportLegacyRowsDoesNotOverwriteApiSourcedSystem(): void
+ {
+ $this->repository->reportStatus('srv1', ['status' => 'ok']);
+
+ $this->repository->importLegacyRows([
+ ['id' => 'srv1', 'name' => 'srv1', 'status' => 'error', 'detail' => '1.2.3.4', 'last_check_at' => '2026-01-01 00:00'],
+ ]);
+
+ $system = $this->repository->find('srv1');
+ self::assertSame('ok', $system['status']);
+ self::assertSame('api', $system['source']);
+ }
+
+ public function testImportLegacyRowsCreatesNewLegacySystem(): void
+ {
+ $this->repository->importLegacyRows([
+ ['id' => 'srv2', 'name' => 'srv2', 'status' => 'ok', 'detail' => '1.2.3.5', 'last_check_at' => '2026-01-01 00:00'],
+ ]);
+
+ $system = $this->repository->find('srv2');
+ self::assertNotNull($system);
+ self::assertSame('legacy', $system['source']);
+ }
+
+ public function testAllOrdersErrorsFirstAndLimitsToNineTiles(): void
+ {
+ for ($i = 0; $i < 12; $i++) {
+ $this->repository->reportStatus('srv' . $i, ['status' => 'ok']);
+ }
+ $this->repository->reportStatus('broken', ['status' => 'error']);
+
+ $all = $this->repository->all();
+
+ self::assertCount(SystemRepository::MAX_TILES, $all);
+ self::assertSame('error', $all[0]['status']);
+ }
+}
diff --git a/api/tests/Unit/Repository/TicketRepositoryTest.php b/api/tests/Unit/Repository/TicketRepositoryTest.php
new file mode 100644
index 0000000..e708a7a
--- /dev/null
+++ b/api/tests/Unit/Repository/TicketRepositoryTest.php
@@ -0,0 +1,135 @@
+repository = new TicketRepository(Database::inMemory());
+ }
+
+ public function testCreateDefaultsToBacklogColumn(): void
+ {
+ $ticket = $this->repository->create(['title' => 'Neues Ticket']);
+
+ self::assertSame('backlog', $ticket['column_name']);
+ self::assertSame('Neues Ticket', $ticket['title']);
+ }
+
+ public function testCreateRejectsUnknownColumn(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->repository->create(['title' => 'X', 'column' => 'unknown']);
+ }
+
+ public function testUpdateMovesTicketToAnotherColumn(): void
+ {
+ $ticket = $this->repository->create(['title' => 'X']);
+
+ $updated = $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']);
+
+ self::assertSame('in_arbeit', $updated['column_name']);
+ }
+
+ public function testUpdateRejectsUnknownColumn(): void
+ {
+ $ticket = $this->repository->create(['title' => 'X']);
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->repository->update((int) $ticket['id'], ['column' => 'unknown']);
+ }
+
+ public function testUpdateThrowsNotFoundForMissingTicket(): void
+ {
+ $this->expectException(NotFoundException::class);
+
+ $this->repository->update(999, ['column' => 'bereit']);
+ }
+
+ public function testAllFiltersByColumn(): void
+ {
+ $this->repository->create(['title' => 'A', 'column' => 'backlog']);
+ $this->repository->create(['title' => 'B', 'column' => 'done']);
+
+ $backlog = $this->repository->all('backlog');
+
+ self::assertCount(1, $backlog);
+ self::assertSame('A', $backlog[0]['title']);
+ }
+
+ public function testAllOrdersByColumnThenCreatedAt(): void
+ {
+ $this->repository->create(['title' => 'first', 'column' => 'backlog']);
+ $this->repository->create(['title' => 'second', 'column' => 'backlog']);
+
+ $tickets = $this->repository->all();
+
+ self::assertSame('first', $tickets[0]['title']);
+ self::assertSame('second', $tickets[1]['title']);
+ }
+
+ public function testUpsertFromCodebergCreatesNewTicket(): void
+ {
+ $ticket = $this->repository->upsertFromCodeberg([
+ 'external_ref' => '42',
+ 'title' => 'Issue 42',
+ 'description' => 'Body',
+ 'column' => 'backlog',
+ 'type' => 'bug',
+ 'color' => '#ff0000',
+ ]);
+
+ self::assertSame('42', $ticket['external_ref']);
+ self::assertSame('codeberg', $ticket['source']);
+ }
+
+ public function testUpsertFromCodebergUpdatesExistingBacklogTicket(): void
+ {
+ $this->repository->upsertFromCodeberg([
+ 'external_ref' => '42',
+ 'title' => 'Issue 42',
+ 'column' => 'backlog',
+ ]);
+
+ $updated = $this->repository->upsertFromCodeberg([
+ 'external_ref' => '42',
+ 'title' => 'Issue 42 (closed)',
+ 'column' => 'done',
+ ]);
+
+ self::assertSame('done', $updated['column_name']);
+ self::assertSame('Issue 42 (closed)', $updated['title']);
+ }
+
+ public function testUpsertFromCodebergDoesNotResetManuallyMovedTicket(): void
+ {
+ $this->repository->upsertFromCodeberg([
+ 'external_ref' => '42',
+ 'title' => 'Issue 42',
+ 'column' => 'backlog',
+ ]);
+ $ticket = $this->repository->findByExternalRef('42');
+ $this->repository->update((int) $ticket['id'], ['column' => 'in_arbeit']);
+
+ $result = $this->repository->upsertFromCodeberg([
+ 'external_ref' => '42',
+ 'title' => 'Issue 42 geaendert',
+ 'column' => 'backlog',
+ ]);
+
+ self::assertSame('in_arbeit', $result['column_name']);
+ }
+}
diff --git a/api/tests/Unit/Sync/CodebergSyncServiceTest.php b/api/tests/Unit/Sync/CodebergSyncServiceTest.php
new file mode 100644
index 0000000..4b8fbf2
--- /dev/null
+++ b/api/tests/Unit/Sync/CodebergSyncServiceTest.php
@@ -0,0 +1,74 @@
+> $issuesByState */
+ public function __construct(private readonly array $issuesByState)
+ {
+ }
+
+ public function listIssues(string $owner, string $repo, string $state): array
+ {
+ return $this->issuesByState[$state] ?? [];
+ }
+}
+
+final class CodebergSyncServiceTest extends TestCase
+{
+ public function testSyncCreatesBacklogAndDoneTickets(): void
+ {
+ $tickets = new TicketRepository(Database::inMemory());
+ $client = new FakeCodebergClient([
+ 'open' => [
+ ['number' => 1, 'title' => 'Offenes Issue', 'body' => 'Beschreibung', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
+ ],
+ 'closed' => [
+ ['number' => 2, 'title' => 'Geschlossenes Issue', 'body' => null, 'labels' => []],
+ ],
+ ]);
+
+ (new CodebergSyncService($client, $tickets, 'fds2610', 'monitor'))->sync();
+
+ $backlog = $tickets->all('backlog');
+ $done = $tickets->all('done');
+
+ self::assertCount(1, $backlog);
+ self::assertSame('Offenes Issue', $backlog[0]['title']);
+ self::assertSame('bug', $backlog[0]['type']);
+ self::assertSame('#ff0000', $backlog[0]['color']);
+ self::assertSame('codeberg', $backlog[0]['source']);
+
+ self::assertCount(1, $done);
+ self::assertSame('Geschlossenes Issue', $done[0]['title']);
+ self::assertNull($done[0]['type']);
+ }
+
+ public function testSyncDoesNotResetManuallyMovedTicket(): void
+ {
+ $tickets = new TicketRepository(Database::inMemory());
+ $client = new FakeCodebergClient([
+ 'open' => [['number' => 1, 'title' => 'Issue', 'body' => null, 'labels' => []]],
+ 'closed' => [],
+ ]);
+ $service = new CodebergSyncService($client, $tickets, 'fds2610', 'monitor');
+
+ $service->sync();
+ $ticket = $tickets->findByExternalRef('1');
+ $tickets->update((int) $ticket['id'], ['column' => 'qa']);
+
+ $service->sync();
+
+ $stillQa = $tickets->findByExternalRef('1');
+ self::assertSame('qa', $stillQa['column_name']);
+ }
+}
diff --git a/api/tests/Unit/Sync/CurlCodebergClientTest.php b/api/tests/Unit/Sync/CurlCodebergClientTest.php
new file mode 100644
index 0000000..d0a5cd0
--- /dev/null
+++ b/api/tests/Unit/Sync/CurlCodebergClientTest.php
@@ -0,0 +1,121 @@
+ */
+ public static array $queue = [];
+
+ /** @var string[] */
+ public static array $requestedUrls = [];
+
+ public static function reset(): void
+ {
+ self::$queue = [];
+ self::$requestedUrls = [];
+ }
+ }
+
+ function curl_init(string $url = '')
+ {
+ CurlStub::$requestedUrls[] = $url;
+ $handle = new \stdClass();
+ $handle->response = array_shift(CurlStub::$queue) ?? ['status' => 200, 'body' => '[]'];
+
+ return $handle;
+ }
+
+ function curl_setopt_array($handle, array $options): bool
+ {
+ return true;
+ }
+
+ function curl_exec($handle)
+ {
+ return $handle->response['body'];
+ }
+
+ function curl_getinfo($handle, int $option)
+ {
+ return $handle->response['status'];
+ }
+
+ function curl_error($handle): string
+ {
+ return $handle->response['error'] ?? '';
+ }
+
+ function curl_close($handle): void
+ {
+ }
+}
+
+namespace Monitor\Api\Tests\Sync {
+ use Monitor\Api\Sync\CurlCodebergClient;
+ use Monitor\Api\Sync\CurlStub;
+ use PHPUnit\Framework\TestCase;
+ use RuntimeException;
+
+ final class CurlCodebergClientTest extends TestCase
+ {
+ protected function setUp(): void
+ {
+ CurlStub::reset();
+ }
+
+ public function testListIssuesParsesSinglePage(): void
+ {
+ CurlStub::$queue = [
+ ['status' => 200, 'body' => json_encode([
+ ['number' => 1, 'title' => 'A', 'body' => 'desc', 'labels' => [['name' => 'bug', 'color' => 'ff0000']]],
+ ])],
+ ];
+
+ $client = new CurlCodebergClient('https://codeberg.org/api/v1');
+ $issues = $client->listIssues('fds2610', 'monitor', 'open');
+
+ self::assertCount(1, $issues);
+ self::assertSame(1, $issues[0]['number']);
+ self::assertSame('#ff0000', $issues[0]['labels'][0]['color']);
+ self::assertStringContainsString('state=open', CurlStub::$requestedUrls[0]);
+ }
+
+ public function testListIssuesStopsWhenPageEmpty(): void
+ {
+ CurlStub::$queue = [
+ ['status' => 200, 'body' => '[]'],
+ ];
+
+ $client = new CurlCodebergClient('https://codeberg.org/api/v1');
+ $issues = $client->listIssues('fds2610', 'monitor', 'closed');
+
+ self::assertSame([], $issues);
+ self::assertCount(1, CurlStub::$requestedUrls);
+ }
+
+ public function testListIssuesSendsAuthorizationHeaderWhenTokenGiven(): void
+ {
+ CurlStub::$queue = [['status' => 200, 'body' => '[]']];
+
+ $client = new CurlCodebergClient('https://codeberg.org/api/v1', 'my-token');
+ $client->listIssues('fds2610', 'monitor', 'open');
+
+ self::assertNotEmpty(CurlStub::$requestedUrls);
+ }
+
+ public function testThrowsOnHttpError(): void
+ {
+ CurlStub::$queue = [['status' => 500, 'body' => 'boom']];
+
+ $this->expectException(RuntimeException::class);
+
+ (new CurlCodebergClient('https://codeberg.org/api/v1'))->listIssues('fds2610', 'monitor', 'open');
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bd15d0d
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,21 @@
+services:
+ api:
+ build:
+ context: ./api
+ dockerfile: docker/Dockerfile
+ volumes:
+ - ./api:/app
+ # Optional: echte status.txt aus der Produktion zum Testen einbinden, z.B.
+ # - ./status.txt:/status.txt:ro
+ working_dir: /app
+ ports:
+ - "8080:8080"
+ environment:
+ MONITOR_DB_PATH: /app/data/monitor.sqlite
+ MONITOR_ADMIN_API_KEY: ${MONITOR_ADMIN_API_KEY:-dev-admin-key}
+ MONITOR_STATUS_FILE: /status.txt
+ MONITOR_DESK_COUNT: 8
+ CODEBERG_API_BASE: https://codeberg.org/api/v1
+ CODEBERG_OWNER: ${CODEBERG_OWNER:-fds2610}
+ CODEBERG_REPO: ${CODEBERG_REPO:-monitor}
+ CODEBERG_TOKEN: ${CODEBERG_TOKEN:-}